├── .vercelignore ├── public ├── bg-dark.png ├── bg-light.png ├── favicon.ico └── og-card.png ├── remix.env.d.ts ├── .eslintrc.js ├── .gitignore ├── remix.config.js ├── app ├── components │ ├── region.tsx │ ├── footer.tsx │ └── illustration.tsx ├── parse-vercel-id.ts ├── regions.ts ├── root.tsx ├── routes │ ├── edge.tsx │ ├── index.tsx │ ├── node.tsx │ └── node-streaming.tsx ├── nav.tsx └── styles │ └── main.css ├── README.md ├── tsconfig.json └── package.json /.vercelignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | .cache 4 | .env 5 | .vercel 6 | build 7 | public/build 8 | -------------------------------------------------------------------------------- /public/bg-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/remix-on-the-edge/HEAD/public/bg-dark.png -------------------------------------------------------------------------------- /public/bg-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/remix-on-the-edge/HEAD/public/bg-light.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/remix-on-the-edge/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/og-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vercel-labs/remix-on-the-edge/HEAD/public/og-card.png -------------------------------------------------------------------------------- /remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** @type {import('eslint').Linter.Config} */ 2 | module.exports = { 3 | extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"], 4 | }; 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | .cache 4 | .env 5 | .vercel 6 | .output 7 | .DS_Store 8 | 9 | /build/ 10 | /public/build 11 | /api/index.js 12 | /api/index.js.map 13 | -------------------------------------------------------------------------------- /remix.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ignoredRouteFiles: ["**/.*"], 3 | // appDirectory: "app", 4 | // assetsBuildDirectory: "public/build", 5 | // serverBuildPath: "build/index.js", 6 | // publicPath: "/build/", 7 | }; 8 | -------------------------------------------------------------------------------- /app/components/region.tsx: -------------------------------------------------------------------------------- 1 | import { regions } from "~/regions"; 2 | 3 | export function Region({ region }: { region: string }) { 4 | const name = regions[region] || "Unknown"; 5 | return ( 6 | 7 | {name} ({region}) 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /app/parse-vercel-id.ts: -------------------------------------------------------------------------------- 1 | export function parseVercelId(id: string | null) { 2 | const parts = id?.split(":").filter(Boolean); 3 | if (!parts) { 4 | console.log('"x-vercel-id" header not present. Running on localhost?'); 5 | return { proxyRegion: "localhost", computeRegion:"localhost" } 6 | } 7 | const proxyRegion = parts[0]; 8 | const computeRegion = parts[parts.length - 2]; 9 | return { proxyRegion, computeRegion } 10 | } 11 | -------------------------------------------------------------------------------- /app/regions.ts: -------------------------------------------------------------------------------- 1 | export const regions: Record = { 2 | sfo1: "San Francisco", 3 | iad1: "Washington, D.C.", 4 | pdx1: "Portland", 5 | cle1: "Cleveland", 6 | gru1: "São Paulo", 7 | hkg1: "Hong Kong", 8 | hnd1: "Tokyo", 9 | icn1: "Seoul", 10 | kix1: "Osaka", 11 | sin1: "Singapore", 12 | bom1: "Mumbai", 13 | syd1: "Sydney", 14 | cdg1: "Paris", 15 | arn1: "Stockholm", 16 | dub1: "Dublin", 17 | lhr1: "London", 18 | fra1: "Frankfurt", 19 | cpt1: "Cape Town", 20 | dev1: "localhost", 21 | }; 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Remix on Vercel Edge Functions 2 | 3 | > [!WARNING] 4 | > Edge Functions have been deprecated. We recommend using the full Node.js runtime with Fluid compute instead. [Learn more here](https://www.youtube.com/watch?v=G-ngjNfMnvE) or [read the docs](https://vercel.com/docs/functions/fluid-compute). Functions using Fluid retain the benefits of Edge Functions, without the downsides of the limited Edge runtime. Further, most workloads have 1-3 regions for their data storage, where you should co-locate your compute. Ensure your Vercel Function region is the same as your database. 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "moduleResolution": "node", 9 | "resolveJsonModule": true, 10 | "target": "ES2019", 11 | "strict": true, 12 | "allowJs": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "baseUrl": ".", 15 | "paths": { 16 | "~/*": ["./app/*"] 17 | }, 18 | 19 | // Remix takes care of building everything in `remix build`. 20 | "noEmit": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "sideEffects": false, 4 | "scripts": { 5 | "build": "remix build", 6 | "dev": "remix dev", 7 | "typecheck": "tsc" 8 | }, 9 | "dependencies": { 10 | "@remix-run/node": "^1.15.0", 11 | "@remix-run/react": "^1.15.0", 12 | "@vercel/remix": "1.15.0", 13 | "framer-motion": "^10.11.2", 14 | "isbot": "^3.6.8", 15 | "react": "^18.2.0", 16 | "react-dom": "^18.2.0" 17 | }, 18 | "devDependencies": { 19 | "@remix-run/dev": "^1.15.0", 20 | "@remix-run/eslint-config": "^1.15.0", 21 | "@remix-run/serve": "^1.15.0", 22 | "@types/react": "^18.0.33", 23 | "@types/react-dom": "^18.0.11", 24 | "eslint": "^8.38.0", 25 | "typescript": "^5.0.4" 26 | }, 27 | "engines": { 28 | "node": ">=14" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import type { MetaFunction, LinksFunction, LoaderArgs } from '@vercel/remix'; 2 | 3 | import { 4 | Links, 5 | LiveReload, 6 | Meta, 7 | Outlet, 8 | Scripts, 9 | ScrollRestoration, 10 | } from '@remix-run/react'; 11 | import NavigationSwitcher from '~/nav'; 12 | 13 | import mainCss from '~/styles/main.css'; 14 | 15 | export function loader({ request }: LoaderArgs) { 16 | return { 17 | host: request.headers.get('x-forwarded-host'), 18 | }; 19 | } 20 | 21 | export const meta: MetaFunction = ({ data: { host } }) => ({ 22 | charset: 'utf-8', 23 | title: 'Remix on Vercel Edge Functions', 24 | description: 'HTML, dynamically rendered in a city near you', 25 | 'twitter:card': 'summary_large_image', 26 | 'twitter:site': '@vercel', 27 | 'twitter:creator': '@vercel', 28 | 'twitter:title': 'Remix on Vercel Edge Functions', 29 | 'twitter:description': 'HTML, dynamically rendered in a city near you', 30 | 'twitter:image': `https://${host}/og-card.png`, 31 | 'twitter:image:alt': 'The Vercel and Remix logos', 32 | viewport: 'width=device-width,initial-scale=1', 33 | }); 34 | 35 | export const links: LinksFunction = () => { 36 | return [ 37 | { 38 | rel: 'stylesheet', 39 | href: mainCss, 40 | }, 41 | ]; 42 | }; 43 | 44 | export default function App() { 45 | return ( 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /app/routes/edge.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderArgs } from '@vercel/remix'; 2 | import { useLoaderData } from '@remix-run/react'; 3 | 4 | import { Footer } from '~/components/footer'; 5 | import { Region } from '~/components/region'; 6 | import { Illustration } from '~/components/illustration'; 7 | import { parseVercelId } from '~/parse-vercel-id'; 8 | 9 | export const config = { runtime: 'edge' }; 10 | 11 | let isCold = true; 12 | let initialDate = Date.now(); 13 | 14 | export async function loader({ request }: LoaderArgs) { 15 | const wasCold = isCold; 16 | isCold = false; 17 | 18 | const parsedId = parseVercelId(request.headers.get("x-vercel-id")); 19 | 20 | return { 21 | ...parsedId, 22 | isCold: wasCold, 23 | date: new Date().toISOString(), 24 | }; 25 | } 26 | 27 | export function headers() { 28 | return { 29 | 'x-edge-age': Date.now() - initialDate, 30 | }; 31 | } 32 | 33 | export default function App() { 34 | const { proxyRegion, computeRegion, isCold, date } = useLoaderData(); 35 | return ( 36 | <> 37 |
38 | 39 |
40 |
41 | Proxy Region 42 | 43 |
44 |
45 | Compute Region 46 | 47 |
48 |
49 |
50 | 51 | 59 | 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | import { defer } from '@vercel/remix'; 3 | import type { LoaderArgs } from '@vercel/remix'; 4 | import { Await, useLoaderData } from '@remix-run/react'; 5 | 6 | import { Footer } from '~/components/footer'; 7 | import { Region } from '~/components/region'; 8 | import { Illustration } from '~/components/illustration'; 9 | import { parseVercelId } from '~/parse-vercel-id'; 10 | 11 | export const config = { runtime: 'edge' }; 12 | 13 | let isCold = true; 14 | let initialDate = Date.now(); 15 | 16 | export async function loader({ request }: LoaderArgs) { 17 | const wasCold = isCold; 18 | isCold = false; 19 | 20 | const parsedId = parseVercelId(request.headers.get("x-vercel-id")); 21 | 22 | return defer({ 23 | isCold: wasCold, 24 | proxyRegion: sleep(parsedId.proxyRegion, 1000), 25 | computeRegion: sleep(parsedId.computeRegion, 1500), 26 | date: new Date().toISOString(), 27 | }); 28 | } 29 | 30 | function sleep(val: any, ms: number) { 31 | return new Promise((resolve) => setTimeout(() => resolve(val), ms)); 32 | } 33 | 34 | export function headers() { 35 | return { 36 | 'x-edge-age': Date.now() - initialDate, 37 | }; 38 | } 39 | 40 | export default function App() { 41 | const { proxyRegion, computeRegion, isCold, date } = useLoaderData(); 42 | return ( 43 | <> 44 |
45 | 46 |
47 |
48 | Proxy Region 49 | Loading...}> 50 | 51 | {(region) => } 52 | 53 | 54 |
55 |
56 | Compute Region 57 | Loading...}> 58 | 59 | {(region) => } 60 | 61 | 62 |
63 |
64 |
65 | 66 | 78 | 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /app/nav.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from 'framer-motion'; 2 | import { NavLink, useLocation } from '@remix-run/react'; 3 | 4 | const SECTION_DATA = [ 5 | { label: 'Edge (Streaming)', href: '/', x: '100%' }, 6 | { label: 'Edge', href: '/edge', x: '62%' }, 7 | { label: 'Node.js (Streaming)', href: '/node-streaming', x: '38%' }, 8 | { label: 'Node.js', href: '/node', x: '0%' }, 9 | ]; 10 | 11 | export default function NavigationSwitcher() { 12 | const { pathname } = useLocation(); 13 | const activeSection = SECTION_DATA.find( 14 | (section) => section.href === pathname 15 | ); 16 | 17 | const buttons = SECTION_DATA.map((section) => { 18 | return ( 19 | 22 | `nav-link ${isActive ? 'active' : isPending ? 'pending' : ''}` 23 | } 24 | key={section.label} 25 | > 26 | {({ isActive }) => { 27 | return ( 28 | <> 29 |
30 | {section.label} 31 |
32 | {isActive ? ( 33 | <> 34 | 47 | 58 | 59 | ) : null} 60 | 61 | ); 62 | }} 63 |
64 | ); 65 | }); 66 | 67 | return ( 68 | <> 69 | 81 |

82 | Note: This demo simulates a slow database or backend connection to 83 | demonstrate streaming. 84 |

85 | 86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /app/components/footer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export function Footer({ children }: React.PropsWithChildren<{}>) { 4 | return ( 5 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /app/routes/node.tsx: -------------------------------------------------------------------------------- 1 | import type { LoaderArgs } from '@vercel/remix'; 2 | import { useLoaderData } from '@remix-run/react'; 3 | 4 | import { Footer } from '~/components/footer'; 5 | import { Region } from '~/components/region'; 6 | import { Illustration } from '~/components/illustration'; 7 | 8 | let isCold = true; 9 | let initialDate = Date.now(); 10 | 11 | export async function loader({ request }: LoaderArgs) { 12 | const wasCold = isCold; 13 | isCold = false; 14 | 15 | // `process.versions.node` only exists in the Node.js runtime, naturally 16 | const version = process.versions.node; 17 | 18 | const region = process.env.VERCEL_REGION; 19 | if (!region) { 20 | throw new Error('`VERCEL_REGION` is not defined'); 21 | } 22 | 23 | return { 24 | isCold: wasCold, 25 | region, 26 | version, 27 | date: new Date().toISOString(), 28 | }; 29 | } 30 | 31 | export function headers() { 32 | return { 33 | 'x-serverless-age': Date.now() - initialDate, 34 | }; 35 | } 36 | 37 | export default function App() { 38 | const { version, region, isCold, date } = useLoaderData(); 39 | return ( 40 | <> 41 |
42 | 43 |
44 |
45 | 46 | 47 | Node.js Version 48 | 49 | {version} 50 |
51 |
52 | Compute Region 53 | 54 |
55 |
56 |
57 | 58 | 66 | 67 | ); 68 | } 69 | 70 | function Nodejs(props: React.HTMLAttributes) { 71 | return ( 72 | 73 | 84 | 88 | 89 | 90 | 94 | 95 | 106 | 110 | 111 | 112 | 116 | 117 | 128 | 132 | 133 | 134 | 135 | 136 | 137 | 145 | 146 | 147 | 148 | 149 | 157 | 158 | 159 | 160 | 161 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | ); 178 | } 179 | -------------------------------------------------------------------------------- /app/routes/node-streaming.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react'; 2 | import { defer } from '@vercel/remix'; 3 | import type { LoaderArgs } from '@vercel/remix'; 4 | import { Await, useLoaderData } from '@remix-run/react'; 5 | 6 | import { Footer } from '~/components/footer'; 7 | import { Region } from '~/components/region'; 8 | import { Illustration } from '~/components/illustration'; 9 | 10 | let isCold = true; 11 | let initialDate = Date.now(); 12 | 13 | export async function loader({ request }: LoaderArgs) { 14 | const wasCold = isCold; 15 | isCold = false; 16 | 17 | // `process.versions.node` only exists in the Node.js runtime, naturally 18 | const version: string = process.versions.node; 19 | 20 | const region = process.env.VERCEL_REGION; 21 | if (!region) { 22 | throw new Error('`VERCEL_REGION` is not defined'); 23 | } 24 | 25 | return defer({ 26 | isCold: wasCold, 27 | version: sleep(version, 1000), 28 | region: sleep(region, 1500), 29 | date: new Date().toISOString(), 30 | }); 31 | } 32 | 33 | function sleep(val: T, ms: number) { 34 | return new Promise((resolve) => setTimeout(() => resolve(val), ms)); 35 | } 36 | 37 | export function headers() { 38 | return { 39 | 'x-serverless-age': Date.now() - initialDate, 40 | }; 41 | } 42 | 43 | export default function App() { 44 | const { version, region, isCold, date } = useLoaderData(); 45 | return ( 46 | <> 47 |
48 | 49 |
50 |
51 | 52 | 53 | Node.js Version 54 | 55 | Loading...}> 56 | 57 | {(version) => {version}} 58 | 59 | 60 |
61 |
62 | Compute Region 63 | Loading...}> 64 | 65 | {(region) => } 66 | 67 | 68 |
69 |
70 |
71 | 72 | 85 | 86 | ); 87 | } 88 | 89 | function Nodejs(props: React.HTMLAttributes) { 90 | return ( 91 | 92 | 103 | 107 | 108 | 109 | 113 | 114 | 125 | 129 | 130 | 131 | 135 | 136 | 147 | 151 | 152 | 153 | 154 | 155 | 156 | 164 | 165 | 166 | 167 | 168 | 176 | 177 | 178 | 179 | 180 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | ); 197 | } 198 | -------------------------------------------------------------------------------- /app/styles/main.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | } 5 | 6 | *, 7 | *::before, 8 | *::after { 9 | box-sizing: border-box; 10 | -webkit-font-smoothing: antialiased; 11 | -moz-osx-font-smoothing: grayscale; 12 | } 13 | 14 | body { 15 | --fg: black; 16 | --bg: white; 17 | --remix: #2f77d1; 18 | 19 | --accents-1: #fafafa; 20 | --accents-2: #eaeaea; 21 | --accents-3: #999999; 22 | --accents-4: #888888; 23 | --accents-5: #666666; 24 | --accents-6: #444444; 25 | --accents-7: #333333; 26 | --accents-8: #111111; 27 | 28 | --nav-border: #bebebe80; 29 | --nav-background: #fff; 30 | --nav-text: #999; 31 | --nav-text-active: #000; 32 | --nav-pill: radial-gradient(#dadada 0%, #f1f1f1 100%); 33 | --root-padding: 16px; 34 | 35 | display: flex; 36 | flex-direction: column; 37 | margin: 0; 38 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, 39 | Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 40 | background: var(--bg); 41 | color: var(--fg); 42 | padding: 0 var(--root-padding); 43 | background-image: url(/bg-light.png); 44 | background-size: cover; 45 | background-position: center center; 46 | background-repeat: no-repeat; 47 | } 48 | 49 | h1, 50 | h2, 51 | h3, 52 | h4, 53 | p { 54 | margin: 0; 55 | } 56 | 57 | ::selection { 58 | background: var(--remix); 59 | color: var(--bg); 60 | } 61 | 62 | @media (prefers-color-scheme: dark) { 63 | body { 64 | --fg: white; 65 | --bg: black; 66 | 67 | --accents-8: #fafafa; 68 | --accents-7: #eaeaea; 69 | --accents-6: #999999; 70 | --accents-5: #888888; 71 | --accents-4: #666666; 72 | --accents-3: #444444; 73 | --accents-2: #333333; 74 | --accents-1: #111111; 75 | 76 | --nav-border: #44444480; 77 | --nav-background: #000; 78 | --nav-text-active: #fff; 79 | --nav-pill: radial-gradient(#505050 0%, #292929 100%); 80 | 81 | background-image: url(/bg-dark.png); 82 | } 83 | } 84 | 85 | /* main */ 86 | 87 | body a { 88 | border-radius: 3px; 89 | } 90 | 91 | body a:focus-visible { 92 | box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accents-4); 93 | outline: 0; 94 | text-decoration: none; 95 | } 96 | 97 | main { 98 | display: flex; 99 | align-items: center; 100 | justify-content: center; 101 | flex-direction: column; 102 | position: relative; 103 | width: 100%; 104 | height: 100%; 105 | max-width: 720px; 106 | margin: 0 auto; 107 | overflow: hidden; 108 | } 109 | 110 | .gradient { 111 | position: fixed; 112 | top: 0; 113 | right: 0; 114 | z-index: -1; 115 | pointer-events: none; 116 | } 117 | 118 | .gradient[data-theme='dark'] { 119 | display: none; 120 | } 121 | 122 | .illustration { 123 | width: 100%; 124 | min-width: 480px; 125 | position: absolute; 126 | top: 50%; 127 | transform: translateY(-50%); 128 | pointer-events: none; 129 | } 130 | 131 | .illustration[data-theme='dark'] { 132 | display: none; 133 | } 134 | 135 | .meta { 136 | display: grid; 137 | grid-template-columns: repeat(2, 1fr); 138 | align-items: center; 139 | justify-content: center; 140 | width: 100%; 141 | gap: 0px; 142 | margin-top: 40vh; 143 | } 144 | 145 | .info { 146 | display: flex; 147 | flex-direction: column; 148 | align-items: center; 149 | text-align: center; 150 | gap: 12px; 151 | } 152 | 153 | .info span { 154 | white-space: nowrap; 155 | display: flex; 156 | width: fit-content; 157 | align-items: center; 158 | gap: 8px; 159 | font-size: clamp(14px, 2vw, 16px); 160 | color: var(--accents-5); 161 | } 162 | 163 | .info span.region strong { 164 | color: var(--fg); 165 | } 166 | 167 | .info span svg { 168 | width: 18px; 169 | height: 18px; 170 | } 171 | 172 | .info strong { 173 | line-height: 1.2; 174 | font-size: clamp(18px, 5vw, 40px); 175 | } 176 | 177 | @keyframes spin { 178 | 0% { 179 | transform: rotate(0deg); 180 | } 181 | 100% { 182 | transform: rotate(-360deg); 183 | } 184 | } 185 | 186 | @keyframes scale { 187 | 0% { 188 | opacity: 0; 189 | } 190 | 100% { 191 | opacity: var(--circle-opacity); 192 | } 193 | } 194 | 195 | .illustration circle { 196 | opacity: 0; 197 | animation: scale 0.5s ease forwards; 198 | } 199 | 200 | .illustration circle[data-index='0'] { 201 | animation-delay: 0.1s; 202 | } 203 | 204 | .illustration circle[data-index='1'] { 205 | animation-delay: 0.2s; 206 | } 207 | 208 | .illustration circle[data-index='2'] { 209 | animation-delay: 0.3s; 210 | } 211 | 212 | .illustration circle[data-index='3'] { 213 | animation-delay: 0.4s; 214 | } 215 | 216 | .illustration circle[data-index='4'] { 217 | animation-delay: 0.5s; 218 | } 219 | 220 | .orbit { 221 | opacity: 0; 222 | animation: scale 1s ease forwards; 223 | } 224 | 225 | .orbit[data-index='0'] { 226 | animation-delay: 0.5s; 227 | } 228 | 229 | .orbit[data-index='1'] { 230 | animation-delay: 0.6s; 231 | } 232 | 233 | .orbit[data-index='2'] { 234 | animation-delay: 0.25s; 235 | } 236 | 237 | .orbit[data-index='3'] { 238 | animation-delay: 0.3s; 239 | } 240 | 241 | .orbit[data-index='4'] { 242 | animation-delay: 0.6s; 243 | } 244 | 245 | .illustration .orbits { 246 | transform-origin: center center; 247 | } 248 | 249 | .illustration .orbits > g { 250 | animation: spin 60s linear both infinite; 251 | } 252 | 253 | .illustration .orbits > g:nth-child(2) { 254 | animation-duration: 80s; 255 | } 256 | 257 | .illustration .orbits > g:nth-child(3) { 258 | animation-duration: 100s; 259 | } 260 | 261 | .illustration .orbits > g:nth-child(4) { 262 | animation-duration: 120s; 263 | } 264 | 265 | /* footer */ 266 | 267 | footer { 268 | display: flex; 269 | justify-content: space-between; 270 | align-items: flex-end; 271 | position: relative; 272 | bottom: 0; 273 | left: 0; 274 | width: 100%; 275 | text-align: center; 276 | padding: 48px; 277 | box-sizing: border-box; 278 | font-size: 16px; 279 | } 280 | 281 | footer p { 282 | line-height: 20px; 283 | color: var(--accents-7); 284 | } 285 | 286 | footer a { 287 | height: fit-content; 288 | } 289 | 290 | footer a:hover { 291 | text-decoration: hover; 292 | } 293 | 294 | footer .details { 295 | display: flex; 296 | flex-direction: column; 297 | gap: 12px; 298 | font-size: inherit; 299 | color: var(--fg); 300 | } 301 | 302 | footer .details a { 303 | color: inherit; 304 | text-decoration-color: var(--mono8); 305 | text-decoration-thickness: 1px; 306 | text-underline-offset: 3px; 307 | } 308 | 309 | footer .source { 310 | display: flex; 311 | align-items: center; 312 | justify-content: flex-end; 313 | gap: 8px; 314 | font-size: inherit; 315 | color: var(--accents-8); 316 | text-decoration: none; 317 | } 318 | 319 | .vercel { 320 | height: 24px; 321 | } 322 | 323 | /* nav */ 324 | 325 | nav { 326 | top: 0; 327 | margin-top: 64px; 328 | font-size: 14px; 329 | position: fixed; 330 | width: fit-content; 331 | z-index: 10; 332 | left: 50%; 333 | transform: translateX(-50%); 334 | max-width: 100%; 335 | 336 | border: 1px solid var(--accents-2); 337 | border-radius: 9999px; 338 | box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12); 339 | position: relative; 340 | z-index: 10; 341 | } 342 | 343 | nav:after { 344 | content: ''; 345 | background: linear-gradient( 346 | to left, 347 | var(--accents-2) 20%, 348 | var(--accents-2) 44%, 349 | var(--accents-6) 50%, 350 | var(--accents-3) 60%, 351 | var(--accents-2) 63%, 352 | var(--accents-2) 100% 353 | ); 354 | z-index: -1; 355 | background-position-x: var(--x); 356 | background-size: 200% auto; 357 | position: absolute; 358 | border-radius: inherit; 359 | bottom: -1px; 360 | left: 0; 361 | width: 100%; 362 | height: 100%; 363 | transition: background-position-x 600ms ease; 364 | } 365 | 366 | .nav-switcher { 367 | width: 100%; 368 | overflow-x: auto; 369 | overflow-y: hidden; 370 | border-radius: inherit; 371 | display: flex; 372 | padding: 4px; 373 | background: var(--accents-1); 374 | } 375 | 376 | .nav-link { 377 | display: flex; 378 | align-items: center; 379 | border-radius: inherit; 380 | height: 32px; 381 | border: 0; 382 | font-family: var(--font-sans); 383 | padding: 0 16px; 384 | font-size: 14px; 385 | background: transparent; 386 | color: var(--accents-5); 387 | position: relative; 388 | cursor: pointer; 389 | transition: color 150ms ease; 390 | text-decoration: none; 391 | white-space: nowrap; 392 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 393 | } 394 | 395 | .nav-link.active { 396 | color: var(--accents-8); 397 | text-shadow: 1px 1px 12px rgba(255, 255, 255, 0.4); 398 | } 399 | 400 | .nav-link:focus-visible { 401 | outline: 0; 402 | box-shadow: 0 0 0 2px var(--accents-4); 403 | } 404 | 405 | .nav-stroke { 406 | background: linear-gradient( 407 | 90deg, 408 | rgba(0, 0, 0, 0), 409 | var(--accents-4) 20%, 410 | var(--accents-2) 67.19%, 411 | rgba(0, 0, 0, 0) 412 | ); 413 | height: 1px; 414 | position: absolute; 415 | top: -1px; 416 | width: 90%; 417 | left: 32px; 418 | z-index: -1; 419 | } 420 | 421 | .nav-glow { 422 | background: white; 423 | width: 50%; 424 | height: 50px; 425 | border-radius: inherit; 426 | position: absolute; 427 | z-index: 1; 428 | filter: blur(7px); 429 | bottom: -52px; 430 | left: 25%; 431 | } 432 | 433 | .nav-pill { 434 | position: absolute; 435 | width: 100%; 436 | height: 100%; 437 | top: 0; 438 | left: 0; 439 | border-radius: inherit; 440 | background: rgba(255, 255, 255, 0.08); 441 | } 442 | 443 | @media (max-width: 420px) { 444 | .nav-link { 445 | padding: 0 12px; 446 | } 447 | } 448 | 449 | @media (hover: hover) and (pointer: fine) { 450 | .nav-link:hover { 451 | color: var(--nav-text-active); 452 | } 453 | } 454 | 455 | @media (prefers-color-scheme: light) { 456 | nav:after { 457 | display: none; 458 | } 459 | 460 | .nav-stroke { 461 | opacity: 0.2; 462 | } 463 | 464 | nav { 465 | border: 1px solid rgba(0, 0, 0, 0.12); 466 | background-clip: padding-box; 467 | box-shadow: 0 5px 10px rgba(0, 0, 0, 0.05); 468 | } 469 | 470 | .nav-switcher { 471 | background: rgba(255, 255, 255, 0.1); 472 | backdrop-filter: blur(16px); 473 | } 474 | 475 | .nav-pill { 476 | background: radial-gradient( 477 | 132.5% 137.28% at 69.9% 88.75%, 478 | #dadada 0%, 479 | #f1f1f1 100% 480 | ); 481 | } 482 | 483 | .nav-link { 484 | color: var(--accents-3); 485 | } 486 | } 487 | 488 | @media (prefers-color-scheme: dark) { 489 | .gradient[data-theme='dark'] { 490 | display: block; 491 | } 492 | .gradient[data-theme='light'] { 493 | display: none; 494 | } 495 | .details p:nth-of-type(2) { 496 | color: var(--accents-5); 497 | } 498 | .illustration[data-theme='dark'] { 499 | display: block; 500 | } 501 | .illustration[data-theme='light'] { 502 | display: none; 503 | } 504 | } 505 | 506 | @media (max-width: 960px) { 507 | footer { 508 | flex-direction: column; 509 | align-items: center; 510 | gap: 16px; 511 | padding: 32px 16px; 512 | font-size: 13px; 513 | } 514 | 515 | footer [data-break] { 516 | display: block; 517 | } 518 | 519 | .source svg { 520 | width: 16px; 521 | height: 16px; 522 | } 523 | 524 | .source { 525 | margin-top: 4px; 526 | } 527 | 528 | nav { 529 | margin-top: 32px; 530 | } 531 | } 532 | 533 | @media (max-width: 600px) { 534 | .meta { 535 | gap: 8px; 536 | } 537 | 538 | footer { 539 | gap: 12px; 540 | } 541 | 542 | .info { 543 | gap: 8px; 544 | } 545 | 546 | .info span svg { 547 | width: 14px; 548 | height: 14px; 549 | } 550 | 551 | .vercel, 552 | .vercel svg { 553 | height: 18px; 554 | } 555 | } 556 | 557 | .note { 558 | font-size: 12px; 559 | color: var(--accents-4); 560 | max-width: 320px; 561 | text-align: center; 562 | top: 0; 563 | margin-top: 12px; 564 | position: fixed; 565 | z-index: 10; 566 | left: 50%; 567 | transform: translateX(-50%); 568 | position: relative; 569 | } 570 | -------------------------------------------------------------------------------- /app/components/illustration.tsx: -------------------------------------------------------------------------------- 1 | const ILLUSTRATION_ARIA_LABEL = 2 | 'Vercel and Remix logos side-by-side, surrounded by multiple growing ellipses with orbiting circles on top.'; 3 | 4 | export function Illustration() { 5 | return ( 6 | <> 7 | 21 | 30 | 39 | 48 | 57 | 66 | 82 | 98 | 114 | 130 | 146 | 147 | 154 | 161 | 162 | 168 | 174 | 178 | 182 | 188 | 192 | 198 | 204 | 205 | 206 | 214 | 215 | 216 | 217 | 225 | 226 | 227 | 228 | 236 | 237 | 238 | 239 | 247 | 248 | 249 | 250 | 251 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 282 | 290 | 298 | 306 | 314 | 322 | 323 | 324 | 332 | 333 | 334 | 335 | 343 | 344 | 345 | 346 | 354 | 355 | 356 | 357 | 365 | 366 | 367 | 368 | 376 | 377 | 378 | 385 | 392 | 393 | 399 | 403 | 404 | 405 | 414 | 415 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 436 | 437 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | 458 | 459 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 480 | 481 | 487 | 488 | 489 | 490 | 491 | 492 | 493 | 502 | 503 | 509 | 510 | 511 | 512 | 513 | 514 | 515 | 524 | 525 | 531 | 532 | 533 | 534 | 535 | 536 | 542 | 543 | 544 | 545 | 546 | 547 | 548 | 549 | 557 | 558 | 559 | 560 | 568 | 569 | 570 | 571 | 579 | 580 | 581 | 582 | 590 | 591 | 592 | 593 | 594 | 602 | 603 | 604 | 605 | 606 | 607 | 608 | 609 | ); 610 | } 611 | --------------------------------------------------------------------------------