diff --git a/.cursor/skills/23rd/SKILL.md b/.cursor/skills/23rd/SKILL.md index 74c8f92..fcc26ed 100644 --- a/.cursor/skills/23rd/SKILL.md +++ b/.cursor/skills/23rd/SKILL.md @@ -3,8 +3,8 @@ name: 23rd description: > Install, choose, and compose 23rd shadcn/ui registry components for React and Svelte 5. Use when the user wants a UI component, background, shader, WebGL - wash, hero atmosphere, footer, 404, color picker, ASCII effect, starfield, - orb, or page-tilt from the 23rd registry (@23rd, 23rd.dev, radiumcoders/23rd.dev), + wash, hero atmosphere, footer, 404, color picker, typewriter text, ASCII effect, + starfield, orb, or page-tilt from the 23rd registry (@23rd, 23rd.dev, radiumcoders/23rd.dev), or when adding a new component to this registry. Covers install commands, import paths, props, theming, and when not to use each item. metadata: @@ -74,7 +74,7 @@ npm dependencies the registry declares: | Item | React | Svelte | | --- | --- | --- | -| `gooey-color-picker`, `tangle-footer`, `stretchy-footer` | `motion` | none | +| `gooey-color-picker` | `motion` | none | | every other item | none | none | Published index: `https://23rd.dev/r/registry.json`. Docs: `https://23rd.dev/docs`. @@ -91,15 +91,16 @@ flowchart TD pick -->|Full page 404| d404[dithered-404] pick -->|Scroll makes the page lean| folio[folio] pick -->|Character or wordmark| mark{Which} + pick -->|Type text out letter by letter| tw[typewriter] atmo -->|Quiet wash| sg[shader-gradient] - atmo -->|Fire from the bottom| sf[shader-fire] + atmo -->|Cel-shaded fire from the bottom| saf[shader-anime-fire] + atmo -->|Soft fire wash from the bottom| sf[shader-fire] atmo -->|Sky or rain, optional glass| ss[shader-sky] atmo -->|ASCII mouse trails| af[ascii-fluid] atmo -->|Hyperspace streaks, scroll warp| rl[radiant-lines] atmo -->|Hair-line explosion| lb[logo-burst] atmo -->|Falling sheet music| ps[phosphor-score] foot -->|Rubber overscroll aurora| st[stretchy-footer] - foot -->|Spinning text ribbons| tf[tangle-footer] mark -->|Eyes that track the pointer| orb[live-orb] mark -->|ASCII wordmark, click to scatter| al[ascii-logo] ``` @@ -109,17 +110,18 @@ Disambiguation that agents get wrong: | User says | Use | Not | | --- | --- | --- | | Color picker, swatch, hex, hue, alpha, eyedropper | `gooey-color-picker` | Any shader. Shaders are not controls. | -| Soft gradient behind a headline | `shader-gradient` | `gooey-color-picker`, `tangle-footer` | -| Fire, embers, heat under a hero | `shader-fire` | `dithered-404` unless the page is a 404 | -| 404 that burns under the cursor | `dithered-404` | `shader-fire` | -| Footer of nested spinning sentences | `tangle-footer` | `stretchy-footer` | -| Overscroll rubber band, aurora at the bottom | `stretchy-footer` | `tangle-footer` | +| Soft gradient behind a headline | `shader-gradient` | `gooey-color-picker` | +| Fire, heat under a hero | `shader-anime-fire` | `dithered-404` unless the page is a 404 | +| Quiet heat wash behind copy | `shader-fire` | `shader-anime-fire` when it should be the show | +| 404 that burns under the cursor | `dithered-404` | `shader-anime-fire` | +| Overscroll rubber band, aurora at the bottom | `stretchy-footer` | `folio` | | Page leans while scrolling | `folio` | `stretchy-footer` (that one stretches, it does not tilt) | | Stars that speed up when you scroll | `radiant-lines` | `logo-burst` | | Logo explodes into lines | `logo-burst` | `ascii-logo` | | ASCII letters that shove, scatter, and fall | `ascii-logo` | `ascii-fluid` (trails, not a wordmark) | | CRT notation, phosphor, staves | `phosphor-score` | `shader-sky` | | A face / orb / mascot | `live-orb` | `logo-burst` | +| Typing effect, typewriter, text that types itself | `typewriter` | `ascii-logo` (a wordmark, not prose) | There is one variant enum in the whole registry: `LiveOrb` `variant` is `"white" | "black" | "webgl" | "custom"`. Nothing else has `variant`. @@ -134,13 +136,13 @@ Backgrounds and shaders fill the parent. They do not create a page. ``` -Same shell for `ShaderFire`, `ShaderSky`, `AsciiFluid`, `LogoBurst`, `PhosphorScore`, and `Dithered404`. Give the parent a height. Put UI in a later stacking context (`relative z-10`). +Same shell for `ShaderAnimeFire`, `ShaderFire`, `ShaderSky`, `AsciiFluid`, `LogoBurst`, `PhosphorScore`, and `Dithered404`. Give the parent a height. Put UI in a later stacking context (`relative z-10`). `RadiantLines` is transparent and warps with scroll. Inside an overflow div, pass the scroller (`containerRef` in React, `container` in Svelte) and make the canvas `sticky top-0 h-svh`. Omit the scroller to use the window. `Folio` and `StretchyFooter` are the scroller by default. Put the page in `children`. For a real document, set `windowScroll` and mark the tilting or lifting element (`data-folio-page` or `data-stretchy-page`). -`TangleFooter` is a `\n\n\n", - "type": "registry:file", - "target": "src/lib/components/ui/tangle-footer.svelte" - } - ], - "type": "registry:ui" -} \ No newline at end of file diff --git a/public/r/tangle-footer.json b/public/r/tangle-footer.json deleted file mode 100644 index 50f6ffa..0000000 --- a/public/r/tangle-footer.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry-item.json", - "name": "tangle-footer", - "title": "Tangle Footer", - "description": "A footer of tangled SVG ribbons — thick dark strokes with white text running along each path via textPath.", - "dependencies": [ - "motion" - ], - "files": [ - { - "path": "registry/tangle-footer/tangle-footer.tsx", - "content": "\"use client\"\n\nimport { useEffect, useId, useMemo, useRef, useState } from \"react\"\nimport { motion, useReducedMotion } from \"motion/react\"\nimport { cn } from \"@/lib/utils\"\n\nexport const DEFAULT_LINES = [\n \"Ship something opinionated — less boilerplate, clearer decisions.\",\n \"Knows what’s going on. Can you check in with them and see what’s next.\",\n \"The new timeline should be ready by Friday, although it’s probably going to slip.\",\n \"Open the docs, grab a component, and make it yours in the codebase.\",\n \"Radiant lines, shader wash, gooey picker — install what you need and move.\",\n]\n\nexport type Ring = {\n d: string\n cx: number\n cy: number\n strokeWidth: number\n fontSize: number\n /** Text repeated enough to blanket the full circumference. */\n text: string\n /** Seconds for one full revolution. */\n duration: number\n /** Fraction of a turn (0–1) to offset the starting rotation. */\n phase: number\n reverse: boolean\n}\n\nexport type TangleFooterOptions = {\n /** Phrases sampled randomly onto rings. */\n lines?: string[]\n /**\n * Ribbon (stroke) color. Omit for theme-aware defaults\n * (near-black in light, soft cream in dark).\n */\n ribbon?: string\n /**\n * Text fill on the ribbons. Omit for theme-aware defaults\n * (cream in light, near-black in dark).\n */\n textColor?: string\n /**\n * Field behind the rings. Omit for theme-aware defaults\n * (warm cream in light, near-black in dark). Pass `\"transparent\"`\n * when the parent already paints the stage.\n */\n background?: string\n /**\n * Band height in px. Omit to use half the measured width\n * (upper semicircle of a full-width nest). When set shorter\n * than that, the nest scales down to fit so rings stay intact.\n */\n height?: number\n /** Seed for random line / marquee assignment across rings. */\n seed?: number\n /** Accessible label. */\n label?: string\n}\n\nexport const RING_COUNT = 5\n\nexport const K = 0.5522847498\nexport const STROKE = 28\n\n/** Mulberry32 — deterministic PRNG so rings stay stable across re-renders. */\nexport function mulberry32(seed: number) {\n let t = seed >>> 0\n return () => {\n t += 0x6d2b79f5\n let r = Math.imul(t ^ (t >>> 15), 1 | t)\n r ^= r + Math.imul(r ^ (r >>> 7), 61 | r)\n return ((r ^ (r >>> 14)) >>> 0) / 4294967296\n }\n}\n\n/** Perfect circle as four cubics (container clips to the upper half). */\nexport function circlePath(cx: number, cy: number, r: number): string {\n const o = r * K\n return [\n `M ${(cx + r).toFixed(1)} ${cy.toFixed(1)}`,\n `C ${(cx + r).toFixed(1)} ${(cy + o).toFixed(1)} ${(cx + o).toFixed(1)} ${(cy + r).toFixed(1)} ${cx.toFixed(1)} ${(cy + r).toFixed(1)}`,\n `C ${(cx - o).toFixed(1)} ${(cy + r).toFixed(1)} ${(cx - r).toFixed(1)} ${(cy + o).toFixed(1)} ${(cx - r).toFixed(1)} ${cy.toFixed(1)}`,\n `C ${(cx - r).toFixed(1)} ${(cy - o).toFixed(1)} ${(cx - o).toFixed(1)} ${(cy - r).toFixed(1)} ${cx.toFixed(1)} ${(cy - r).toFixed(1)}`,\n `C ${(cx + o).toFixed(1)} ${(cy - r).toFixed(1)} ${(cx + r).toFixed(1)} ${(cy - o).toFixed(1)} ${(cx + r).toFixed(1)} ${cy.toFixed(1)}`,\n ].join(\" \")\n}\n\n/**\n * Tile copy so the ring is fully blanketed with no visible gap.\n * Rotation makes the loop seamless, so we only need to cover the\n * circumference — no exact tile-width matching required.\n */\nexport function buildRingCopy(\n line: string,\n other: string,\n mix: boolean,\n circumference: number,\n fontSize: number\n): string {\n const unit = mix\n ? `${line} · ${other} · `\n : `${line} · ${line} · ${other} · `\n // Bold + letter-spacing runs wider than 0.5em — over-estimate so we never underfill.\n const unitWidth = Math.max(unit.length * fontSize * 0.72, 1)\n const repeats = Math.max(3, Math.ceil(circumference / unitWidth) + 1)\n return unit.repeat(repeats)\n}\n\n/**\n * Five concentric circles nested as an upper semicircle.\n * Outer radius fits both width and band height so a short band\n * still shows complete arches instead of an equatorial clip.\n */\nexport function buildRings(\n width: number,\n bandHeight: number,\n lines: string[],\n seed: number\n): Ring[] {\n const rand = mulberry32(seed)\n const cx = width / 2\n const cy = bandHeight\n const strokePad = STROKE / 2 + 2\n // Full-width nest needs height ≈ width/2; if the band is shorter,\n // shrink the nest so the upper semicircle stays fully visible.\n const outer = Math.max(\n Math.min(width / 2 - strokePad, bandHeight - strokePad),\n STROKE * 4\n )\n const radii = Array.from(\n { length: RING_COUNT },\n (_, i) => (outer * (i + 1)) / RING_COUNT\n )\n\n const fontSize = Math.min(24, Math.max(16, width * 0.022))\n const pool = lines.length > 0 ? lines : DEFAULT_LINES\n\n return radii.map((r, i) => {\n const line = pool[Math.floor(rand() * pool.length)]!\n const other = pool[Math.floor(rand() * pool.length)]!\n const circumference = 2 * Math.PI * r\n const text = buildRingCopy(\n line,\n other,\n rand() > 0.45,\n circumference,\n fontSize\n )\n\n return {\n d: circlePath(cx, cy, r),\n cx,\n cy,\n strokeWidth: STROKE,\n fontSize,\n text,\n // Keep angular pace lively but readable; outer rings a touch slower.\n duration: 42 + i * 8 + rand() * 10,\n phase: rand(),\n // Alternate direction each ring (inner → outer)\n reverse: i % 2 === 1,\n }\n })\n}\n\nexport type TangleFooterProps = TangleFooterOptions & {\n className?: string\n}\n\n/**\n * Footer of five nested text rings — full-width upper semicircle.\n * The marquee is a continuous rotation of each ring around its center.\n * A full turn is inherently seamless, so it runs on GPU-friendly CSS\n * transforms with no per-frame layout work.\n */\nexport function TangleFooter({\n className,\n lines = DEFAULT_LINES,\n ribbon,\n textColor,\n background,\n height,\n seed = 23,\n label = \"Site footer\",\n}: TangleFooterProps) {\n const reduce = useReducedMotion() ?? false\n const uid = useId().replace(/:/g, \"\")\n const rootRef = useRef(null)\n const [width, setWidth] = useState(0)\n const [paused, setPaused] = useState(false)\n\n useEffect(() => {\n const el = rootRef.current\n if (!el) return\n\n const measure = () => {\n setWidth(el.clientWidth)\n }\n\n measure()\n const ro = new ResizeObserver(measure)\n ro.observe(el)\n return () => ro.disconnect()\n }, [])\n\n // Pause the animation while off-screen so it never wastes frames.\n useEffect(() => {\n const el = rootRef.current\n if (!el) return\n\n const io = new IntersectionObserver(\n ([entry]) => setPaused(!(entry?.isIntersecting ?? true)),\n { rootMargin: \"64px\", threshold: 0 }\n )\n io.observe(el)\n return () => io.disconnect()\n }, [])\n\n const bandHeight = height ?? (width > 0 ? width / 2 : 0)\n const rings = useMemo(\n () =>\n width > 0 && bandHeight > 0\n ? buildRings(width, bandHeight, lines, seed)\n : [],\n [width, bandHeight, lines, seed]\n )\n\n const spinName = `tangle-spin-${uid}`\n\n return (\n 0 ? bandHeight : undefined,\n aspectRatio: height == null ? \"2 / 1\" : undefined,\n }}\n >\n \n\n {width > 0 && bandHeight > 0 ? (\n \n \n {rings.map((ring, i) => (\n \n ))}\n \n\n {rings.map((ring, i) => {\n const pathId = `${uid}-path-${i}`\n return (\n \n \n \n \n {ring.text}\n \n \n \n )\n })}\n \n ) : null}\n\n

{lines.join(\" \")}

\n \n )\n}\n", - "type": "registry:ui", - "target": "components/ui/tangle-footer.tsx" - } - ], - "type": "registry:ui" -} \ No newline at end of file diff --git a/public/r/typewriter-svelte.json b/public/r/typewriter-svelte.json new file mode 100644 index 0000000..e15aef5 --- /dev/null +++ b/public/r/typewriter-svelte.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "typewriter-svelte", + "title": "Typewriter", + "description": "A real, playable typewriter in SVG — each key presses down, its typebar swings up to strike the ribbon, and the carriage steps along. Click it and type.", + "files": [ + { + "path": "registry/typewriter/typewriter.svelte", + "content": "\n\n\n\n\n", + "type": "registry:file", + "target": "src/lib/components/ui/typewriter.svelte" + } + ], + "type": "registry:ui" +} \ No newline at end of file diff --git a/public/r/typewriter.json b/public/r/typewriter.json new file mode 100644 index 0000000..1e0f604 --- /dev/null +++ b/public/r/typewriter.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "typewriter", + "title": "Typewriter", + "description": "A real, playable typewriter in SVG — each key presses down, its typebar swings up to strike the ribbon, and the carriage steps along. Click it and type.", + "files": [ + { + "path": "registry/typewriter/typewriter.tsx", + "content": "\"use client\"\n\nimport { useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport const DEFAULT_TEXT =\n \"Dear reader,\\nevery key is wired to a typebar. Press one and its bar swings up out of the basket, strikes the ribbon, and the carriage steps left for the next letter.\\n\\nYours, 23rd\"\n\n/** Enamel of the body and paper rest — a vintage mint. */\nexport const DEFAULT_COLOR = \"#8EC3B5\"\n/** Characters per second. */\nexport const DEFAULT_SPEED = 12\n/** Line length on the sheet, in characters. */\nexport const DEFAULT_COLUMNS = 32\n/** Pause on the finished page before it feeds out and starts over, in ms. */\nexport const DEFAULT_HOLD = 2600\n/** Wait before the first keystroke, in ms. */\nexport const DEFAULT_START_DELAY = 600\n/** Per-letter baseline, tilt, and ink wobble (0–1). */\nexport const DEFAULT_JITTER = 0.3\n\n/** Bell rings this many characters before the right margin. */\nconst BELL_CELLS = 6\n\nexport type TypewriterOptions = {\n /** What gets typed. `\\n` starts a new line; long lines wrap at words. */\n text?: string\n /** Characters per second. Default `12`. */\n speed?: number\n /** Uneven keystrokes, with longer rests after punctuation. Default `true`. */\n humanize?: boolean\n /** Pull the sheet out and type it again after `hold`. Default `true`. */\n loop?: boolean\n /** Pause on the finished page before looping, in ms. Default `2600`. */\n hold?: number\n /** Wait before the first keystroke, in ms. Default `600`. */\n startDelay?: number\n /** Body enamel, any CSS color. Default `#8EC3B5`. */\n color?: string\n /** Line length on the sheet, in characters (16–60). Default `32`. */\n columns?: number\n /** Per-letter baseline, tilt, and ink wobble, 0–1. Default `0.3`. */\n jitter?: number\n /** Synthesized key clicks, bell, and return. Default `false`. */\n sound?: boolean\n /**\n * Click the machine and type on it: your keyboard, or its keys, space\n * bar, shift lock, and return lever. Taking over stops the script.\n * Default `true`.\n */\n interactive?: boolean\n /** Text on a chrome nameplate on the front. Omit for none. */\n label?: string\n /** Called each time the last character lands. */\n onDone?: () => void\n}\n\nexport type TypewriterInstance = {\n setOptions: (options: Partial) => void\n /** Feed a fresh sheet and type from the first character. */\n restart: () => void\n destroy: () => void\n}\n\nexport type TypewriterStroke =\n | { kind: \"char\"; ch: string; line: number; col: number; src: number }\n | { kind: \"return\"; line: number; from: number; src: number }\n\n/**\n * The keystrokes that type `text` into `columns`-wide lines: characters\n * with their cell, and carriage returns. Words wrap whole unless a word is\n * wider than the line. `src` orders strokes against the source text, so a\n * relayout can resume at the same point.\n */\nexport function layoutTypewriter(\n text: string,\n columns: number\n): TypewriterStroke[] {\n const width = Math.max(1, Math.floor(columns))\n const source = Array.from(text.replace(/\\r\\n?/g, \"\\n\").replace(/\\t/g, \" \"))\n const strokes: TypewriterStroke[] = []\n let line = 0\n let col = 0\n /** Wrap returns wait for the character after them. */\n let pendingWraps: TypewriterStroke[] = []\n\n const feed = (src: number) => {\n const stroke: TypewriterStroke = {\n kind: \"return\",\n line: line + 1,\n from: col,\n src,\n }\n strokes.push(stroke)\n line += 1\n col = 0\n return stroke\n }\n const wrap = () => pendingWraps.push(feed(Infinity))\n const type = (ch: string, src: number) => {\n for (const stroke of pendingWraps) stroke.src = src - 0.5\n pendingWraps = []\n strokes.push({ kind: \"char\", ch, line, col, src })\n col += 1\n }\n\n let i = 0\n while (i < source.length) {\n const ch = source[i]!\n if (ch === \"\\n\") {\n for (const stroke of pendingWraps) stroke.src = i - 0.5\n pendingWraps = []\n feed(i)\n i += 1\n continue\n }\n if (/\\s/.test(ch)) {\n // Spaces past the margin fall off the page.\n if (col < width) type(\" \", i)\n i += 1\n continue\n }\n let end = i\n while (end < source.length && !/\\s/.test(source[end]!)) end += 1\n const length = end - i\n if (col > 0 && col + length > width) wrap()\n for (let k = i; k < end; k++) {\n if (col >= width) wrap()\n type(source[k]!, k)\n }\n i = end\n }\n return strokes\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction numberOr(value: unknown, fallback: number, min: number, max: number) {\n if (typeof value !== \"number\" || !Number.isFinite(value)) return fallback\n return clamp(value, min, max)\n}\n\n/** Stable 0–1 noise per integer, so a letter keeps its wobble on relayout. */\nfunction hash(n: number) {\n let x = Math.imul(n + 1, 0x27d4eb2d) ^ 0x165667b1\n x = Math.imul(x ^ (x >>> 15), 0x85ebca6b)\n x = Math.imul(x ^ (x >>> 13), 0xc2b2ae35)\n return ((x ^ (x >>> 16)) >>> 0) / 4294967296\n}\n\nconst SVG_NS = \"http://www.w3.org/2000/svg\"\n\ntype Sound = {\n key: (space: boolean) => void\n bell: () => void\n carriage: (ms: number) => void\n close: () => void\n}\n\n/** Key clicks, the margin bell, and the return ratchet, all synthesized. */\nfunction createSound(): Sound | null {\n const Ctx =\n window.AudioContext ??\n (window as unknown as { webkitAudioContext?: typeof AudioContext })\n .webkitAudioContext\n if (!Ctx) return null\n let audio: AudioContext | null = null\n let out: GainNode | null = null\n let noise: AudioBuffer | null = null\n\n const ready = () => {\n if (!audio) {\n audio = new Ctx()\n out = audio.createGain()\n out.gain.value = 0.55\n out.connect(audio.destination)\n noise = audio.createBuffer(\n 1,\n Math.floor(audio.sampleRate * 0.4),\n audio.sampleRate\n )\n const data = noise.getChannelData(0)\n for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1\n }\n if (audio.state === \"suspended\") void audio.resume().catch(() => {})\n return audio.state === \"running\" ? audio : null\n }\n\n const burst = (\n a: AudioContext,\n at: number,\n freq: number,\n q: number,\n peak: number,\n decay: number\n ) => {\n const src = a.createBufferSource()\n src.buffer = noise\n const band = a.createBiquadFilter()\n band.type = \"bandpass\"\n band.frequency.value = freq\n band.Q.value = q\n const gain = a.createGain()\n gain.gain.setValueAtTime(0.0001, at)\n gain.gain.exponentialRampToValueAtTime(peak, at + 0.002)\n gain.gain.exponentialRampToValueAtTime(0.0001, at + decay)\n src.connect(band).connect(gain).connect(out!)\n src.start(at, Math.random() * 0.2)\n src.stop(at + decay + 0.02)\n }\n\n const thump = (a: AudioContext, at: number, freq: number, peak: number) => {\n const osc = a.createOscillator()\n osc.frequency.setValueAtTime(freq, at)\n osc.frequency.exponentialRampToValueAtTime(freq * 0.5, at + 0.05)\n const gain = a.createGain()\n gain.gain.setValueAtTime(0.0001, at)\n gain.gain.exponentialRampToValueAtTime(peak, at + 0.003)\n gain.gain.exponentialRampToValueAtTime(0.0001, at + 0.06)\n osc.connect(gain).connect(out!)\n osc.start(at)\n osc.stop(at + 0.08)\n }\n\n return {\n key(space) {\n const a = ready()\n if (!a) return\n const at = a.currentTime\n if (space) {\n burst(a, at, 900, 0.9, 0.22, 0.05)\n thump(a, at, 110, 0.3)\n } else {\n burst(a, at, 2600 + Math.random() * 900, 1.3, 0.34, 0.035)\n thump(a, at, 150 + Math.random() * 30, 0.2)\n }\n },\n bell() {\n const a = ready()\n if (!a) return\n const at = a.currentTime\n for (const [freq, peak] of [\n [2093, 0.16],\n [5776, 0.05],\n ] as const) {\n const osc = a.createOscillator()\n osc.frequency.value = freq\n const gain = a.createGain()\n gain.gain.setValueAtTime(0.0001, at)\n gain.gain.exponentialRampToValueAtTime(peak, at + 0.004)\n gain.gain.exponentialRampToValueAtTime(0.0001, at + 1.1)\n osc.connect(gain).connect(out!)\n osc.start(at)\n osc.stop(at + 1.2)\n }\n },\n carriage(ms) {\n const a = ready()\n if (!a) return\n const at = a.currentTime\n const seconds = ms / 1000\n // Ratchet clicks as the line feeds, then the glide home.\n for (let i = 0; i < 3; i++) burst(a, at + i * 0.028, 1800, 2, 0.16, 0.02)\n const src = a.createBufferSource()\n src.buffer = noise\n src.loop = true\n const low = a.createBiquadFilter()\n low.type = \"bandpass\"\n low.Q.value = 0.8\n low.frequency.setValueAtTime(700, at)\n low.frequency.linearRampToValueAtTime(1400, at + seconds)\n const gain = a.createGain()\n gain.gain.setValueAtTime(0.0001, at)\n gain.gain.exponentialRampToValueAtTime(0.07, at + seconds * 0.3)\n gain.gain.exponentialRampToValueAtTime(0.0001, at + seconds)\n src.connect(low).connect(gain).connect(out!)\n src.start(at)\n src.stop(at + seconds + 0.02)\n thump(a, at + seconds, 120, 0.28)\n },\n close() {\n void audio?.close().catch(() => {})\n audio = null\n },\n }\n}\n\nconst TAU = Math.PI * 2\n\n// The machine is drawn in one SVG coordinate space, seen from the front and\n// a little above. The print point sits at the origin: the line being typed\n// has its baseline on y = 0, and the carriage slides so the next column\n// always lands at x = 0. Light comes from the upper left.\n\n/** Character pitch on the sheet. */\nconst CW = 10\n/** Line pitch on the sheet. */\nconst LH = 22\nconst FONT = 16.5\n/** Sheet margin either side of the text. */\nconst PAD = 30\n/** Sheet above the first baseline once it's fed in. */\nconst TOP_MARGIN = 38\nconst VIEW_TOP = -178\nconst VIEW_BOTTOM = 314\n/** Where a typebar's slug lands: the middle of the glyph on the print line. */\nconst STRIKE_Y = -5\n/** Centre of the typebar basket, under the print point. */\nconst BASKET_Y = 42\nconst PIVOT_R = 56\nconst REST_R = 27\n/** Half the basket's fan, in radians from straight down. */\nconst SPREAD = (74 * Math.PI) / 180\n/** Platen knob radius, for how far it turns per line. */\nconst KNOB_R = 21\nconst RIDGES = 11\nconst SPOOL_Y = 80\nconst SPOOL_X = 150\nconst KEY_ROWS = [\"1234567890\", \"qwertyuiop\", \"asdfghjkl;\", \"zxcvbnm,./\"]\nconst KEY_Y = [136, 166, 196, 226]\nconst KEY_PITCH = 35\nconst KEY_R = 12.5\n/** Key tops are circles seen from above: this squashes them. */\nconst KEY_TILT = 0.64\n/** How far a key travels when pressed. */\nconst KEY_TRAVEL = 4\n/** Paper lean per unit/s of carriage speed, in degrees, and its limit. */\nconst FLEX_GAIN = 0.0042\nconst FLEX_MAX = 5\n/** Spring the paper wobbles back on. */\nconst FLEX_K = 170\nconst FLEX_C = 9\n/** Platen line: where the sheet bends from. */\nconst FLEX_ANCHOR = 40\n/** Shifted characters, and the key that types them. */\nconst SHIFTED: Record = {\n \"!\": \"1\",\n '\"': \"2\",\n \"#\": \"3\",\n $: \"4\",\n \"%\": \"5\",\n _: \"6\",\n \"&\": \"7\",\n \"'\": \"8\",\n \"(\": \"9\",\n \")\": \"0\",\n \":\": \";\",\n \"?\": \"/\",\n \"<\": \",\",\n \">\": \".\",\n}\n\nconst INK = \"#24221F\"\nconst LEGEND = \"#EFE7D1\"\nconst RIBBON_BLACK = \"#16171A\"\nconst RIBBON_RED = \"#C4342B\"\nconst CHROME = \"#D3D8DC\"\nconst CHROME_EDGE = \"#5E656C\"\nconst SHEET = \"var(--typewriter-sheet, #FBF9F3)\"\n\n/** An SVG element. A `fill` of `\"sheet\"` paints in the paper tone. */\nfunction draw(\n tag: K,\n attrs: Record,\n parent?: Element\n) {\n const node = document.createElementNS(SVG_NS, tag)\n for (const [key, value] of Object.entries(attrs)) {\n if (key === \"fill\" && value === \"sheet\") node.style.fill = SHEET\n else node.setAttribute(key, String(value))\n }\n parent?.append(node)\n return node\n}\n\ntype Rgb = [number, number, number]\n\nlet swatch: CanvasRenderingContext2D | null = null\n\n/** Any CSS color as RGB, via a one-pixel canvas. */\nfunction rgbOf(color: string, fallback: Rgb): Rgb {\n swatch ??= document\n .createElement(\"canvas\")\n .getContext(\"2d\", { willReadFrequently: true })\n if (!swatch) return fallback\n swatch.clearRect(0, 0, 1, 1)\n swatch.fillStyle = `rgb(${fallback.join(\" \")})`\n swatch.fillStyle = color\n swatch.fillRect(0, 0, 1, 1)\n const [r = 0, g = 0, b = 0] = swatch.getImageData(0, 0, 1, 1).data\n return [r, g, b]\n}\n\nfunction mix(a: Rgb, b: Rgb, t: number) {\n const c = a.map((v, i) => Math.round(v + (b[i]! - v) * t))\n return `rgb(${c.join(\" \")})`\n}\n\nconst linear = (t: number) => t\nconst easeIn = (t: number) => t * t * t\nconst easeOut = (t: number) => 1 - (1 - t) ** 3\nconst easeInOut = (t: number) =>\n t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2\n\n/** Down fast, hold, ease back up — a key, a typebar, the ribbon. */\nfunction pulse(t: number, from: number, rise: number, hold: number) {\n if (t < rise) return from + (1 - from) * easeOut(t / rise)\n if (t < rise + hold) return 1\n return 1 - easeInOut((t - rise - hold) / Math.max(0.001, 1 - rise - hold))\n}\n\n/** A point on the basket, `angle` radians from straight down. */\nfunction polar(r: number, angle: number): [number, number] {\n return [r * Math.sin(angle), BASKET_Y + r * Math.cos(angle)]\n}\n\ntype Bar = {\n rest: SVGGElement\n strike: SVGGElement\n /** Dark edge under the bar, so it reads over the paper. */\n edge: SVGLineElement\n core: SVGLineElement\n slug: SVGLineElement\n pivot: [number, number]\n tip: [number, number]\n v: number\n}\n\ntype Key = { cap: SVGGElement; bar: Bar | null; v: number }\n\ntype Machine = {\n columns: number\n /** Everything but the ground shadow — it shudders on each strike. */\n rig: SVGGElement\n /** Under-inked fills, for letters the ribbon didn't fully cover. */\n inkFills: string[]\n carriage: SVGGElement\n paper: SVGGElement\n sheet: SVGRectElement\n shadow: SVGRectElement\n ink: SVGGElement\n lever: SVGGElement\n leverPivot: [number, number]\n knobs: { x: number; lines: SVGLineElement[] }[]\n keys: Map\n shifts: Key[]\n space: Key\n basket: SVGGElement\n bars: Bar[]\n ribbon: { path: SVGPathElement; dy: number }[]\n vibrator: SVGGElement\n spools: SVGGElement[]\n}\n\n/**\n * Draw the whole typewriter into `svg`: the carriage (rail, paper rest,\n * platen, knobs, return lever, sheet, scale) and the body (enamel shell,\n * typebar basket, ribbon spools, ribbon, keyboard). `color` is the enamel;\n * its lights and shadows are mixed from it.\n */\nfunction drawMachine(\n svg: SVGSVGElement,\n id: string,\n columns: number,\n color: string,\n label?: string\n): Machine {\n const span = columns * CW\n const platenL = -PAD - 22\n const platenR = span + PAD + 22\n const knobW = 15\n const leverX = platenL - knobW - 4\n // Room for the carriage at both ends of its travel, and the body.\n const left = leverX - 56 - (span + CW / 2)\n const right = platenR + knobW - CW / 2\n const half = Math.ceil(Math.max(-left, right, 262) + 14)\n svg.replaceChildren()\n svg.setAttribute(\n \"viewBox\",\n `${-half} ${VIEW_TOP} ${half * 2} ${VIEW_BOTTOM - VIEW_TOP}`\n )\n\n const base = rgbOf(color, [142, 195, 181])\n const white: Rgb = [255, 255, 255]\n const black: Rgb = [0, 0, 0]\n const enamel = {\n shine: mix(base, white, 0.62),\n hi: mix(base, white, 0.34),\n light: mix(base, white, 0.14),\n mid: mix(base, base, 0),\n low: mix(base, black, 0.16),\n deep: mix(base, black, 0.34),\n edge: mix(base, black, 0.55),\n }\n\n /* Gradients. */\n const defs = draw(\"defs\", {}, svg)\n const gradient = (\n name: string,\n kind: \"linearGradient\" | \"radialGradient\",\n attrs: Record,\n stops: [number, string, number?][]\n ) => {\n const el = draw(kind, { id: `${id}-${name}`, ...attrs }, defs)\n for (const [offset, stopColor, alpha = 1] of stops) {\n draw(\n \"stop\",\n { offset, \"stop-color\": stopColor, \"stop-opacity\": alpha },\n el\n )\n }\n return `url(#${id}-${name})`\n }\n const down = { x1: 0, y1: 0, x2: 0, y2: 1 }\n const across = { x1: 0, y1: 0, x2: 1, y2: 0 }\n const fills = {\n body: gradient(\"body\", \"linearGradient\", down, [\n [0, enamel.hi],\n [0.18, enamel.light],\n [0.62, enamel.mid],\n [1, enamel.low],\n ]),\n sides: gradient(\"sides\", \"linearGradient\", across, [\n [0, \"#000\", 0.26],\n [0.1, \"#000\", 0.04],\n [0.5, \"#fff\", 0.05],\n [0.9, \"#000\", 0.04],\n [1, \"#000\", 0.3],\n ]),\n front: gradient(\"front\", \"linearGradient\", down, [\n [0, enamel.low],\n [1, enamel.deep],\n ]),\n rest: gradient(\"rest\", \"linearGradient\", down, [\n [0, enamel.hi],\n [0.5, enamel.light],\n [1, enamel.low],\n ]),\n chrome: gradient(\"chrome\", \"linearGradient\", down, [\n [0, \"#FDFEFE\"],\n [0.38, \"#D2D7DB\"],\n [0.52, \"#8B9298\"],\n [0.7, \"#C3C8CC\"],\n [1, \"#EEF0F2\"],\n ]),\n chromeV: gradient(\"chrome-v\", \"linearGradient\", across, [\n [0, \"#8B9298\"],\n [0.35, \"#F4F6F7\"],\n [0.6, \"#BFC4C9\"],\n [1, \"#737A80\"],\n ]),\n rubber: gradient(\"rubber\", \"linearGradient\", down, [\n [0, \"#55595E\"],\n [0.16, \"#2A2D31\"],\n [0.55, \"#0D0E10\"],\n [0.85, \"#050607\"],\n [1, \"#1E2124\"],\n ]),\n bakelite: gradient(\"bakelite\", \"linearGradient\", down, [\n [0, \"#62666C\"],\n [0.2, \"#2C2F33\"],\n [0.6, \"#111214\"],\n [1, \"#2A2D31\"],\n ]),\n capTop: gradient(\n \"cap-top\",\n \"radialGradient\",\n { cx: 0.38, cy: 0.3, r: 0.75 },\n [\n [0, \"#575C63\"],\n [0.55, \"#202326\"],\n [1, \"#0C0D0F\"],\n ]\n ),\n capSide: gradient(\"cap-side\", \"linearGradient\", down, [\n [0, \"#9AA1A7\"],\n [0.5, \"#4B5157\"],\n [1, \"#2A2E32\"],\n ]),\n well: gradient(\"well\", \"linearGradient\", down, [\n [0, \"#08090A\"],\n [0.18, \"#151719\"],\n [1, \"#24272A\"],\n ]),\n recess: gradient(\"recess\", \"radialGradient\", { cx: 0.5, cy: 0, r: 1 }, [\n [0, \"#030304\"],\n [0.7, \"#141618\"],\n [1, \"#24272A\"],\n ]),\n spoolRibbon: gradient(\n \"spool-ribbon\",\n \"radialGradient\",\n { cx: 0.42, cy: 0.38, r: 0.7 },\n [\n [0, \"#3B3E43\"],\n [0.7, \"#151618\"],\n [1, \"#060607\"],\n ]\n ),\n flange: gradient(\n \"flange\",\n \"radialGradient\",\n { cx: 0.36, cy: 0.3, r: 0.85 },\n [\n [0, \"#FFFFFF\"],\n [0.45, \"#CDD2D6\"],\n [1, \"#7D848A\"],\n ]\n ),\n ground: gradient(\"ground\", \"radialGradient\", { cx: 0.5, cy: 0.5, r: 0.5 }, [\n [0, \"#000\", 0.34],\n [0.6, \"#000\", 0.12],\n [1, \"#000\", 0],\n ]),\n keyShadow: gradient(\n \"key-shadow\",\n \"radialGradient\",\n { cx: 0.5, cy: 0.5, r: 0.5 },\n [\n [0, \"#000\", 0.7],\n [1, \"#000\", 0],\n ]\n ),\n curl: gradient(\n \"curl\",\n \"linearGradient\",\n { gradientUnits: \"userSpaceOnUse\", x1: 0, y1: 12, x2: 0, y2: 41 },\n [\n [0, \"#3A2E1C\", 0],\n [0.55, \"#3A2E1C\", 0.1],\n [1, \"#3A2E1C\", 0.34],\n ]\n ),\n contact: gradient(\"contact\", \"linearGradient\", down, [\n [0, \"#000\", 0.3],\n [1, \"#000\", 0],\n ]),\n }\n const inkFills = (\n [\n [down, 0, 0.36],\n [down, 1, 0.4],\n [across, 0, 0.42],\n [across, 1, 0.42],\n ] as const\n ).map(([dir, faintEnd, alpha], i) =>\n gradient(\n `ink-${i}`,\n \"linearGradient\",\n dir,\n faintEnd === 0\n ? [\n [0, INK, alpha],\n [0.55, INK, 1],\n [1, INK, 1],\n ]\n : [\n [0, INK, 1],\n [0.45, INK, 1],\n [1, INK, alpha],\n ]\n )\n )\n\n // The sheet fades out at the top of the frame and tucks behind the\n // platen at the bottom.\n const fade = gradient(\n \"fade-g\",\n \"linearGradient\",\n {\n gradientUnits: \"userSpaceOnUse\",\n x1: 0,\n x2: 0,\n y1: VIEW_TOP,\n y2: 41,\n },\n [\n [0, \"#fff\", 0],\n [84 / (41 - VIEW_TOP), \"#fff\", 1],\n [0.998, \"#fff\", 1],\n [1, \"#fff\", 0],\n ]\n )\n const box = { x: -4000, y: VIEW_TOP - 4, width: 8000, height: 50 - VIEW_TOP }\n const mask = draw(\n \"mask\",\n { id: `${id}-fade`, maskUnits: \"userSpaceOnUse\", ...box },\n defs\n )\n draw(\"rect\", { ...box, fill: fade }, mask)\n\n // Ground shadow.\n draw(\"ellipse\", { cx: 0, cy: 302, rx: 300, ry: 15, fill: fills.ground }, svg)\n\n /* ---------------- Carriage ---------------- */\n\n const rig = draw(\"g\", {}, svg)\n const carriage = draw(\"g\", {}, rig)\n // Rail it rides on.\n draw(\n \"rect\",\n {\n x: platenL - knobW - 8,\n y: 40,\n width: platenR - platenL + knobW * 2 + 16,\n height: 6,\n rx: 3,\n fill: fills.chrome,\n },\n carriage\n )\n // Paper rest behind the sheet.\n const tL = -PAD - 16\n const tR = span + PAD + 16\n draw(\n \"path\",\n {\n d: `M${tL} 10V-80Q${tL} -98 ${tL + 18} -98H${tR - 18}Q${tR} -98 ${tR} -80V10Z`,\n fill: fills.rest,\n },\n carriage\n )\n draw(\n \"path\",\n {\n d: `M${tL + 1} -80Q${tL + 1} -97 ${tL + 18} -97H${tR - 18}Q${tR - 1} -97 ${tR - 1} -80`,\n fill: \"none\",\n stroke: enamel.shine,\n \"stroke-width\": 1.4,\n \"stroke-opacity\": 0.85,\n },\n carriage\n )\n // Platen, with chrome collars at its ends.\n draw(\n \"rect\",\n {\n x: platenL,\n y: 6,\n width: platenR - platenL,\n height: 34,\n rx: 4,\n fill: fills.rubber,\n },\n carriage\n )\n draw(\n \"rect\",\n {\n x: platenL + 4,\n y: 10.5,\n width: platenR - platenL - 8,\n height: 1.6,\n rx: 0.8,\n fill: \"#fff\",\n \"fill-opacity\": 0.22,\n },\n carriage\n )\n for (const x of [platenL - 4, platenR]) {\n draw(\n \"rect\",\n { x, y: 9, width: 4, height: 28, rx: 1, fill: fills.chrome },\n carriage\n )\n }\n // Knobs: bakelite with knurling that turns with the platen.\n const knobs = [platenL - 4 - knobW, platenR + 4].map((x) => {\n draw(\n \"rect\",\n { x, y: 1, width: knobW, height: 44, rx: 6, fill: fills.bakelite },\n carriage\n )\n const lines = Array.from({ length: RIDGES }, () =>\n draw(\n \"line\",\n {\n x1: x + 2.5,\n x2: x + knobW - 2.5,\n stroke: \"#B8BEC4\",\n \"stroke-width\": 1,\n \"stroke-linecap\": \"round\",\n },\n carriage\n )\n )\n draw(\n \"rect\",\n {\n x: x + 2,\n y: 3,\n width: knobW - 4,\n height: 6,\n rx: 3,\n fill: \"#fff\",\n \"fill-opacity\": 0.12,\n },\n carriage\n )\n return { x, lines }\n })\n // Carriage return lever: chrome arm, bakelite grip.\n const lever = draw(\"g\", { \"data-key\": \"return\" }, carriage)\n const leverPivot: [number, number] = [leverX, 30]\n draw(\n \"rect\",\n {\n x: leverX - 5,\n y: 20,\n width: 10,\n height: 22,\n rx: 3,\n fill: fills.chromeV,\n },\n lever\n )\n const arm = `M${leverX} 28L${leverX - 13} 4L${leverX - 36} -14`\n for (const [stroke, width] of [\n [CHROME_EDGE, 6.4],\n [CHROME, 4],\n ] as const) {\n draw(\n \"path\",\n {\n d: arm,\n fill: \"none\",\n stroke,\n \"stroke-width\": width,\n \"stroke-linecap\": \"round\",\n \"stroke-linejoin\": \"round\",\n },\n lever\n )\n }\n draw(\n \"path\",\n {\n d: `M${leverX - 1} 26L${leverX - 13} 4.5L${leverX - 34} -12`,\n fill: \"none\",\n stroke: \"#fff\",\n \"stroke-width\": 1.2,\n \"stroke-opacity\": 0.8,\n \"stroke-linecap\": \"round\",\n \"stroke-linejoin\": \"round\",\n },\n lever\n )\n draw(\n \"rect\",\n {\n x: leverX - 56,\n y: -25,\n width: 28,\n height: 11,\n rx: 5.5,\n fill: fills.bakelite,\n transform: `rotate(-24 ${leverX - 42} -19.5)`,\n },\n lever\n )\n\n // The sheet, masked so it fades at the top and wraps under the platen.\n const wrap = draw(\"g\", { mask: `url(#${id}-fade)` }, carriage)\n const paper = draw(\"g\", {}, wrap)\n const shadow = draw(\n \"rect\",\n {\n x: -PAD + 3,\n y: -TOP_MARGIN + 3,\n width: span + PAD * 2,\n height: 600,\n fill: \"#000\",\n \"fill-opacity\": 0.12,\n },\n paper\n )\n const sheet = draw(\n \"rect\",\n {\n x: -PAD,\n y: -TOP_MARGIN,\n width: span + PAD * 2,\n height: 600,\n fill: \"sheet\",\n stroke: \"#000\",\n \"stroke-opacity\": 0.07,\n \"stroke-width\": 0.8,\n },\n paper\n )\n const ink = draw(\n \"g\",\n { \"text-anchor\": \"middle\", \"font-size\": FONT, fill: INK },\n paper\n )\n // Where the sheet curls round the platen.\n draw(\n \"rect\",\n {\n x: -PAD,\n y: 12,\n width: span + PAD * 2,\n height: 30,\n fill: fills.curl,\n },\n wrap\n )\n\n // Paper scale under the print line.\n draw(\n \"rect\",\n {\n x: -PAD + 2,\n y: 8.5,\n width: span + PAD * 2 - 4,\n height: 6,\n rx: 3,\n fill: fills.chrome,\n },\n carriage\n )\n let ticks = \"\"\n for (let c = 0; c <= columns; c++) {\n const x = c * CW\n ticks += `M${x} 9.4V${c % 10 === 0 ? 13.6 : c % 5 === 0 ? 12.4 : 11.2}`\n }\n draw(\n \"path\",\n {\n d: ticks,\n stroke: \"#3E444A\",\n \"stroke-width\": 0.7,\n fill: \"none\",\n },\n carriage\n )\n\n /* ---------------- Body ---------------- */\n\n const body = draw(\"g\", {}, rig)\n const shell =\n \"M-192 42Q-214 42 -219 62C-228 120 -240 190 -247 246Q-249 262 -232 264H232Q249 262 247 246C240 190 228 120 219 62Q214 42 192 42Z\"\n draw(\"path\", { d: shell, fill: fills.body }, body)\n draw(\"path\", { d: shell, fill: fills.sides }, body)\n draw(\n \"rect\",\n { x: -206, y: 42.5, width: 412, height: 15, fill: fills.contact },\n body\n )\n // Front face, below the top surfaces, with a chrome trim.\n draw(\n \"path\",\n {\n d: \"M-247 248Q-249 262 -232 264H232Q249 262 247 248L245 284Q243 296 227 296H-227Q-243 296 -245 284Z\",\n fill: fills.front,\n },\n body\n )\n draw(\n \"path\",\n {\n d: \"M-240 264.5H240\",\n stroke: enamel.shine,\n \"stroke-width\": 1.2,\n \"stroke-opacity\": 0.7,\n },\n body\n )\n draw(\n \"rect\",\n {\n x: -236,\n y: 274,\n width: 472,\n height: 3,\n rx: 1.5,\n fill: fills.chrome,\n },\n body\n )\n if (label) {\n const w = Math.max(36, label.length * 7.4 + 18)\n draw(\n \"rect\",\n {\n x: -w / 2,\n y: 268.5,\n width: w,\n height: 14,\n rx: 3,\n fill: fills.chrome,\n stroke: CHROME_EDGE,\n \"stroke-width\": 0.8,\n },\n body\n )\n for (const [dy, fill] of [\n [0.6, \"#fff\"],\n [0, \"#30353A\"],\n ] as const) {\n const plate = draw(\n \"text\",\n {\n x: 0,\n y: 276 + dy,\n \"text-anchor\": \"middle\",\n \"dominant-baseline\": \"central\",\n \"font-size\": 8.4,\n \"font-weight\": 700,\n \"letter-spacing\": 1.6,\n fill,\n },\n body\n )\n plate.textContent = label\n }\n }\n for (const x of [-228, 188]) {\n draw(\n \"rect\",\n { x, y: 293, width: 40, height: 10, rx: 4, fill: \"#151618\" },\n body\n )\n }\n const sheen = gradient(\"sheen\", \"linearGradient\", across, [\n [0, \"#fff\", 0.34],\n [0.55, \"#fff\", 0.1],\n [1, \"#fff\", 0],\n ])\n draw(\n \"path\",\n { d: \"M-210 48H-96L-150 110H-224Q-222 82 -210 48Z\", fill: sheen },\n body\n )\n // Glossy rim along the top of the shell.\n draw(\n \"path\",\n {\n d: \"M-194 43.4H194\",\n stroke: enamel.shine,\n \"stroke-width\": 1.6,\n \"stroke-linecap\": \"round\",\n },\n body\n )\n draw(\n \"path\",\n {\n d: \"M-208 56Q-212 70 -216 96\",\n stroke: enamel.shine,\n \"stroke-width\": 2.4,\n \"stroke-linecap\": \"round\",\n \"stroke-opacity\": 0.55,\n fill: \"none\",\n },\n body\n )\n\n // Typebar basket, sunk in the deck.\n draw(\n \"path\",\n {\n d: `M-68 ${BASKET_Y}A68 68 0 0 0 68 ${BASKET_Y}Z`,\n fill: fills.recess,\n },\n body\n )\n draw(\n \"path\",\n {\n d: `M-68 ${BASKET_Y}A68 68 0 0 0 68 ${BASKET_Y}`,\n fill: \"none\",\n stroke: enamel.shine,\n \"stroke-width\": 1.3,\n \"stroke-opacity\": 0.75,\n },\n body\n )\n const basket = draw(\"g\", {}, body)\n const [ax, ay] = polar(62, -SPREAD - 0.12)\n const [bx, by] = polar(62, SPREAD + 0.12)\n const [cx, cy] = polar(53, SPREAD + 0.12)\n const [dx, dy] = polar(53, -SPREAD - 0.12)\n draw(\n \"path\",\n {\n d: `M${ax} ${ay}A62 62 0 0 0 ${bx} ${by}L${cx} ${cy}A53 53 0 0 1 ${dx} ${dy}Z`,\n fill: fills.chrome,\n },\n basket\n )\n\n // Ribbon spools: black ribbon wound under a turning chrome flange.\n const spools = [-SPOOL_X, SPOOL_X].map((x) => {\n draw(\n \"circle\",\n { cx: x, cy: SPOOL_Y + 2, r: 27, fill: \"#000\", \"fill-opacity\": 0.22 },\n body\n )\n draw(\"circle\", { cx: x, cy: SPOOL_Y, r: 26, fill: fills.flange }, body)\n draw(\"circle\", { cx: x, cy: SPOOL_Y, r: 22, fill: fills.spoolRibbon }, body)\n const turn = draw(\"g\", {}, body)\n let windows = \"\"\n for (let i = 0; i < 3; i++) {\n const a0 = (i / 3) * TAU + 0.25\n const a1 = a0 + 1.35\n const p = (r: number, a: number) =>\n `${(x + Math.cos(a) * r).toFixed(2)} ${(SPOOL_Y + Math.sin(a) * r).toFixed(2)}`\n windows += `M${p(19, a0)}A19 19 0 0 1 ${p(19, a1)}L${p(8.5, a1)}A8.5 8.5 0 0 0 ${p(8.5, a0)}Z`\n }\n draw(\n \"path\",\n {\n d: `M${x - 21} ${SPOOL_Y}a21 21 0 1 0 42 0a21 21 0 1 0 -42 0Z${windows}`,\n fill: fills.flange,\n \"fill-rule\": \"evenodd\",\n },\n turn\n )\n draw(\"circle\", { cx: x, cy: SPOOL_Y, r: 5, fill: fills.chrome }, turn)\n draw(\"circle\", { cx: x, cy: SPOOL_Y, r: 1.6, fill: \"#2A2D31\" }, turn)\n return turn\n })\n\n // The type guide either side of the print point.\n for (const x of [-23, 17]) {\n draw(\n \"rect\",\n {\n x,\n y: 0,\n width: 6,\n height: 16,\n rx: 1.6,\n fill: fills.chromeV,\n stroke: CHROME_EDGE,\n \"stroke-width\": 0.8,\n },\n body\n )\n }\n\n // Two-tone ribbon, black over red, through the vibrator.\n const ribbon = [\n {\n path: draw(\n \"path\",\n {\n fill: \"none\",\n stroke: RIBBON_BLACK,\n \"stroke-width\": 5.8,\n \"stroke-linejoin\": \"round\",\n },\n body\n ),\n dy: 0,\n },\n {\n path: draw(\n \"path\",\n {\n fill: \"none\",\n stroke: RIBBON_RED,\n \"stroke-width\": 2.6,\n \"stroke-linejoin\": \"round\",\n },\n body\n ),\n dy: 1.5,\n },\n ]\n const vibrator = draw(\"g\", {}, body)\n for (const [stroke, width] of [\n [CHROME_EDGE, 3.8],\n [CHROME, 2],\n ] as const) {\n draw(\n \"path\",\n {\n d: \"M-12 2.5V11.5H12V2.5\",\n fill: \"none\",\n stroke,\n \"stroke-width\": width,\n \"stroke-linejoin\": \"round\",\n \"stroke-linecap\": \"round\",\n },\n vibrator\n )\n }\n\n // Keyboard well.\n draw(\n \"path\",\n {\n d: \"M-204 112H204Q217 112 219 125L233 240Q235 254 220 254H-220Q-235 254 -233 240L-219 125Q-217 112 -204 112Z\",\n fill: fills.well,\n },\n body\n )\n draw(\n \"path\",\n {\n d: \"M-219.5 126Q-217.5 111 -204 111H204Q217.5 111 219.5 126\",\n fill: \"none\",\n stroke: enamel.shine,\n \"stroke-width\": 1.3,\n \"stroke-opacity\": 0.8,\n },\n body\n )\n draw(\n \"path\",\n {\n d: \"M-226 254.6H226\",\n stroke: enamel.shine,\n \"stroke-width\": 1.2,\n \"stroke-opacity\": 0.6,\n },\n body\n )\n\n const ry = KEY_R * KEY_TILT\n const height = 4.5\n /** A round key on its stem, seen from above: rim, cap, legend, shadow. */\n const keyAt = (x: number, y: number, label: string, ch: string) => {\n draw(\n \"ellipse\",\n {\n cx: x + 2,\n cy: y + 21,\n rx: 11,\n ry: 4,\n fill: fills.keyShadow,\n },\n body\n )\n draw(\n \"rect\",\n {\n x: x - 1.5,\n y: y + 4,\n width: 3,\n height: 19,\n rx: 1,\n fill: fills.chromeV,\n },\n body\n )\n const cap = draw(\"g\", {}, body)\n draw(\n \"path\",\n {\n d: `M${x - KEY_R} ${y}V${y + height}A${KEY_R} ${ry} 0 0 0 ${x + KEY_R} ${y + height}V${y}A${KEY_R} ${ry} 0 0 1 ${x - KEY_R} ${y}Z`,\n fill: fills.capSide,\n },\n cap\n )\n draw(\"ellipse\", { cx: x, cy: y, rx: KEY_R, ry, fill: fills.chrome }, cap)\n draw(\n \"ellipse\",\n {\n cx: x,\n cy: y + 0.3,\n rx: KEY_R - 2.3,\n ry: ry - 1.6,\n fill: fills.capTop,\n },\n cap\n )\n draw(\n \"ellipse\",\n {\n cx: x - 3.4,\n cy: y - 2.6,\n rx: 4.4,\n ry: 1.6,\n fill: \"#fff\",\n \"fill-opacity\": 0.2,\n },\n cap\n )\n const text = draw(\n \"text\",\n {\n x,\n y: y + 0.7,\n \"text-anchor\": \"middle\",\n \"dominant-baseline\": \"central\",\n \"font-size\": 9.6,\n \"font-weight\": 600,\n fill: LEGEND,\n transform: `matrix(1 0 0 0.74 0 ${(y * 0.26).toFixed(2)})`,\n },\n cap\n )\n text.textContent = label\n draw(\n \"path\",\n {\n d: `M${x - KEY_R + 3.6} ${y - 0.4}A${KEY_R - 3.6} ${ry - 2.3} 0 0 1 ${x + KEY_R - 3.6} ${y - 0.4}`,\n fill: \"none\",\n stroke: \"#fff\",\n \"stroke-width\": 0.9,\n \"stroke-opacity\": 0.32,\n \"stroke-linecap\": \"round\",\n },\n cap\n )\n cap.setAttribute(\"data-key\", ch)\n return cap\n }\n\n const keys = new Map()\n const placed: { x: number; key: Key }[] = []\n KEY_ROWS.forEach((row, r) => {\n Array.from(row).forEach((ch, i) => {\n const x = -157.5 + (r - 1.5) * 9 + i * KEY_PITCH\n const key: Key = {\n cap: keyAt(x, KEY_Y[r]!, ch.toUpperCase(), ch),\n bar: null,\n v: 0,\n }\n keys.set(ch, key)\n placed.push({ x, key })\n })\n })\n\n /** A pill key (shift, space): same rim and cap, stretched. */\n const pill = (x: number, y: number, w: number, legend?: string) => {\n for (const sx of [x - w * 0.3, x + w * 0.3]) {\n draw(\n \"rect\",\n {\n x: sx - 1.5,\n y: y + 4,\n width: 3,\n height: 17,\n rx: 1,\n fill: fills.chromeV,\n },\n body\n )\n }\n draw(\n \"ellipse\",\n {\n cx: x + 2,\n cy: y + 19,\n rx: w / 2 + 2,\n ry: 4,\n fill: fills.keyShadow,\n },\n body\n )\n const cap = draw(\"g\", {}, body)\n const r = ry\n draw(\n \"rect\",\n {\n x: x - w / 2,\n y: y - r + height,\n width: w,\n height: r * 2,\n rx: r,\n fill: fills.capSide,\n },\n cap\n )\n draw(\n \"rect\",\n {\n x: x - w / 2,\n y: y - r,\n width: w,\n height: r * 2,\n rx: r,\n fill: fills.chrome,\n },\n cap\n )\n draw(\n \"rect\",\n {\n x: x - w / 2 + 2.3,\n y: y - r + 1.6,\n width: w - 4.6,\n height: r * 2 - 3.2,\n rx: r - 1.6,\n fill: fills.capTop,\n },\n cap\n )\n if (legend) {\n draw(\"path\", { d: legend, fill: LEGEND }, cap)\n }\n return cap\n }\n const y3 = KEY_Y[3]!\n const shifts = [-1, 1].map((side) => {\n const x = side * 201\n const arrow = `M${x} ${y3 - 3.6}L${x + 4} ${y3 + 0.6}H${x + 1.6}V${y3 + 3.4}H${x - 1.6}V${y3 + 0.6}H${x - 4}Z`\n const cap = pill(x, y3, 30, arrow)\n cap.setAttribute(\"data-key\", \"shift\")\n return { cap, bar: null, v: 0 } satisfies Key\n })\n\n // Space bar: a chrome bar on two arms.\n for (const sx of [-80, 80]) {\n draw(\n \"rect\",\n {\n x: sx - 1.5,\n y: 247,\n width: 3,\n height: 7,\n fill: fills.chromeV,\n },\n body\n )\n }\n const spaceCap = draw(\"g\", { \"data-key\": \" \" }, body)\n draw(\n \"rect\",\n {\n x: -112,\n y: 242,\n width: 224,\n height: 9,\n rx: 4.5,\n fill: \"#4E555B\",\n },\n spaceCap\n )\n draw(\n \"rect\",\n {\n x: -112,\n y: 239.5,\n width: 224,\n height: 8,\n rx: 4,\n fill: fills.chrome,\n },\n spaceCap\n )\n const space: Key = { cap: spaceCap, bar: null, v: 0 }\n\n // One typebar per key, fanned left to right in key order.\n const overlay = draw(\"g\", {}, rig)\n placed.sort((a, b) => a.x - b.x)\n const bars = placed.map(({ key }, i) => {\n const angle = -SPREAD + (2 * SPREAD * i) / (placed.length - 1)\n const pivot = polar(PIVOT_R, angle)\n const tip = polar(REST_R, angle)\n const rest = draw(\"g\", {}, basket)\n draw(\n \"line\",\n {\n x1: pivot[0],\n y1: pivot[1],\n x2: tip[0],\n y2: tip[1],\n stroke: i % 2 ? \"#C4C9CE\" : \"#E6E9EC\",\n \"stroke-width\": 1.25,\n \"stroke-linecap\": \"round\",\n },\n rest\n )\n const [sx, sy] = polar(REST_R + 3.4, angle)\n draw(\n \"line\",\n {\n x1: sx,\n y1: sy,\n x2: tip[0],\n y2: tip[1],\n stroke: \"#7D848A\",\n \"stroke-width\": 2.6,\n \"stroke-linecap\": \"round\",\n },\n rest\n )\n const strike = draw(\"g\", { display: \"none\" }, overlay)\n const line = (stroke: string, width: number) =>\n draw(\n \"line\",\n { stroke, \"stroke-width\": width, \"stroke-linecap\": \"round\" },\n strike\n )\n const edge = line(\"#1C1E21\", 3.9)\n const core = line(\"#E2E5E8\", 1.8)\n const slug = line(\"#2A2D31\", 5.6)\n const bar: Bar = { rest, strike, edge, core, slug, pivot, tip, v: 0 }\n key.bar = bar\n return bar\n })\n\n return {\n columns,\n rig,\n inkFills,\n carriage,\n paper,\n sheet,\n shadow,\n ink,\n lever,\n leverPivot,\n knobs,\n keys,\n shifts,\n space,\n basket,\n bars,\n ribbon,\n vibrator,\n spools,\n }\n}\n\ntype Motion = {\n key: string\n start: number\n ms: number\n ease: (t: number) => number\n run: (v: number) => void\n begin?: () => void\n}\n\nlet machineIds = 0\n\n/**\n * A typewriter that types. Each letter presses its key, swings that key's\n * typebar up out of the basket to strike the ribbon, and the carriage\n * steps one character left. Capitals hold shift, which drops the basket. At\n * the margin the return lever kicks, the platen turns up a line, and the\n * carriage slides home. With `loop`, the finished sheet is pulled out and\n * a fresh one rolls in. `color` is the enamel; chrome, rubber, and the\n * ivory sheet are fixed. `prefers-reduced-motion` prints the whole page at\n * once.\n */\nexport function createTypewriter(\n root: HTMLElement,\n initial: TypewriterOptions = {}\n): TypewriterInstance {\n let options: TypewriterOptions = { ...initial }\n const textOf = () => options.text ?? DEFAULT_TEXT\n const speedOf = () => numberOr(options.speed, DEFAULT_SPEED, 1, 60)\n const holdOf = () => numberOr(options.hold, DEFAULT_HOLD, 0, 60000)\n const startOf = () =>\n numberOr(options.startDelay, DEFAULT_START_DELAY, 0, 60000)\n const jitterOf = () => numberOr(options.jitter, DEFAULT_JITTER, 0, 1)\n const columnsOf = () =>\n Math.round(numberOr(options.columns, DEFAULT_COLUMNS, 16, 60))\n const id = `typewriter-${++machineIds}`\n\n // Screen readers get the whole text once; the machine is decoration.\n const spoken = document.createElement(\"span\")\n Object.assign(spoken.style, {\n position: \"absolute\",\n width: \"1px\",\n height: \"1px\",\n margin: \"-1px\",\n padding: \"0\",\n overflow: \"hidden\",\n clip: \"rect(0 0 0 0)\",\n whiteSpace: \"nowrap\",\n border: \"0\",\n })\n const svg = draw(\"svg\", {\n width: \"100%\",\n height: \"100%\",\n preserveAspectRatio: \"xMidYMid meet\",\n \"aria-hidden\": \"true\",\n })\n svg.style.display = \"block\"\n root.append(spoken, svg)\n\n let M!: Machine\n\n /* ---------------------------------------------------------------- */\n /* Drawing */\n /* ---------------------------------------------------------------- */\n\n const build = () => {\n M = drawMachine(\n svg,\n id,\n columnsOf(),\n options.color ?? DEFAULT_COLOR,\n options.label\n )\n setInteractive()\n }\n\n /* ---------------------------------------------------------------- */\n /* Poses */\n /* ---------------------------------------------------------------- */\n\n const pose = {\n carriage: 0,\n paper: 0,\n knob: 0,\n spool: 0,\n lever: 0,\n shift: 0,\n ribbon: 0,\n }\n\n const carriageAt = (col: number) => -(col * CW + CW / 2)\n\n const setCarriage = (x: number) => {\n pose.carriage = x\n M.carriage.setAttribute(\"transform\", `translate(${x.toFixed(2)} 0)`)\n }\n /** Sheet lean, a spring driven by how fast the carriage moves. */\n const flex = { angle: 0, vel: 0, x: 0, t: 0 }\n const placePaper = () => {\n const y = pose.paper\n const a = flex.angle\n M.paper.setAttribute(\n \"transform\",\n Math.abs(a) < 0.01\n ? `translate(0 ${y.toFixed(2)})`\n : `translate(0 ${FLEX_ANCHOR}) skewX(${a.toFixed(3)}) translate(0 ${(y - FLEX_ANCHOR).toFixed(2)})`\n )\n }\n const setPaper = (y: number) => {\n pose.paper = y\n placePaper()\n }\n /** Knurling on the platen knobs, scrolling as the platen turns. */\n const setKnob = (phase: number) => {\n pose.knob = phase\n for (const knob of M.knobs) {\n knob.lines.forEach((line, i) => {\n const a = phase + (i / RIDGES) * TAU\n const facing = Math.cos(a)\n if (facing <= 0.05) {\n line.setAttribute(\"stroke-opacity\", \"0\")\n return\n }\n const y = (23 + Math.sin(a) * 19).toFixed(2)\n line.setAttribute(\"y1\", y)\n line.setAttribute(\"y2\", y)\n line.setAttribute(\"stroke-opacity\", (0.18 + facing * 0.42).toFixed(2))\n })\n }\n }\n const setSpool = (deg: number) => {\n pose.spool = deg\n M.spools.forEach((spool, i) => {\n const x = i === 0 ? -SPOOL_X : SPOOL_X\n spool.setAttribute(\n \"transform\",\n `rotate(${(i === 0 ? -deg : deg).toFixed(1)} ${x} ${SPOOL_Y})`\n )\n })\n }\n const setLever = (deg: number) => {\n pose.lever = deg\n const [x, y] = M.leverPivot\n M.lever.setAttribute(\"transform\", `rotate(${deg.toFixed(2)} ${x} ${y})`)\n }\n const setShift = (v: number) => {\n pose.shift = v\n M.basket.setAttribute(\"transform\", `translate(0 ${(v * 4).toFixed(2)})`)\n for (const key of M.shifts) setKey(key, v)\n }\n /** The ribbon vibrator lifts the ribbon over the print point. */\n const setRibbon = (v: number) => {\n pose.ribbon = v\n const lift = v * 13\n const y = 7.5 - lift\n const top = SPOOL_Y - 24\n for (const { path, dy } of M.ribbon) {\n path.setAttribute(\n \"d\",\n `M${-SPOOL_X} ${top + dy}L-12 ${(y + dy).toFixed(2)}H12L${SPOOL_X} ${top + dy}`\n )\n }\n M.vibrator.setAttribute(\"transform\", `translate(0 ${(-lift).toFixed(2)})`)\n }\n const setKey = (key: Key, v: number) => {\n key.v = v\n key.cap.setAttribute(\n \"transform\",\n v > 0.001 ? `translate(0 ${(v * KEY_TRAVEL).toFixed(2)})` : \"\"\n )\n }\n /** 0 rests in the basket; 1 is the slug on the paper. */\n const setBar = (bar: Bar, v: number) => {\n bar.v = v\n if (v <= 0.001) {\n bar.rest.removeAttribute(\"display\")\n bar.strike.setAttribute(\"display\", \"none\")\n return\n }\n bar.rest.setAttribute(\"display\", \"none\")\n bar.strike.removeAttribute(\"display\")\n const drop = pose.shift * 4\n const [px, py0] = bar.pivot\n const py = py0 + drop\n const tx = bar.tip[0] + (0 - bar.tip[0]) * v\n const ty = bar.tip[1] + drop + (STRIKE_Y - bar.tip[1] - drop) * v\n const len = Math.hypot(tx - px, ty - py) || 1\n const ux = (tx - px) / len\n const uy = (ty - py) / len\n for (const line of [bar.edge, bar.core]) {\n line.setAttribute(\"x1\", px.toFixed(2))\n line.setAttribute(\"y1\", py.toFixed(2))\n line.setAttribute(\"x2\", tx.toFixed(2))\n line.setAttribute(\"y2\", ty.toFixed(2))\n }\n bar.slug.setAttribute(\"x1\", (tx - ux * 6).toFixed(2))\n bar.slug.setAttribute(\"y1\", (ty - uy * 6).toFixed(2))\n bar.slug.setAttribute(\"x2\", tx.toFixed(2))\n bar.slug.setAttribute(\"y2\", ty.toFixed(2))\n }\n\n /** Everything at rest, the carriage and sheet where the text has got to. */\n const restPose = () => {\n setCarriage(carriageAt(cursor.col))\n setPaper(-cursor.line * LH)\n setKnob(pose.knob)\n setSpool(pose.spool)\n setLever(0)\n setShift(capsLock ? 1 : 0)\n setRibbon(0)\n flex.angle = 0\n flex.vel = 0\n placePaper()\n for (const key of M.keys.values()) setKey(key, 0)\n setKey(M.space, 0)\n for (const bar of M.bars) setBar(bar, 0)\n }\n\n /* ---------------------------------------------------------------- */\n /* Motion */\n /* ---------------------------------------------------------------- */\n\n const queued: Motion[] = []\n const running = new Map()\n let raf = 0\n\n const frame = (now: number) => {\n raf = 0\n queued.sort((a, b) => a.start - b.start)\n while (queued.length && queued[0]!.start <= now) {\n const motion = queued.shift()!\n motion.begin?.()\n running.set(motion.key, motion)\n }\n for (const [key, motion] of running) {\n const t = clamp((now - motion.start) / motion.ms, 0, 1)\n motion.run(motion.ease(t))\n if (t >= 1) running.delete(key)\n }\n // The sheet leans against the carriage's motion, then wobbles upright.\n const dt = flex.t ? Math.min(0.05, (now - flex.t) / 1000) : 0\n flex.t = now\n if (dt > 0) {\n const speed = (pose.carriage - flex.x) / dt\n const target = clamp(speed * FLEX_GAIN, -FLEX_MAX, FLEX_MAX)\n flex.vel += (-FLEX_K * (flex.angle - target) - FLEX_C * flex.vel) * dt\n flex.angle += flex.vel * dt\n placePaper()\n }\n flex.x = pose.carriage\n const settling = Math.abs(flex.angle) > 0.02 || Math.abs(flex.vel) > 0.05\n if (queued.length || running.size || settling) {\n raf = requestAnimationFrame(frame)\n } else {\n flex.t = 0\n }\n }\n const wake = () => {\n if (!raf) raf = requestAnimationFrame(frame)\n }\n /** Run `run(0…1)` over `ms`. A motion with the same key replaces it. */\n const play = (\n key: string,\n ms: number,\n run: (v: number) => void,\n {\n delay = 0,\n ease = linear,\n begin,\n }: {\n delay?: number\n ease?: (t: number) => number\n begin?: () => void\n } = {}\n ) => {\n queued.push({\n key,\n start: performance.now() + delay,\n ms: Math.max(1, ms),\n ease,\n run,\n begin,\n })\n wake()\n }\n /** Ease a pose from wherever it is when the motion starts. */\n const tween = (\n key: string,\n get: () => number,\n set: (v: number) => void,\n to: number,\n ms: number,\n opts: { delay?: number; ease?: (t: number) => number } = {}\n ) => {\n let from = 0\n play(key, ms, (v) => set(from + (to - from) * v), {\n ...opts,\n begin: () => {\n from = get()\n },\n })\n }\n const stopMotion = () => {\n queued.length = 0\n running.clear()\n cancelAnimationFrame(raf)\n raf = 0\n }\n\n const press = (name: string, key: Key, ms: number, delay = 0) => {\n let from = 0\n play(name, ms, (t) => setKey(key, pulse(t, from, 0.22, 0.28)), {\n delay,\n begin: () => {\n from = key.v\n },\n })\n }\n\n /* ---------------------------------------------------------------- */\n /* Typing */\n /* ---------------------------------------------------------------- */\n\n let strokes: TypewriterStroke[] = []\n /** Strokes on the page. */\n let pos = 0\n let cursor = { line: 0, col: 0 }\n let sheetH = 600\n let phase: \"waiting\" | \"typing\" | \"done\" | \"feeding\" | \"manual\" = \"waiting\"\n /** Shift lock, from the machine's own shift keys. */\n let capsLock = false\n /** Next `src` for strokes typed by hand. */\n let nextSrc = 0\n let rang = false\n let destroyed = false\n let reduce = false\n let onScreen = true\n let sound: Sound | null = null\n\n // One pending step, paused while the machine can't be seen.\n let pending: (() => void) | null = null\n let timer = 0\n let remaining = 0\n let dueAt = 0\n const active = () =>\n !destroyed && onScreen && !reduce && document.visibilityState !== \"hidden\"\n const arm = () => {\n dueAt = performance.now() + remaining\n timer = window.setTimeout(() => {\n timer = 0\n const run = pending\n pending = null\n run?.()\n }, remaining)\n }\n const schedule = (run: () => void, ms: number) => {\n window.clearTimeout(timer)\n timer = 0\n pending = run\n remaining = Math.max(0, ms)\n if (active()) arm()\n }\n const cancel = () => {\n window.clearTimeout(timer)\n timer = 0\n pending = null\n }\n const sync = () => {\n if (active()) {\n if (pending && !timer) arm()\n } else if (timer) {\n window.clearTimeout(timer)\n timer = 0\n remaining = Math.max(0, dueAt - performance.now())\n }\n }\n\n const soundOn = () => {\n if (!options.sound) return null\n sound ??= createSound()\n return sound\n }\n\n const after = (stroke: TypewriterStroke | undefined) => {\n if (!stroke) return { line: 0, col: 0 }\n return stroke.kind === \"char\"\n ? { line: stroke.line, col: stroke.col + 1 }\n : { line: stroke.line, col: 0 }\n }\n\n /**\n * A letter as it lands: a little off its cell, sometimes under-inked\n * where the ribbon missed, now and then struck twice.\n */\n const letter = (stroke: Extract) => {\n const jitter = jitterOf()\n const n = Math.floor(stroke.src) * 8\n const x = stroke.col * CW + CW / 2\n const y = stroke.line * LH\n const group = draw(\"g\", {}, M.ink)\n const glyph = (attrs: Record) => {\n const el = draw(\"text\", { x, y, ...attrs }, group)\n el.textContent = stroke.ch\n return el\n }\n if (jitter <= 0) {\n glyph({})\n return group\n }\n const dx = (hash(n) - 0.5) * jitter * 1.1\n const dy = (hash(n + 1) - 0.5) * jitter * 1.6\n const tilt = (hash(n + 2) - 0.5) * jitter * 3.2\n group.setAttribute(\n \"transform\",\n `translate(${dx.toFixed(2)} ${dy.toFixed(2)}) rotate(${tilt.toFixed(2)} ${x} ${y})`\n )\n const ink = 1 - hash(n + 3) * jitter * 0.38\n const worn = hash(n + 4) < jitter * 0.6\n glyph({\n \"fill-opacity\": ink.toFixed(3),\n ...(worn\n ? {\n fill: M.inkFills[Math.floor(hash(n + 5) * M.inkFills.length)]!,\n }\n : {}),\n })\n if (hash(n + 6) < jitter * 0.14) {\n glyph({\n \"fill-opacity\": (ink * 0.42).toFixed(3),\n transform: `translate(${(0.45 + hash(n + 7) * 0.3).toFixed(2)} 0.3)`,\n })\n }\n return group\n }\n\n const sizeSheet = () => {\n const lines = (strokes.at(-1)?.line ?? 0) + 1\n sheetH = Math.max(\n TOP_MARGIN + lines * LH + 60,\n (M.columns * CW + PAD * 2) * 1.3\n )\n M.sheet.setAttribute(\"height\", sheetH.toFixed(0))\n M.shadow.setAttribute(\"height\", sheetH.toFixed(0))\n }\n\n /** Print the first `pos` strokes and park the machine, without motion. */\n const paint = () => {\n M.ink.replaceChildren()\n for (let i = 0; i < pos; i++) {\n const stroke = strokes[i]!\n if (stroke.kind === \"char\" && stroke.ch !== \" \") letter(stroke)\n }\n if (phase !== \"manual\") cursor = after(strokes[pos - 1])\n rang = cursor.col >= M.columns - BELL_CELLS\n restPose()\n }\n\n const keyFor = (ch: string) => {\n const lower = ch.toLowerCase()\n const own = M.keys.get(lower)\n if (own) return { key: own, shift: ch !== lower }\n const base = SHIFTED[ch]\n if (base) return { key: M.keys.get(base)!, shift: true }\n // Anything the keyboard doesn't have borrows a key.\n const all = [...M.keys.values()]\n return {\n key: all[(ch.codePointAt(0) ?? 0) % all.length]!,\n shift: false,\n }\n }\n\n const pace = (ch: string) => {\n const base = 1000 / speedOf()\n if (!(options.humanize ?? true)) return base\n let factor = 0.55 + Math.random() * 0.9\n if (ch === \" \") factor *= 1.15\n else if (/[,;:]/.test(ch)) factor *= 2.4\n else if (/[.!?]/.test(ch)) factor *= 3.4\n if (Math.random() < 0.04) factor *= 2.6\n return base * factor\n }\n\n /** Press the key, swing its bar into the ribbon, step the carriage. */\n const strike = (\n stroke: Extract,\n fixed?: number\n ) => {\n const base = 1000 / speedOf()\n const ms = fixed ?? clamp(base * 1.7, 90, 170)\n const audio = soundOn()\n const step = carriageAt(stroke.col + 1)\n cursor = { line: stroke.line, col: stroke.col + 1 }\n\n if (stroke.ch === \" \") {\n press(\"space\", M.space, ms)\n tween(\"carriage\", () => pose.carriage, setCarriage, step, ms * 0.4, {\n delay: ms * 0.2,\n ease: easeOut,\n })\n audio?.key(true)\n } else {\n const { key, shift } = keyFor(stroke.ch)\n const lead = shift ? ms * 0.25 : 0\n const impact = lead + ms * 0.42\n if (shift && !capsLock) {\n let from = 0\n play(\"shift\", ms * 1.6, (t) => setShift(pulse(t, from, 0.16, 0.5)), {\n begin: () => {\n from = pose.shift\n },\n })\n }\n press(`key-${stroke.ch.toLowerCase()}`, key, ms * 1.1, lead)\n const bar = key.bar\n if (bar) {\n let from = 0\n play(\n `bar-${M.bars.indexOf(bar)}`,\n ms,\n (t) => {\n const v =\n t < 0.42\n ? from + (1 - from) * easeIn(t / 0.42)\n : 1 - easeOut((t - 0.42) / 0.58)\n setBar(bar, v)\n },\n {\n delay: lead,\n begin: () => {\n from = bar.v\n },\n }\n )\n }\n let lifted = 0\n play(\"ribbon\", ms * 0.9, (t) => setRibbon(pulse(t, lifted, 0.4, 0.08)), {\n delay: lead + ms * 0.08,\n begin: () => {\n lifted = pose.ribbon\n },\n })\n const mark = letter(stroke)\n mark.setAttribute(\"opacity\", \"0\")\n play(\n `ink-${stroke.src}`,\n 60,\n (v) => mark.setAttribute(\"opacity\", v.toFixed(3)),\n { delay: impact }\n )\n // The whole machine takes the blow.\n play(\n \"shudder\",\n 110,\n (v) => {\n const y = Math.sin(v * Math.PI) * (1 - v) * 1.6\n M.rig.setAttribute(\n \"transform\",\n y > 0.01 ? `translate(0 ${y.toFixed(2)})` : \"\"\n )\n },\n { delay: impact }\n )\n tween(\"carriage\", () => pose.carriage, setCarriage, step, ms * 0.36, {\n delay: impact + 6,\n ease: easeOut,\n })\n tween(\"spool\", () => pose.spool, setSpool, pose.spool + 5, ms * 0.3, {\n delay: impact,\n })\n if (audio) window.setTimeout(() => audio.key(false), impact)\n }\n\n if (\n !rang &&\n cursor.col >= M.columns - BELL_CELLS &&\n M.columns > BELL_CELLS * 2\n ) {\n rang = true\n audio?.bell()\n }\n }\n\n /** Kick the lever, turn up a line, slide the carriage home. */\n const carriageReturn = (\n stroke: Extract\n ) => {\n const glide = clamp(220 + stroke.from * 8, 260, 640)\n cursor = { line: stroke.line, col: 0 }\n rang = false\n play(\"lever\", 420, (t) => setLever(-24 * pulse(t, 0, 0.3, 0.12)))\n tween(\"paper\", () => pose.paper, setPaper, -stroke.line * LH, 220, {\n delay: 60,\n ease: easeInOut,\n })\n tween(\"knob\", () => pose.knob, setKnob, pose.knob + LH / KNOB_R, 220, {\n delay: 60,\n ease: easeInOut,\n })\n tween(\"carriage\", () => pose.carriage, setCarriage, carriageAt(0), glide, {\n delay: 130,\n ease: easeInOut,\n })\n soundOn()?.carriage(glide)\n return glide + 130\n }\n\n const setPhase = (next: typeof phase) => {\n phase = next\n }\n\n const finish = () => {\n setPhase(\"done\")\n options.onDone?.()\n if (options.loop ?? true) schedule(feedOut, holdOf())\n }\n\n const step = () => {\n if (pos >= strokes.length) {\n finish()\n return\n }\n setPhase(\"typing\")\n const stroke = strokes[pos++]!\n if (stroke.kind === \"char\") {\n strike(stroke)\n schedule(step, pace(stroke.ch))\n } else {\n const ms = carriageReturn(stroke)\n schedule(step, ms + pace(\" \") * 0.8)\n }\n }\n\n /** Roll a fresh sheet up from behind the platen. */\n const feedIn = (then: () => void) => {\n setPaper(40 + TOP_MARGIN)\n tween(\"paper\", () => pose.paper, setPaper, 0, 900, { ease: easeOut })\n tween(\"knob\", () => pose.knob, setKnob, pose.knob + 5, 900, {\n ease: easeOut,\n })\n schedule(then, 940)\n }\n\n /** Pull the finished sheet up and out, bring the carriage home, go again. */\n const feedOut = () => {\n setPhase(\"feeding\")\n const ms = 950\n tween(\"paper\", () => pose.paper, setPaper, VIEW_TOP - sheetH - 20, ms, {\n ease: easeIn,\n })\n tween(\"knob\", () => pose.knob, setKnob, pose.knob + 7, ms, {\n ease: easeIn,\n })\n tween(\"carriage\", () => pose.carriage, setCarriage, carriageAt(0), 720, {\n ease: easeInOut,\n })\n if (cursor.col > 0) {\n play(\"lever\", 420, (t) => setLever(-24 * pulse(t, 0, 0.3, 0.12)))\n }\n schedule(() => {\n pos = 0\n paint()\n setPhase(\"waiting\")\n feedIn(() => schedule(step, startOf() * 0.5))\n }, ms + 40)\n }\n\n const start = () => {\n cancel()\n stopMotion()\n capsLock = false\n strokes = layoutTypewriter(textOf(), M.columns)\n spoken.textContent = textOf()\n sizeSheet()\n if (reduce) {\n pos = strokes.length\n setPhase(\"done\")\n paint()\n return\n }\n pos = 0\n setPhase(\"waiting\")\n paint()\n feedIn(() => schedule(step, startOf()))\n }\n\n /** Redraw for a new line length, keeping the place in the text. */\n const rebuild = () => {\n const at = strokes[pos - 1]?.src ?? -1\n stopMotion()\n build()\n if (phase === \"manual\") {\n sizeSheet()\n paint()\n return\n }\n strokes = layoutTypewriter(textOf(), M.columns)\n if (phase === \"done\") pos = strokes.length\n else {\n pos = 0\n while (pos < strokes.length && strokes[pos]!.src <= at) pos += 1\n }\n sizeSheet()\n paint()\n if (phase === \"typing\" || phase === \"feeding\") {\n setPhase(\"typing\")\n schedule(step, 200)\n } else if (phase === \"waiting\") schedule(step, startOf())\n }\n\n /* ---------------------------------------------------------------- */\n /* Typing by hand */\n /* ---------------------------------------------------------------- */\n\n const interactiveOf = () => options.interactive ?? true\n\n /** The first key from a person stops the script; the page stays. */\n const takeOver = () => {\n if (phase === \"manual\") return\n cancel()\n if (phase === \"feeding\" || phase === \"waiting\") {\n stopMotion()\n strokes = phase === \"feeding\" ? [] : strokes.slice(0, pos)\n } else {\n strokes = strokes.slice(0, pos)\n }\n pos = strokes.length\n cursor = after(strokes[pos - 1])\n nextSrc = Math.floor(strokes.at(-1)?.src ?? -1) + 1\n setPhase(\"manual\")\n sizeSheet()\n if (!running.size) paint()\n }\n\n const typeReturn = () => {\n takeOver()\n const stroke: TypewriterStroke = {\n kind: \"return\",\n line: cursor.line + 1,\n from: cursor.col,\n src: nextSrc++,\n }\n strokes.push(stroke)\n pos = strokes.length\n sizeSheet()\n if (reduce) {\n cursor = after(stroke)\n setPaper(-cursor.line * LH)\n setCarriage(carriageAt(0))\n return\n }\n carriageReturn(stroke)\n }\n\n const typeKey = (ch: string) => {\n takeOver()\n // No margin release: at the margin the carriage returns on its own.\n if (cursor.col >= M.columns) typeReturn()\n const stroke: Extract = {\n kind: \"char\",\n ch,\n line: cursor.line,\n col: cursor.col,\n src: nextSrc++,\n }\n strokes.push(stroke)\n pos = strokes.length\n if (reduce) {\n if (ch !== \" \") letter(stroke)\n cursor = after(stroke)\n setCarriage(carriageAt(cursor.col))\n return\n }\n strike(stroke, 120)\n }\n\n /** Backspace moves the carriage back a space; the next letter overstrikes. */\n const typeBack = () => {\n takeOver()\n if (cursor.col === 0) return\n cursor = { line: cursor.line, col: cursor.col - 1 }\n if (reduce) {\n setCarriage(carriageAt(cursor.col))\n return\n }\n tween(\n \"carriage\",\n () => pose.carriage,\n setCarriage,\n carriageAt(cursor.col),\n 90,\n {\n ease: easeOut,\n }\n )\n soundOn()?.key(true)\n }\n\n const setShiftLock = (on: boolean) => {\n capsLock = on\n if (reduce) setShift(on ? 1 : 0)\n else\n tween(\"shift\", () => pose.shift, setShift, on ? 1 : 0, 130, {\n ease: easeOut,\n })\n }\n\n const onKeyDown = (event: KeyboardEvent) => {\n if (!interactiveOf() || event.ctrlKey || event.metaKey || event.altKey) {\n return\n }\n const { key } = event\n if (key === \"Enter\") {\n if (!event.repeat) typeReturn()\n } else if (key === \"Backspace\") {\n typeBack()\n } else if (key.length === 1) {\n // A real machine doesn't auto-repeat.\n if (!event.repeat) typeKey(key)\n } else {\n return\n }\n event.preventDefault()\n }\n\n const onPointerDown = (event: PointerEvent) => {\n if (!interactiveOf() || event.button !== 0) return\n root.focus({ preventScroll: true })\n const hit = (event.target as Element | null)?.closest?.(\"[data-key]\")\n const key = hit?.getAttribute(\"data-key\")\n if (key == null) return\n event.preventDefault()\n if (key === \"shift\") setShiftLock(!capsLock)\n else if (key === \"return\") typeReturn()\n else typeKey(capsLock ? key.toUpperCase() : key)\n }\n\n /** Focus, keyboard, and clickable parts follow `interactive`. */\n function setInteractive() {\n const on = interactiveOf()\n if (on) {\n root.tabIndex = 0\n root.setAttribute(\"role\", \"group\")\n root.setAttribute(\n \"aria-label\",\n \"Typewriter. Focus it and type, or click its keys.\"\n )\n } else {\n root.removeAttribute(\"tabindex\")\n root.removeAttribute(\"role\")\n root.removeAttribute(\"aria-label\")\n }\n for (const el of svg.querySelectorAll(\"[data-key]\")) {\n el.style.cursor = on ? \"pointer\" : \"\"\n }\n }\n\n root.addEventListener(\"keydown\", onKeyDown)\n svg.addEventListener(\"pointerdown\", onPointerDown)\n\n /* ---------------------------------------------------------------- */\n /* Theme */\n /* ---------------------------------------------------------------- */\n\n const isDark = () => {\n const html = document.documentElement\n if (html.classList.contains(\"dark\")) return true\n if (html.classList.contains(\"light\")) return false\n const attr = html.getAttribute(\"data-theme\")\n if (attr === \"dark\") return true\n if (attr === \"light\") return false\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n }\n /** Ivory in light, a touch dimmer in dark so it doesn't glare. */\n const applyTheme = () => {\n root.style.setProperty(\n \"--typewriter-sheet\",\n isDark() ? \"#ECE7DA\" : \"#FBF9F3\"\n )\n }\n\n build()\n applyTheme()\n\n const mqReduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n reduce = mqReduce.matches\n const onReduce = () => {\n reduce = mqReduce.matches\n start()\n }\n mqReduce.addEventListener(\"change\", onReduce)\n\n start()\n\n const theme = new MutationObserver(applyTheme)\n theme.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"class\", \"data-theme\", \"style\"],\n })\n const mqDark = window.matchMedia(\"(prefers-color-scheme: dark)\")\n mqDark.addEventListener(\"change\", applyTheme)\n\n const io = new IntersectionObserver(([entry]) => {\n onScreen = entry?.isIntersecting ?? true\n sync()\n })\n io.observe(root)\n const onVisibility = () => sync()\n document.addEventListener(\"visibilitychange\", onVisibility)\n\n return {\n setOptions(next) {\n const prev = options\n options = { ...options, ...next }\n if (textOf() !== (prev.text ?? DEFAULT_TEXT)) {\n start()\n return\n }\n if (\n columnsOf() !== M.columns ||\n options.color !== prev.color ||\n options.label !== prev.label\n ) {\n rebuild()\n }\n if (options.interactive !== prev.interactive) setInteractive()\n else if (options.jitter !== prev.jitter) {\n for (const motion of running.values()) motion.run(1)\n stopMotion()\n paint()\n }\n const loop = options.loop ?? true\n if (loop !== (prev.loop ?? true) && phase === \"done\") {\n if (loop) schedule(feedOut, holdOf())\n else cancel()\n }\n },\n restart: start,\n destroy() {\n destroyed = true\n cancel()\n stopMotion()\n theme.disconnect()\n mqDark.removeEventListener(\"change\", applyTheme)\n io.disconnect()\n mqReduce.removeEventListener(\"change\", onReduce)\n document.removeEventListener(\"visibilitychange\", onVisibility)\n root.removeEventListener(\"keydown\", onKeyDown)\n svg.removeEventListener(\"pointerdown\", onPointerDown)\n for (const name of [\"tabindex\", \"role\", \"aria-label\"]) {\n root.removeAttribute(name)\n }\n sound?.close()\n root.style.removeProperty(\"--typewriter-sheet\")\n spoken.remove()\n svg.remove()\n },\n }\n}\n\nexport type TypewriterProps = TypewriterOptions & {\n className?: string\n}\n\n/**\n * A typewriter that types — each letter presses its key, swings that key's\n * typebar up to strike the ribbon, and steps the carriage left. At the\n * margin the lever kicks, the platen turns up a line, and the carriage\n * slides home. Ink follows `currentColor`. Honors\n * `prefers-reduced-motion`.\n */\nexport function Typewriter({\n className,\n text = DEFAULT_TEXT,\n speed = DEFAULT_SPEED,\n humanize = true,\n loop = true,\n hold = DEFAULT_HOLD,\n startDelay = DEFAULT_START_DELAY,\n color = DEFAULT_COLOR,\n columns = DEFAULT_COLUMNS,\n jitter = DEFAULT_JITTER,\n sound = false,\n interactive = true,\n label,\n onDone,\n}: TypewriterProps) {\n const rootRef = useRef(null)\n const instanceRef = useRef(null)\n const onDoneRef = useRef(onDone)\n\n useEffect(() => {\n onDoneRef.current = onDone\n }, [onDone])\n\n useEffect(() => {\n const root = rootRef.current\n if (!root) return\n instanceRef.current = createTypewriter(root, {\n text,\n speed,\n humanize,\n loop,\n hold,\n startDelay,\n color,\n columns,\n jitter,\n sound,\n interactive,\n label,\n onDone: () => onDoneRef.current?.(),\n })\n return () => {\n instanceRef.current?.destroy()\n instanceRef.current = null\n }\n // Engine reads live options via setOptions; mount once.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n useEffect(() => {\n instanceRef.current?.setOptions({\n text,\n speed,\n humanize,\n loop,\n hold,\n startDelay,\n color,\n columns,\n jitter,\n sound,\n interactive,\n label,\n })\n }, [\n text,\n speed,\n humanize,\n loop,\n hold,\n startDelay,\n color,\n columns,\n jitter,\n sound,\n interactive,\n label,\n ])\n\n return (\n \n )\n}\n", + "type": "registry:ui", + "target": "components/ui/typewriter.tsx" + } + ], + "type": "registry:ui" +} \ No newline at end of file diff --git a/registry.json b/registry.json index 306b41f..d1f7521 100644 --- a/registry.json +++ b/registry.json @@ -12,10 +12,11 @@ "registry/logo-burst/registry.json", "registry/phosphor-score/registry.json", "registry/radiant-lines/registry.json", + "registry/shader-anime-fire/registry.json", "registry/shader-fire/registry.json", "registry/shader-gradient/registry.json", "registry/shader-sky/registry.json", "registry/stretchy-footer/registry.json", - "registry/tangle-footer/registry.json" + "registry/typewriter/registry.json" ] } diff --git a/registry/ascii-fluid/ascii-fluid.svelte b/registry/ascii-fluid/ascii-fluid.svelte index a122f24..af99365 100644 --- a/registry/ascii-fluid/ascii-fluid.svelte +++ b/registry/ascii-fluid/ascii-fluid.svelte @@ -74,7 +74,10 @@ diff --git a/registry/ascii-fluid/ascii-fluid.tsx b/registry/ascii-fluid/ascii-fluid.tsx index f98ecbc..6d70499 100644 --- a/registry/ascii-fluid/ascii-fluid.tsx +++ b/registry/ascii-fluid/ascii-fluid.tsx @@ -96,11 +96,17 @@ export function AsciiFluid({ data-slot="ascii-fluid" aria-hidden className={cn( - "pointer-events-none absolute inset-0 overflow-hidden", + "pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit] [mask-image:linear-gradient(#000,#000)]", className )} > - + {/* Firefox can hand an opaque WebGL canvas straight to the system + compositor, which ignores rounded clips. The mask on the root keeps + it in the page's own layer, so the parent's radius holds. */} + ) } diff --git a/registry/dithered-404/dithered-404-vanilla.ts b/registry/dithered-404/dithered-404-vanilla.ts index 094e4ae..5c32d71 100644 --- a/registry/dithered-404/dithered-404-vanilla.ts +++ b/registry/dithered-404/dithered-404-vanilla.ts @@ -480,7 +480,7 @@ function drawFireball( /** * Bayer-pixel 404 section — a fireball cursor scorches and burns * the glyph into embers and smoke, then the type reforms. - * `dither={false}` is a soft realistic fire, same idea as Shader Fire. + * `dither={false}` is a soft realistic fire. */ export function createDithered404( wrap: HTMLElement, diff --git a/registry/dithered-404/dithered-404.tsx b/registry/dithered-404/dithered-404.tsx index 1c109fb..61e065d 100644 --- a/registry/dithered-404/dithered-404.tsx +++ b/registry/dithered-404/dithered-404.tsx @@ -24,7 +24,7 @@ export type Dithered404Props = Dithered404Options & { /** * Bayer-pixel 404 section — a fireball cursor scorches and burns * the glyph into embers and smoke, then the type reforms. - * `dither={false}` is a soft realistic fire, same idea as Shader Fire. + * `dither={false}` is a soft realistic fire. */ export function Dithered404({ className, diff --git a/registry/folio/folio-vanilla.ts b/registry/folio/folio-vanilla.ts index 864d7c6..cfce71e 100644 --- a/registry/folio/folio-vanilla.ts +++ b/registry/folio/folio-vanilla.ts @@ -34,6 +34,14 @@ const MIN_PERSPECTIVE = 1000 const DEFAULT_RETURN_MS = 520 const VEL_REF = 2.4 const WHEEL_REF = 110 +/** + * Wheel speed, in px/ms, that leans all the way. Trackpads send a stream + * of small deltas, so a lean read per event barely moved; speed reads a + * fast swipe and a fast mouse flick the same. + */ +const WHEEL_VEL_REF = 1.1 +/** A wheel gap longer than this starts a fresh gesture. */ +const WHEEL_GESTURE_GAP_MS = 150 const WHEEL_SCROLL_LOCK_MS = 64 const END_SLACK_PX = 8 const SPRING_IN = { stiffness: 72, damping: 22 } @@ -274,9 +282,7 @@ function animateScroll( /** Lean the page while scrolling down, then spring flat. */ export async function playFolioDemo(detail: FolioPlayDetail = {}) { if (typeof window === "undefined") return - window.dispatchEvent( - new CustomEvent(FOLIO_PLAY, { detail }) - ) + window.dispatchEvent(new CustomEvent(FOLIO_PLAY, { detail })) const hold = detail.holdMs ?? 720 await new Promise((r) => window.setTimeout(r, hold + 1800)) } @@ -318,6 +324,7 @@ export function createFolio(options: { let lastTop = readScrollTop(options.scroller) let lastTime = performance.now() let lastWheelAt = 0 + let wheelVel = 0 let impulse = 0 let gate = 1 let gateFrom = 1 @@ -453,9 +460,21 @@ export function createFolio(options: { const onWheel = (event: Event) => { if (playing) return - const dy = (event as WheelEvent).deltaY + const wheel = event as WheelEvent + // Lines (Firefox mouse wheels) and pages, in px. + const dy = + wheel.deltaMode === 1 + ? wheel.deltaY * 16 + : wheel.deltaMode === 2 + ? wheel.deltaY * viewHeightOf(options.scroller) + : wheel.deltaY if (!dy) return - lastWheelAt = performance.now() + const now = performance.now() + const gap = now - lastWheelAt + // A lone mouse notch counts as one frame's worth of travel. + const speed = dy / Math.min(50, Math.max(8, gap)) + wheelVel = gap > WHEEL_GESTURE_GAP_MS ? speed : wheelVel * 0.6 + speed * 0.4 + lastWheelAt = now const atEnd = endFade(options.scroller) <= 0.02 const atStart = startFade(options.scroller) <= 0.02 const leavingEnd = atEnd && dy < 0 @@ -466,7 +485,7 @@ export function createFolio(options: { spring.set(0) return } - lean(leanFromDelta(dy), leavingEnd || leavingStart) + lean(clampSigned(wheelVel / WHEEL_VEL_REF), leavingEnd || leavingStart) } const onScroll = () => { diff --git a/registry/gooey-color-picker/gooey-color-picker.tsx b/registry/gooey-color-picker/gooey-color-picker.tsx index 027536a..c086b0e 100644 --- a/registry/gooey-color-picker/gooey-color-picker.tsx +++ b/registry/gooey-color-picker/gooey-color-picker.tsx @@ -3,8 +3,10 @@ import { useCallback, useEffect, + useLayoutEffect, useRef, useState, + useSyncExternalStore, type PointerEvent as ReactPointerEvent, } from "react" import { AnimatePresence, motion } from "motion/react" @@ -74,30 +76,35 @@ function useControllableColor( return [color, setColor] as const } -function bindDrag(onMove: (clientX: number, clientY: number) => void) { - return (event: ReactPointerEvent) => { - event.preventDefault() - const target = event.currentTarget - target.setPointerCapture(event.pointerId) - onMove(event.clientX, event.clientY) +function startDrag( + event: ReactPointerEvent, + onMove: (clientX: number, clientY: number) => void +) { + event.preventDefault() + const target = event.currentTarget + target.setPointerCapture(event.pointerId) + onMove(event.clientX, event.clientY) - function move(e: PointerEvent) { - onMove(e.clientX, e.clientY) - } - function up(e: PointerEvent) { - window.removeEventListener("pointermove", move) - window.removeEventListener("pointerup", up) - try { - target.releasePointerCapture(e.pointerId) - } catch { - // ignore - } + function move(e: PointerEvent) { + onMove(e.clientX, e.clientY) + } + function up(e: PointerEvent) { + window.removeEventListener("pointermove", move) + window.removeEventListener("pointerup", up) + try { + target.releasePointerCapture(e.pointerId) + } catch { + // ignore } - window.addEventListener("pointermove", move) - window.addEventListener("pointerup", up) } + window.addEventListener("pointermove", move) + window.addEventListener("pointerup", up) } +const noopSubscribe = () => () => {} +const hasEyeDropper = () => "EyeDropper" in window +const noEyeDropper = () => false + function TriggerIcon({ open }: { open: boolean }) { const paths = open ? CLOSE_PATHS : PALETTE_PATHS @@ -171,23 +178,26 @@ export function GooeyColorPicker({ const alphaRef = useRef(null) const [open, setOpen] = useState(false) const [color, setColor] = useControllableColor(value, defaultValue, onChange) + // Drag and eyedropper handlers read the latest color without re-binding. const colorRef = useRef(color) - colorRef.current = color + useLayoutEffect(() => { + colorRef.current = color + }) const css = toCss(color) const opaque = toCss({ ...color, a: 1 }) const hex = toHex(color) const [hexDraft, setHexDraft] = useState(hex) const hexFocused = useRef(false) - const [supportsEyeDropper, setSupportsEyeDropper] = useState(false) + const supportsEyeDropper = useSyncExternalStore( + noopSubscribe, + hasEyeDropper, + noEyeDropper + ) useEffect(() => { if (!hexFocused.current) setHexDraft(hex) }, [hex]) - useEffect(() => { - setSupportsEyeDropper(typeof window !== "undefined" && "EyeDropper" in window) - }, []) - // Commit a color from any picker surface (wheel, alpha, eyedropper) and keep // the code field in sync — even while the input is focused. const applyColor = useCallback( @@ -334,6 +344,9 @@ export function GooeyColorPicker({ role="slider" tabIndex={open ? 0 : -1} aria-label="Color" + aria-valuemin={0} + aria-valuemax={360} + aria-valuenow={Math.round(color.h)} aria-valuetext={`hue ${Math.round(color.h)}, saturation ${Math.round(color.s)}`} className={cn( "relative size-40 shrink-0 touch-none rounded-full outline-none", @@ -354,7 +367,7 @@ export function GooeyColorPicker({ ) `, }} - onPointerDown={bindDrag((x, y) => updateWheelFromPointer(x, y))} + onPointerDown={(event) => startDrag(event, updateWheelFromPointer)} onKeyDown={(event) => { if (event.key === "ArrowLeft") { event.preventDefault() @@ -399,7 +412,7 @@ export function GooeyColorPicker({ style={{ background: `linear-gradient(to right, transparent, ${opaque})`, }} - onPointerDown={bindDrag((x) => updateAlphaFromPointer(x))} + onPointerDown={(event) => startDrag(event, updateAlphaFromPointer)} onKeyDown={(event) => { if (event.key === "ArrowRight" || event.key === "ArrowUp") { event.preventDefault() diff --git a/registry/phosphor-score/phosphor-score-demo.tsx b/registry/phosphor-score/phosphor-score-demo.tsx index 5737f75..f0fd517 100644 --- a/registry/phosphor-score/phosphor-score-demo.tsx +++ b/registry/phosphor-score/phosphor-score-demo.tsx @@ -12,10 +12,10 @@ import { ComponentPreview } from "@/components/component-preview" import { useHydratedTheme } from "@/hooks/use-hydrated-theme" import { usePreviewProps } from "@/hooks/use-preview-props" import { - DARK_BG, DARK_COLOR, DEFAULT_DENSITY, DEFAULT_GLOW, + DEFAULT_SEED, DEFAULT_SPEED, LIGHT_COLOR, PhosphorScore, @@ -39,6 +39,10 @@ export function PhosphorScoreDemo() { glow: DEFAULT_GLOW, speed: DEFAULT_SPEED, density: DEFAULT_DENSITY, + seed: DEFAULT_SEED, + rotateX: 0, + rotateY: 0, + rotateZ: 0, sway: true, }), [stock] @@ -63,15 +67,16 @@ export function PhosphorScoreDemo() { title="Phosphor Score" stageClassName="min-h-0 overflow-hidden p-0" > -
+
@@ -88,6 +93,10 @@ export function PhosphorScoreDemo() { speed: props.speed === DEFAULT_SPEED ? undefined : props.speed, density: props.density === DEFAULT_DENSITY ? undefined : props.density, + seed: props.seed === DEFAULT_SEED ? undefined : props.seed, + rotateX: props.rotateX || undefined, + rotateY: props.rotateY || undefined, + rotateZ: props.rotateZ || undefined, sway: props.sway ? undefined : false, }} > @@ -120,6 +129,38 @@ export function PhosphorScoreDemo() { step={0.05} onChange={(v) => updateProp("density", v)} /> + updateProp("seed", v)} + /> + updateProp("rotateX", v)} + /> + updateProp("rotateY", v)} + /> + updateProp("rotateZ", v)} + /> >> 0 @@ -120,55 +145,31 @@ function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)) } -function wrapDelta(t: number, now: number, loop: number) { - let d = t - now - d = ((((d + loop / 2) % loop) + loop) % loop) - loop / 2 - return d -} - -function parseColor(input?: string): Rgb { - const raw = (input ?? DEFAULT_COLOR).trim() - if (/^#[0-9a-fA-F]{6}$/.test(raw)) { - return [ - parseInt(raw.slice(1, 3), 16), - parseInt(raw.slice(3, 5), 16), - parseInt(raw.slice(5, 7), 16), - ] - } - if (/^#[0-9a-fA-F]{3}$/.test(raw)) { - return [ - parseInt(raw[1]! + raw[1], 16), - parseInt(raw[2]! + raw[2], 16), - parseInt(raw[3]! + raw[3], 16), - ] - } - return [77, 255, 106] -} - -function rgba(rgb: Rgb, a: number) { - return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${clamp(a, 0, 1)})` -} - -function glowOf(value?: number) { - if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_GLOW - return clamp(value, 0, 100) +/** Signed beats from `now` to `t`, taking the shorter way round the loop. */ +function wrapDelta(t: number, now: number) { + const d = t - now + return ( + ((((d + LOOP_BEATS / 2) % LOOP_BEATS) + LOOP_BEATS) % LOOP_BEATS) - + LOOP_BEATS / 2 + ) } -function speedOf(value?: number) { - if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_SPEED - return clamp(value, 0.15, 4) -} - -function densityOf(value?: number) { - if (typeof value !== "number" || !Number.isFinite(value)) { - return DEFAULT_DENSITY - } - return clamp(value, 0.35, 1.85) +function numberOr(value: unknown, fallback: number, min: number, max: number) { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback + return clamp(value, min, max) } -function seedOf(value?: number) { - if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_SEED - return Math.round(value) >>> 0 +const glowOf = (value?: number) => numberOr(value, DEFAULT_GLOW, 0, 100) +const speedOf = (value?: number) => numberOr(value, DEFAULT_SPEED, 0.15, 4) +const densityOf = (value?: number) => + numberOr(value, DEFAULT_DENSITY, 0.35, 1.85) +const seedOf = (value?: number) => + Math.round(numberOr(value, DEFAULT_SEED, 0, 2 ** 32 - 1)) >>> 0 + +const tiltOf = (value?: number) => numberOr(value, 0, -MAX_TILT, MAX_TILT) +const spinOf = (value?: number) => { + const deg = numberOr(value, 0, -Infinity, Infinity) + return ((((deg + 180) % 360) + 360) % 360) - 180 } function themeOf(value?: PhosphorScoreTheme): PhosphorScoreTheme { @@ -210,166 +211,326 @@ export function resolveBg(dark: boolean) { return dark ? DARK_BG : LIGHT_BG } -type Score = { - notes: Note[] - marks: Mark[] - hairpins: Hairpin[] - slurs: Slur[] - links: Link[] +/** Any CSS color → rgb, using the canvas' own parser. */ +function parseColor( + ctx: CanvasRenderingContext2D, + input: string, + fallback: string +): Rgb { + ctx.fillStyle = fallback + ctx.fillStyle = input + const value = String(ctx.fillStyle) + if (value.startsWith("#") && value.length === 7) { + return [ + parseInt(value.slice(1, 3), 16), + parseInt(value.slice(3, 5), 16), + parseInt(value.slice(5, 7), 16), + ] + } + const parts = value.match(/[\d.]+/g)?.map(Number) ?? [] + return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0] +} + +function rgba(rgb: Rgb, a: number) { + return `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${clamp(a, 0, 1)})` } function generateScore(seed: number, density: number): Score { const rng = mulberry32(seed) const notes: Note[] = [] + const stems: Stem[] = [] const marks: Mark[] = [] const hairpins: Hairpin[] = [] const slurs: Slur[] = [] - const links: Link[] = [] - let group = 1 - const dens = densityOf(density) - - const pick = (list: readonly T[]) => list[Math.floor(rng() * list.length)]! - const pushNote = ( - t: number, - pitch: number, - dur: number, - staff: 0 | 1, - beam: number - ) => { - notes.push({ + const note = (t: number, pitch: number, staff: Staff, hollow = false) => { + const n: Note = { t, - pitch: clamp(pitch, -3, 11), - dur, + pitch: clamp(Math.round(pitch), -3, 11), staff, - beam, + hollow, acc: rng() < 0.08 ? (rng() < 0.5 ? -1 : 1) : 0, + } + notes.push(n) + return n + } + + const addStem = ( + staff: Staff, + list: Note[], + beams: Stem["beams"], + chord = false + ) => { + const pitches = list.map((n) => n.pitch) + const tip = + staff === 0 ? Math.min(...pitches) - STEM : Math.max(...pitches) + STEM + stems.push({ + staff, + notes: list, + t0: list[0]!.t, + t1: list[list.length - 1]!.t, + tip, + beams: list.length > 1 && !chord ? beams : 0, + chord, }) } - const addRun = ( - staff: 0 | 1, + const run = ( + staff: Staff, start: number, pitch: number, count: number, - step: number, - dir: number + step: number ) => { - const beam = group++ + let dir = rng() < 0.5 ? 1 : -1 let p = pitch + const list: Note[] = [] for (let i = 0; i < count; i++) { - if (rng() > 0.12 / dens) pushNote(start + i * step, p, step, staff, beam) - const hop = rng() < 0.18 ? 2 : 1 - p += dir * hop - if (p > 10 || p < -2) dir *= -1 + if (i === 0 || rng() > 0.12 / density) + list.push(note(start + i * step, p, staff)) + p += dir * (rng() < 0.18 ? 2 : 1) + if (p >= 10 || p <= -2) { + dir = -dir + p = clamp(p, -2, 10) + } } - if (count >= 4 && rng() < 0.45) { - slurs.push({ - t0: start, - t1: start + (count - 1) * step, - p0: pitch, - p1: p, - staff, - }) + // A run thinned down to one note is drawn as a lone quarter. + addStem(staff, list, step <= 0.25 ? 2 : 1) + if (list.length >= 4 && rng() < 0.45) { + slurs.push({ staff, first: list[0]!, last: list[list.length - 1]! }) } } - const addArp = (staff: 0 | 1, start: number, root: number, step: number) => { - const pattern = [0, 2, 4, 7, 4, 2, 0, -1] - const beam = group++ - for (let i = 0; i < pattern.length; i++) { - pushNote(start + i * step, root + pattern[i]!, step, staff, beam) - } + const arpeggio = ( + staff: Staff, + start: number, + root: number, + step: number + ) => { + const shape = [0, 2, 4, 7, 4, 2, 0, -1] + const list = shape.map((offset, i) => + note(start + i * step, root + offset, staff) + ) + addStem(staff, list, step <= 0.25 ? 2 : 1) } - const addChord = (staff: 0 | 1, t: number, root: number) => { - const beam = -group++ - pushNote(t, root, 1, staff, beam) - pushNote(t, root + 2, 1, staff, beam) - if (rng() < 0.7) pushNote(t, root + 4, 1, staff, beam) + const chord = (staff: Staff, t: number, root: number, hollow: boolean) => { + const list = [ + note(t, root, staff, hollow), + note(t, root + 2, staff, hollow), + ] + if (rng() < 0.7) list.push(note(t, root + 4, staff, hollow)) + addStem(staff, list, 0, true) } let t = 0 - while (t < LOOP_BEATS - 1) { + while (t < LOOP_END) { + const room = LOOP_END - t const kind = rng() - const leftBusy = rng() < 0.55 + dens * 0.15 - const runLen = rng() < 0.4 ? 8 : rng() < 0.55 ? 6 : 4 - const step = rng() < 0.28 * dens ? 0.125 : 0.25 - const startPitch = Math.floor(rng() * 7) + (leftBusy ? 1 : 0) + const lead: Staff = rng() < 0.55 ? 0 : 1 + const other: Staff = lead === 0 ? 1 : 0 + const phraseStart = t if (kind < 0.5) { - addRun( - leftBusy ? 0 : 1, - t, - startPitch, - runLen, - step, - rng() < 0.5 ? 1 : -1 - ) - if (rng() < 0.55 * dens) { - addChord(leftBusy ? 1 : 0, t, 2 + Math.floor(rng() * 4)) + const count = rng() < 0.4 ? 8 : rng() < 0.55 ? 6 : 4 + const step = rng() < 0.35 * density ? 0.25 : 0.5 + const length = count * step + if (length > room) break + run(lead, t, Math.floor(rng() * 7) + 1, count, step) + if (rng() < 0.55 * density) { + chord(other, t, 1 + Math.floor(rng() * 4), length >= 2 && rng() < 0.5) } - t += runLen * step + t += length } else if (kind < 0.72) { - addArp(0, t, 1 + Math.floor(rng() * 5), 0.25) - if (rng() < 0.7 * dens) { - addRun(1, t, 0 + Math.floor(rng() * 4), 4, 0.5, rng() < 0.5 ? 1 : -1) - } + if (room < 2) break + arpeggio(lead, t, 1 + Math.floor(rng() * 4), 0.25) + if (rng() < 0.7 * density) + run(other, t, Math.floor(rng() * 4) + 1, 4, 0.5) t += 2 - } else if (kind < 0.86) { - addChord(0, t, 3 + Math.floor(rng() * 4)) - addChord(1, t, Math.floor(rng() * 4)) - if (rng() < 0.5) { - links.push({ - t, - p0: 4, - p1: 4, - }) - } - t += rng() < 0.5 ? 1 : 2 + } else if (kind < 0.88) { + const hollow = rng() < 0.45 + const length = hollow ? 2 : 1 + if (length > room) break + chord(0, t, 2 + Math.floor(rng() * 4), hollow) + chord(1, t, Math.floor(rng() * 4), hollow) + t += length } else { - t += rng() < 0.5 ? 0.5 : 1 + // A rest. Sparser scores rest longer. + t += density < 0.8 ? 1 : 0.5 + continue } - if (rng() < 0.18) { + if (rng() < 0.2) marks.push({ - t, - staff: rng() < 0.5 ? 0 : 1, - text: pick(DYNAMICS), - pitch: rng() < 0.5 ? -3.6 : 11.4, - }) - } - if (rng() < 0.12) { - hairpins.push({ - t, - dur: 2 + Math.floor(rng() * 3), - staff: rng() < 0.5 ? 0 : 1, - open: rng() < 0.5, + t: phraseStart, + text: DYNAMICS[Math.floor(rng() * DYNAMICS.length)]!, }) + if (rng() < 0.14) { + const dur = Math.min(2 + Math.floor(rng() * 3), LOOP_END - phraseStart) + if (dur >= 1) hairpins.push({ t: phraseStart, dur, opening: rng() < 0.5 }) } } - return { notes, marks, hairpins, slurs, links } + return { notes, stems, marks, hairpins, slurs } } function makeDust(seed: number): Dust[] { const rng = mulberry32(seed ^ 0x9e3779b9) - const dust: Dust[] = [] - for (let i = 0; i < 90; i++) { - dust.push({ - x: (rng() - 0.5) * 2, - y: (rng() - 0.5) * 2, - z: rng(), - s: 0.4 + rng() * 1.4, - a: 0.08 + rng() * 0.22, - }) + return Array.from({ length: 90 }, () => ({ + x: rng(), + y: rng(), + z: rng(), + s: 0.4 + rng() * 1.4, + a: 0.08 + rng() * 0.22, + })) +} + +/** A soft round glow in `rgb`, drawn once and stamped with `drawImage`. */ +function makeGlowSprite(rgb: Rgb) { + const size = 64 + const sprite = document.createElement("canvas") + sprite.width = size + sprite.height = size + const g = sprite.getContext("2d") + if (!g) return sprite + const grad = g.createRadialGradient( + size / 2, + size / 2, + 0, + size / 2, + size / 2, + size / 2 + ) + grad.addColorStop(0, rgba(rgb, 1)) + grad.addColorStop(0.25, rgba(rgb, 0.45)) + grad.addColorStop(0.6, rgba(rgb, 0.1)) + grad.addColorStop(1, rgba(rgb, 0)) + g.fillStyle = grad + g.fillRect(0, 0, size, size) + return sprite +} + +/** Brightness at the playhead: ramps in just before, decays after (phosphor persistence). */ +function litAt(d: number) { + if (d > 0.3) return 0 + if (d >= 0) { + const k = 1 - d / 0.3 + return k * k + } + return Math.exp(d * 2.4) +} + +/** + * How the score plane sits in front of the camera. Plane coordinates are + * the unrotated canvas pixels; it turns about the playhead, which is also + * where perspective converges. + */ +type View = { + /** `rotateX · rotateY · rotateZ`, row-major, CSS axes (y down, z toward you). */ + m: number[] + ox: number + oy: number + /** Camera distance. */ + cam: number + /** No depth variation across the plane, so nothing foreshortens or fogs. */ + depthless: boolean + /** Bounds of the plane that are on screen and not lost in the distance. */ + x0: number + x1: number + y0: number + y1: number +} + +function makeView( + w: number, + h: number, + rotateX: number, + rotateY: number, + rotateZ: number +): View { + const rad = Math.PI / 180 + const [cx, sx] = [Math.cos(rotateX * rad), Math.sin(rotateX * rad)] + const [cy, sy] = [Math.cos(rotateY * rad), Math.sin(rotateY * rad)] + const [cz, sz] = [Math.cos(rotateZ * rad), Math.sin(rotateZ * rad)] + // Rx · Ry · Rz, the order CSS applies `rotateX() rotateY() rotateZ()`. + const m = [ + cy * cz, + -cy * sz, + sy, + sx * sy * cz + cx * sz, + -sx * sy * sz + cx * cz, + -sx * cy, + -cx * sy * cz + sx * sz, + cx * sy * sz + sx * cz, + cx * cy, + ] + const ox = w / 2 + const oy = h * PLAYHEAD + const cam = Math.max(w, h) * PERSPECTIVE + const view: View = { + m, + ox, + oy, + cam, + depthless: Math.abs(m[6]!) + Math.abs(m[7]!) < 1e-6, + x0: 0, + x1: w, + y0: 0, + y1: h, + } + if (rotateX === 0 && rotateY === 0 && rotateZ === 0) return view + + // The screen, less whatever lies beyond FAR_SCALE, traced back onto the + // plane. Along a ray through screen point (u, v) the plane is hit at + // t = m22·cam / (m22·cam − m02·u − m12·v), with depth scale 1 / t — so + // "not too far" is a straight line on screen and clips the rectangle. + const [m02, m12, m22] = [m[2]!, m[5]!, m[8]!] + const far = (u: number, v: number) => + m22 * cam * (1 - FAR_SCALE) - m02 * u - m12 * v + const corners: [number, number][] = [ + [-ox, -oy], + [w - ox, -oy], + [w - ox, h - oy], + [-ox, h - oy], + ] + const kept: [number, number][] = [] + corners.forEach((a, i) => { + const b = corners[(i + 1) % corners.length]! + const fa = far(a[0], a[1]) + const fb = far(b[0], b[1]) + if (fa >= 0) kept.push(a) + if (fa >= 0 !== fb >= 0) { + const k = fa / (fa - fb) + kept.push([a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k]) + } + }) + + view.x0 = view.y0 = Infinity + view.x1 = view.y1 = -Infinity + for (const [u, v] of kept) { + const t = (m22 * cam) / (m22 * cam - m02 * u - m12 * v) + const [wx, wy, wz] = [t * u, t * v, cam - t * cam] + const px = m[0]! * wx + m[3]! * wy + m[6]! * wz + ox + const py = m[1]! * wx + m[4]! * wy + m[7]! * wz + oy + view.x0 = Math.min(view.x0, px) + view.x1 = Math.max(view.x1, px) + view.y0 = Math.min(view.y0, py) + view.y1 = Math.max(view.y1, py) } - return dust + return view +} + +/** Fades the plane out as it recedes toward `FAR_SCALE`. */ +function fogOf(scale: number) { + const k = clamp((scale - FAR_SCALE) / FOG_RANGE, 0, 1) + return k * k * (3 - 2 * k) } /** - * Vertical phosphor sheet music — notation falls toward a playhead, - * blooms, then exits in a flare. Canvas 2D, CRT-green by default. + * Vertical phosphor sheet music: a grand staff scrolls down toward a + * playhead, each note blooms and flares as it plays, then fades with a + * phosphor afterglow. Canvas 2D; pauses off-screen and in hidden tabs, and + * holds a still frame under `prefers-reduced-motion`. */ export function createPhosphorScore( canvas: HTMLCanvasElement, @@ -388,21 +549,36 @@ export function createPhosphorScore( const ctx = canvas.getContext("2d", { alpha: true }) if (!ctx) return null - const size = { w: 0, h: 0 } let score = generateScore(seedOf(options.seed), densityOf(options.density)) + let previous: Score | null = null + let rebuildAge = REBUILD_FADE let dust = makeDust(seedOf(options.seed)) + let dark = resolveDark(themeOf(options.theme)) - let rgb = parseColor(resolveColor(options.color, dark)) + let rgb: Rgb = [0, 0, 0] + let glowSprite = document.createElement("canvas") + const recolor = () => { + rgb = parseColor( + ctx, + resolveColor(options.color, dark), + dark ? DARK_COLOR : LIGHT_COLOR + ) + glowSprite = makeGlowSprite(rgb) + } + recolor() options.onThemeChange?.(dark) + const flares: Flare[] = [] - const hit = new Set() + const size = { w: 0, h: 0, dpr: 1 } let reduce = false - let raf = 0 + let onScreen = true let running = true - let lastFrame = performance.now() - let elapsed = 8 + let raf = 0 + let lastFrame = 0 + // Start a few bars in, so the first frame already has notes at the playhead. + let beat = 8 let clock = 0 - let dtSmooth = 1 / 60 + let view = makeView(1, 1, 0, 0, 0) const resize = () => { const parent = canvas.parentElement @@ -410,467 +586,585 @@ export function createPhosphorScore( const dpr = Math.min(window.devicePixelRatio || 1, 2) const w = parent.clientWidth const h = parent.clientHeight + if (w === size.w && h === size.h && dpr === size.dpr) return size.w = w size.h = h - canvas.width = Math.floor(w * dpr) - canvas.height = Math.floor(h * dpr) + size.dpr = dpr + canvas.width = Math.max(1, Math.floor(w * dpr)) + canvas.height = Math.max(1, Math.floor(h * dpr)) canvas.style.width = `${w}px` canvas.style.height = `${h}px` - ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + wake() } - resize() - const ro = new ResizeObserver(resize) - if (canvas.parentElement) ro.observe(canvas.parentElement) + const shouldAnimate = () => + running && onScreen && !reduce && document.visibilityState !== "hidden" - const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)") - const onReduce = () => { - reduce = mqReduce.matches + function wake() { + if (!running || raf) return + lastFrame = performance.now() + raf = requestAnimationFrame(tick) } - onReduce() - mqReduce.addEventListener("change", onReduce) const syncTheme = () => { const next = resolveDark(themeOf(options.theme)) if (next === dark) return dark = next - rgb = parseColor(resolveColor(options.color, dark)) + recolor() options.onThemeChange?.(dark) + wake() + } + + const ro = new ResizeObserver(resize) + if (canvas.parentElement) ro.observe(canvas.parentElement) + + const io = new IntersectionObserver(([entry]) => { + onScreen = entry?.isIntersecting ?? true + wake() + }) + io.observe(canvas) + + const onVisibility = () => wake() + document.addEventListener("visibilitychange", onVisibility) + + const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)") + const onReduce = () => { + reduce = mqReduce.matches + wake() } + reduce = mqReduce.matches + mqReduce.addEventListener("change", onReduce) + const mo = new MutationObserver(syncTheme) mo.observe(document.documentElement, { attributes: true, - attributeFilter: ["class", "data-theme", "style"], + attributeFilter: ["class", "data-theme"], }) const mqDark = window.matchMedia("(prefers-color-scheme: dark)") mqDark.addEventListener("change", syncTheme) - const rebuild = () => { - score = generateScore(seedOf(options.seed), densityOf(options.density)) - dust = makeDust(seedOf(options.seed)) - rgb = parseColor(resolveColor(options.color, dark)) - hit.clear() - flares.length = 0 + function tick(now: number) { + raf = 0 + if (!running) return + if (Math.min(window.devicePixelRatio || 1, 2) !== size.dpr) resize() + + const dt = Math.min(0.05, Math.max(0, (now - lastFrame) / 1000)) + lastFrame = now + const animate = shouldAnimate() + const before = beat + if (animate) { + clock += dt + beat = (beat + dt * speedOf(options.speed)) % LOOP_BEATS + rebuildAge += dt + spawnFlares(before, beat) + } else { + rebuildAge = REBUILD_FADE + flares.length = 0 + } + for (let i = flares.length - 1; i >= 0; i--) { + flares[i]!.age += dt + if (flares[i]!.age >= FLARE_LIFE) flares.splice(i, 1) + } + + draw() + if (animate) raf = requestAnimationFrame(tick) } - type Pt = { x: number; y: number; s: number; z: number } + /** Every note whose time was crossed since the last frame flares once — no frame can skip one. */ + function spawnFlares(from: number, to: number) { + const span = wrapDelta(to, from) + if (span <= 0) return + for (const n of score.notes) { + const d = wrapDelta(n.t, from) + if (d > 0 && d <= span) { + if (flares.length >= MAX_FLARES) flares.shift() + flares.push({ staff: n.staff, pitch: n.pitch, age: 0 }) + } + } + } - const cam = { x: 0, y: 0, z: 0 } - let frameFit = 0.78 + function draw() { + const { w, h, dpr } = size + ctx!.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx!.globalCompositeOperation = "source-over" + ctx!.globalAlpha = 1 + ctx!.clearRect(0, 0, w, h) + if (w < 2 || h < 2) return + + view = makeView( + w, + h, + tiltOf(options.rotateX), + tiltOf(options.rotateY), + spinOf(options.rotateZ) + ) + const swayOn = options.sway !== false && !reduce + const cam = { + x: swayOn ? Math.cos(clock * 0.23) * 10 : 0, + y: swayOn ? Math.sin(clock * 0.31) * 8 : 0, + } + const sp = clamp(Math.min(w, h) * 0.021, 5, 11) + const layout = { + sp, + gap: clamp(w * 0.16, sp * 6.5, sp * 12), + ppb: clamp(h * 0.15, sp * 5.2, sp * 11), + cx: w / 2 + cam.x, + playY: h * PLAYHEAD + cam.y, + } + const bloom = glowOf(options.glow) / 50 - const project = (x: number, y: number): Pt => { - const cosZ = Math.cos(cam.z) - const sinZ = Math.sin(cam.z) - const rx = x * cosZ - y * sinZ + cam.y - const ry = x * sinZ + y * cosZ + cam.x - const s = frameFit - return { - x: size.w * 0.5 + rx * s, - y: size.h * 0.5 + ry * s, - s, - z: 0, + // Dust drifts in screen space, beneath everything. + for (const d of dust) { + const px = (d.x + clock * 0.01 * d.z) % 1 + const py = (d.y + beat * 0.004 * (0.3 + d.z)) % 1 + ctx!.fillStyle = rgba(rgb, dark ? d.a : d.a * 0.35) + ctx!.fillRect(px * w, py * h, d.s, d.s) } + + drawStaves(layout) + + const fade = previous ? clamp(rebuildAge / REBUILD_FADE, 0, 1) : 1 + if (previous && fade >= 1) previous = null + if (previous) drawScore(previous, layout, 1 - fade, 0) + drawScore(score, layout, fade, bloom) + + drawPlayhead(layout, bloom) + drawFlares(layout, bloom) + drawEdgeFade(w, h) } - const staffCenter = (staff: 0 | 1, gap: number) => (staff === 0 ? -gap : gap) + type Layout = { + sp: number + gap: number + ppb: number + cx: number + playY: number + } - const pitchX = (staff: 0 | 1, pitch: number, gap: number, sp: number) => - staffCenter(staff, gap) + (pitch - 4) * (sp * 0.5) + const xOf = (l: Layout, staff: Staff, pitch: number) => + l.cx + (staff === 0 ? -l.gap : l.gap) + (pitch - 4) * (l.sp / 2) + const yOf = (l: Layout, d: number) => l.playY - d * l.ppb - const timeY = (delta: number, ppb: number, playY: number) => - playY - delta * ppb + /** + * Every beat offset at which something at time `t`, lasting `len` beats, + * is in view. Usually one; a steep tilt can see several loops ahead. + */ + function spots(l: Layout, t: number, len = 0) { + const minD = (l.playY - view.y1 - l.ppb) / l.ppb + const maxD = (l.playY - view.y0 + l.ppb) / l.ppb + const first = wrapDelta(t, beat) + const out: number[] = [] + const from = Math.ceil((minD - len - first) / LOOP_BEATS) + const to = Math.floor((maxD - first) / LOOP_BEATS) + for (let k = from; k <= to; k++) out.push(first + k * LOOP_BEATS) + return out + } - const nearPlay = (delta: number) => { - if (delta > 0.55) return 0 - if (delta > 0) return 1 - delta / 0.55 - if (delta > -0.28) return 1 - if (delta > -1.35) return 1 - (-delta - 0.28) / 1.07 - return 0 + /** + * Plane point → screen `[x, y, scale]`, where scale is how much the + * perspective enlarges it there. Scale is `0` behind the camera. + */ + function project(x: number, y: number): [number, number, number] { + const { m, ox, oy, cam } = view + const px = x - ox + const py = y - oy + const z = m[6]! * px + m[7]! * py + if (cam - z < cam * 0.02) return [0, 0, 0] + const s = cam / (cam - z) + return [ + ox + (m[0]! * px + m[1]! * py) * s, + oy + (m[3]! * px + m[4]! * py) * s, + s, + ] } - const drawOval = (pt: Pt, r: number, fill: string) => { - ctx.save() - ctx.translate(pt.x, pt.y) - ctx.rotate(-0.38) - ctx.scale(1, 0.7) - ctx.beginPath() - ctx.arc(0, 0, r * pt.s, 0, Math.PI * 2) - ctx.fillStyle = fill - ctx.fill() - ctx.restore() + /** + * Moves the canvas into the plane at `(x, y)`, so a notehead, glyph or + * glow drawn around the origin tilts and foreshortens with the score. + * Returns the distance fog there; `leave()` restores screen space. + */ + function enter(x: number, y: number) { + const { m, ox, oy, cam } = view + const { dpr } = size + const px = x - ox + const py = y - oy + const qx = m[0]! * px + m[1]! * py + const qy = m[3]! * px + m[4]! * py + const z = m[6]! * px + m[7]! * py + if (cam - z < cam * 0.02) return 0 + const s = cam / (cam - z) + // Jacobian of the projection: how a unit step along the plane lands on screen. + const k = (s * s) / cam + ctx!.setTransform( + dpr * (s * m[0]! + qx * k * m[6]!), + dpr * (s * m[3]! + qy * k * m[6]!), + dpr * (s * m[1]! + qx * k * m[7]!), + dpr * (s * m[4]! + qy * k * m[7]!), + dpr * (ox + qx * s), + dpr * (oy + qy * s) + ) + return fogOf(s) } - const glowBlob = (pt: Pt, radius: number, alpha: number) => { - const r = Math.max(1.2, radius * pt.s) - const g = ctx.createRadialGradient(pt.x, pt.y, 0, pt.x, pt.y, r) - g.addColorStop(0, rgba(rgb, alpha)) - g.addColorStop(0.28, rgba(rgb, alpha * 0.42)) - g.addColorStop(0.62, rgba(rgb, alpha * 0.1)) - g.addColorStop(1, rgba(rgb, 0)) - ctx.fillStyle = g - ctx.beginPath() - ctx.arc(pt.x, pt.y, r, 0, Math.PI * 2) - ctx.fill() + function leave() { + const { dpr } = size + ctx!.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx!.globalAlpha = 1 } - const frame = (now: number) => { - if (!running) return - const raw = Math.min(0.05, (now - lastFrame) / 1000) - lastFrame = now - dtSmooth += (raw - dtSmooth) * 0.2 - const dt = dtSmooth - clock += dt - if (!reduce) elapsed += dt * speedOf(options.speed) - const beat = elapsed % LOOP_BEATS - - const w = size.w - const h = size.h - const swayOn = options.sway !== false && !reduce - cam.x = swayOn ? Math.sin(clock * 0.31) * 8 : 0 - cam.y = swayOn ? Math.cos(clock * 0.23) * 10 : 0 - cam.z = 0 - frameFit = 0.78 - - const pad = Math.max(28, Math.min(w, h) * 0.1) - const innerW = Math.max(1, w - pad * 2) - const innerH = Math.max(1, h - pad * 2) - let sp = clamp(Math.min(innerW, innerH) * 0.028, 10, 28) - const maxHalf = innerW / 2 / frameFit - let gap = maxHalf * 0.4 - if (gap < sp * 7) { - sp = clamp(maxHalf * 0.4 / 7, 10, 28) - gap = maxHalf * 0.4 - } - gap = Math.max(sp * 7, gap) - const topY = -innerH / 2 / frameFit - const playY = (innerH / 2 - sp * 2) / frameFit - const ppb = Math.max(28, (playY - topY) / 7.6) - const bloom = glowOf(options.glow) / 50 - const color = parseColor(resolveColor(options.color, dark)) - rgb = color - - ctx.imageSmoothingEnabled = true - ctx.globalCompositeOperation = "source-over" - if (dark) { - ctx.fillStyle = DARK_BG - ctx.fillRect(0, 0, w, h) - } else { - ctx.clearRect(0, 0, w, h) + /** Plane stroke width; `line` and `polyline` scale it with depth. */ + let lw = 1 + + /** + * A straight stroke on the plane. Long ones pass `segments` so the fog + * can thin them as they recede. + */ + function line(x0: number, y0: number, x1: number, y1: number, segments = 1) { + const n = view.depthless ? 1 : segments + for (let i = 0; i < n; i++) { + const a = i / n + const b = (i + 1) / n + polyline([ + [x0 + (x1 - x0) * a, y0 + (y1 - y0) * a], + [x0 + (x1 - x0) * b, y0 + (y1 - y0) * b], + ]) } + } - ctx.save() - ctx.globalAlpha = 1 - for (const d of dust) { - const px = ((d.x + 1) * 0.5 + clock * 0.01 * d.z) % 1 - const py = ((d.y + 1) * 0.5 + elapsed * 0.004 * (0.3 + d.z)) % 1 - ctx.fillStyle = rgba(color, dark ? d.a : d.a * 0.28) - ctx.fillRect(px * w, py * h, d.s, d.s) + function polyline(points: [number, number][]) { + let scale = 0 + ctx!.beginPath() + for (const [i, [x, y]] of points.entries()) { + const p = project(x, y) + if (p[2] <= 0) return + scale += p[2] + if (i === 0) ctx!.moveTo(p[0], p[1]) + else ctx!.lineTo(p[0], p[1]) } - ctx.restore() + scale /= points.length + const fog = fogOf(scale) + if (fog <= 0) return + ctx!.globalAlpha = fog + ctx!.lineWidth = lw * scale + ctx!.stroke() + ctx!.globalAlpha = 1 + } - const visMin = (playY - innerH / 2 / frameFit) / ppb - const visMax = (playY - topY) / ppb + /** A quadratic curve on the plane; its control point is projected too. */ + function curve( + x0: number, + y0: number, + cx: number, + cy: number, + x1: number, + y1: number + ) { + const a = project(x0, y0) + const c = project(cx, cy) + const b = project(x1, y1) + if (a[2] <= 0 || b[2] <= 0 || c[2] <= 0) return + const scale = (a[2] + b[2]) / 2 + const fog = fogOf(scale) + if (fog <= 0) return + ctx!.globalAlpha = fog + ctx!.lineWidth = lw * scale + ctx!.beginPath() + ctx!.moveTo(a[0], a[1]) + ctx!.quadraticCurveTo(c[0], c[1], b[0], b[1]) + ctx!.stroke() + ctx!.globalAlpha = 1 + } - const inView = (t: number) => { - const d = wrapDelta(t, beat, LOOP_BEATS) - return d < visMax && d > visMin + /** The glow sprite, stretched to `rx` × `ry` around `(x, y)` on the plane. */ + function glow(x: number, y: number, rx: number, ry: number, alpha: number) { + const fog = enter(x, y) + if (fog > 0) { + ctx!.globalAlpha = alpha * fog + ctx!.drawImage(glowSprite, -rx, -ry, rx * 2, ry * 2) } + leave() + } - const staffY0 = topY - h * 0.4 / frameFit - const staffY1 = playY + h * 0.25 / frameFit - ctx.lineCap = "butt" - ctx.lineJoin = "miter" - ctx.strokeStyle = rgba(color, 0.22) - ctx.lineWidth = 1 + function drawStaves(l: Layout) { + ctx!.lineCap = "butt" + lw = 1 + ctx!.strokeStyle = rgba(rgb, dark ? 0.2 : 0.24) for (const staff of [0, 1] as const) { - for (let line = 0; line < 5; line++) { - const x = pitchX(staff, line * 2, gap, sp) - const a = project(x, staffY0) - const b = project(x, staffY1) - ctx.beginPath() - ctx.moveTo(a.x, a.y) - ctx.lineTo(b.x, b.y) - ctx.stroke() + for (let p = 0; p <= 8; p += 2) { + const x = Math.round(xOf(l, staff, p)) + 0.5 + line(x, view.y0, x, view.y1, 16) } } - - ctx.lineCap = "round" - ctx.lineJoin = "round" - + // Bar lines every four beats, through both staves like a grand staff. for (let bar = 0; bar < LOOP_BEATS; bar += 4) { - if (!inView(bar)) continue - const d = wrapDelta(bar, beat, LOOP_BEATS) - const y = timeY(d, ppb, playY) - const alpha = 0.14 + nearPlay(d) * 0.2 - for (const staff of [0, 1] as const) { - const a = project(pitchX(staff, 0, gap, sp), y) - const b = project(pitchX(staff, 8, gap, sp), y) - ctx.strokeStyle = rgba(color, alpha) - ctx.lineWidth = 1 - ctx.beginPath() - ctx.moveTo(a.x, a.y) - ctx.lineTo(b.x, b.y) - ctx.stroke() + for (const d of spots(l, bar)) { + const y = yOf(l, d) + ctx!.strokeStyle = rgba(rgb, (dark ? 0.16 : 0.2) + litAt(d) * 0.3) + line(xOf(l, 0, 0), y, xOf(l, 1, 8), y) } } + } - ctx.font = `italic ${Math.round(clamp(sp * 1.15, 9, 14))}px Georgia, "Times New Roman", serif` - ctx.textBaseline = "middle" - for (const mark of score.marks) { - if (!inView(mark.t)) continue - const d = wrapDelta(mark.t, beat, LOOP_BEATS) - const pt = project( - pitchX(mark.staff, mark.pitch, gap, sp), - timeY(d, ppb, playY) - ) - ctx.fillStyle = rgba(color, 0.28 + nearPlay(d) * 0.45) - ctx.fillText(mark.text, pt.x, pt.y) - } - - for (const pin of score.hairpins) { - const t1 = pin.t + pin.dur - if (!inView(pin.t) && !inView(t1)) continue - const d0 = wrapDelta(pin.t, beat, LOOP_BEATS) - const d1 = wrapDelta(t1, beat, LOOP_BEATS) - const staff = pin.staff - const outer = staff === 0 ? -4.6 : 12.6 - const inner = staff === 0 ? -3.4 : 11.4 - const y0 = timeY(d0, ppb, playY) - const y1 = timeY(d1, ppb, playY) - const xOuter0 = pitchX(staff, pin.open ? outer : inner, gap, sp) - const xOuter1 = pitchX(staff, pin.open ? inner : outer, gap, sp) - const xMid = pitchX(staff, (outer + inner) / 2, gap, sp) - const a = project(xOuter0, y0) - const b = project(xOuter1, y1) - const c = project(xMid, y0) - const e = project(xMid, y1) - ctx.strokeStyle = rgba(color, 0.22) - ctx.lineWidth = 1 - ctx.beginPath() - ctx.moveTo(a.x, a.y) - ctx.lineTo(b.x, b.y) - ctx.moveTo(c.x, c.y) - ctx.lineTo(e.x, e.y) - ctx.stroke() - } - - for (const slur of score.slurs) { - if (!inView(slur.t0) && !inView(slur.t1)) continue - const d0 = wrapDelta(slur.t0, beat, LOOP_BEATS) - const d1 = wrapDelta(slur.t1, beat, LOOP_BEATS) - const side = slur.staff === 0 ? -1.8 : 1.8 - const a = project( - pitchX(slur.staff, slur.p0 + side, gap, sp), - timeY(d0, ppb, playY) - ) - const b = project( - pitchX(slur.staff, slur.p1 + side, gap, sp), - timeY(d1, ppb, playY) - ) - const midT = (slur.t0 + slur.t1) / 2 - const midP = (slur.p0 + slur.p1) / 2 + side * 1.6 - const c = project( - pitchX(slur.staff, midP, gap, sp), - timeY(wrapDelta(midT, beat, LOOP_BEATS), ppb, playY) - ) - ctx.strokeStyle = rgba(color, 0.2) - ctx.lineWidth = 1.1 - ctx.beginPath() - ctx.moveTo(a.x, a.y) - ctx.quadraticCurveTo(c.x, c.y, b.x, b.y) - ctx.stroke() - } - - for (const link of score.links) { - if (!inView(link.t)) continue - const d = wrapDelta(link.t, beat, LOOP_BEATS) - const y = timeY(d, ppb, playY) - const a = project(pitchX(0, link.p0, gap, sp), y) - const b = project(pitchX(1, link.p1, gap, sp), y) - ctx.strokeStyle = rgba(color, 0.12 + nearPlay(d) * 0.18) - ctx.lineWidth = 1 - ctx.beginPath() - ctx.moveTo(a.x, a.y) - ctx.lineTo(b.x, b.y) - ctx.stroke() - } - - const groups = new Map() - for (const note of score.notes) { - if (note.beam <= 0) continue - if (!inView(note.t)) continue - const list = groups.get(note.beam) - if (list) list.push(note) - else groups.set(note.beam, [note]) + function drawScore(s: Score, l: Layout, opacity: number, bloom: number) { + if (opacity <= 0) return + const base = (d: number) => + d >= 0 ? (dark ? 0.46 : 0.58) : dark ? 0.22 : 0.3 + const alphaAt = (d: number) => { + const b = base(d) + return (b + (1 - b) * litAt(d)) * opacity } - - const stemTip = (note: Note, d: number) => { - const dir = note.staff === 0 ? -1 : 1 - const y = timeY(d, ppb, playY) - const x0 = pitchX(note.staff, note.pitch, gap, sp) - const x1 = pitchX(note.staff, note.pitch + dir * 6.4, gap, sp) - return { - head: project(x0, y), - tip: project(x1, y), - d, + const sp = l.sp + const rx = sp * 0.5 + const ry = sp * 0.64 + + ctx!.lineCap = "butt" + + // Dynamics and hairpins share the space between the staves. + ctx!.font = `italic ${Math.round(clamp(sp * 1.3, 10, 17))}px Georgia, "Times New Roman", serif` + ctx!.textAlign = "center" + ctx!.textBaseline = "middle" + for (const mark of s.marks) { + for (const d of spots(l, mark.t)) { + const fog = enter(l.cx, yOf(l, d) + sp * 0.9) + if (fog > 0) { + ctx!.globalAlpha = fog + ctx!.fillStyle = rgba(rgb, alphaAt(d) * 0.85) + ctx!.fillText(mark.text, 0, 0) + } + leave() } } - - for (const note of score.notes) { - if (!inView(note.t)) continue - const d = wrapDelta(note.t, beat, LOOP_BEATS) - const { head, tip } = stemTip(note, d) - const intens = nearPlay(d) - ctx.strokeStyle = rgba(color, 0.22 + intens * 0.45) - ctx.lineWidth = 1 * head.s - ctx.beginPath() - ctx.moveTo(head.x, head.y) - ctx.lineTo(tip.x, tip.y) - ctx.stroke() + lw = 1 + for (const pin of s.hairpins) { + for (const d0 of spots(l, pin.t, pin.dur)) { + const d1 = d0 + pin.dur + const narrow = pin.opening ? d0 : d1 + const wide = pin.opening ? d1 : d0 + const half = sp * 0.9 + ctx!.strokeStyle = rgba( + rgb, + alphaAt(Math.min(Math.abs(d0), Math.abs(d1))) * 0.6 + ) + polyline([ + [l.cx - half, yOf(l, wide)], + [l.cx, yOf(l, narrow)], + [l.cx + half, yOf(l, wide)], + ]) + } } - for (const [, groupNotes] of groups) { - if (groupNotes.length < 2) continue - groupNotes.sort((a, b) => a.t - b.t) - const first = groupNotes[0]! - const last = groupNotes[groupNotes.length - 1]! - const a = stemTip(first, wrapDelta(first.t, beat, LOOP_BEATS)) - const b = stemTip(last, wrapDelta(last.t, beat, LOOP_BEATS)) - const intens = Math.max(nearPlay(a.d), nearPlay(b.d)) - ctx.strokeStyle = rgba(color, 0.28 + intens * 0.5) - ctx.lineWidth = Math.max(1.6, sp * 0.18) * ((a.tip.s + b.tip.s) * 0.5) - ctx.beginPath() - ctx.moveTo(a.tip.x, a.tip.y) - ctx.lineTo(b.tip.x, b.tip.y) - ctx.stroke() - if (first.dur <= 0.13) { - const oy = (first.staff === 0 ? -1 : 1) * 3.2 - ctx.beginPath() - ctx.moveTo(a.tip.x, a.tip.y + oy) - ctx.lineTo(b.tip.x, b.tip.y + oy) - ctx.stroke() + // Slurs curve on the inner side, opposite the stems. + lw = Math.max(1, sp * 0.1) + for (const slur of s.slurs) { + const len = slur.last.t - slur.first.t + for (const d0 of spots(l, slur.first.t, len)) { + const d1 = d0 + len + const inward = slur.staff === 0 ? 1 : -1 + const y0 = yOf(l, d0) + const y1 = yOf(l, d1) + const x0 = xOf(l, slur.staff, slur.first.pitch + inward * 2) + const x1 = xOf(l, slur.staff, slur.last.pitch + inward * 2) + const edge = + inward > 0 + ? Math.max(slur.first.pitch, slur.last.pitch) + 3.5 + : Math.min(slur.first.pitch, slur.last.pitch) - 3.5 + ctx!.strokeStyle = rgba( + rgb, + alphaAt(d1 < 0 ? d1 : Math.max(0, d0)) * 0.5 + ) + curve(x0, y0, xOf(l, slur.staff, edge), (y0 + y1) / 2, x1, y1) } } - const playYScreen = project(0, playY).y - ctx.save() - ctx.lineCap = "butt" - ctx.strokeStyle = rgba(color, (dark ? 0.22 : 0.16) * Math.min(1, bloom)) - ctx.lineWidth = 2 - ctx.beginPath() - ctx.moveTo(0, playYScreen) - ctx.lineTo(w, playYScreen) - ctx.stroke() - ctx.strokeStyle = rgba(color, (dark ? 0.7 : 0.45) * Math.min(1, bloom)) - ctx.lineWidth = 0.75 - ctx.beginPath() - ctx.moveTo(0, playYScreen) - ctx.lineTo(w, playYScreen) - ctx.stroke() - ctx.restore() - - const bloomOp = dark ? "lighter" : "source-over" - ctx.globalCompositeOperation = bloomOp - - for (const note of score.notes) { - const d = wrapDelta(note.t, beat, LOOP_BEATS) - if (d > visMax || d < visMin) continue - const y = timeY(d, ppb, playY) - const x = pitchX(note.staff, note.pitch, gap, sp) - const pt = project(x, y) - const intens = nearPlay(d) - const upcoming = d > 0 ? clamp(1 - d / 10, 0.18, 0.55) : 0.2 - const headR = Math.max(2.4, sp * 0.38) - const alpha = upcoming + intens * 0.8 - - if (note.acc !== 0 && d > -0.4) { - const accPt = project(x, y - sp * 0.9) - ctx.fillStyle = rgba(color, 0.25 + intens * 0.5) - ctx.font = `${Math.round(sp * 1.1)}px Georgia, serif` - ctx.fillText(note.acc > 0 ? "#" : "b", accPt.x - 4, accPt.y) + // Stems and beams. A stem runs along the pitch axis from the side of its + // head (the time axis is "across" here), out to the beam. + const stemW = Math.max(1, sp * 0.11) + const side = -ry * 0.8 + for (const stem of s.stems) { + const len = stem.t1 - stem.t0 + for (const d0 of spots(l, stem.t0, len)) { + const d1 = d0 + len + const out = stem.staff === 0 ? -1 : 1 + const tipX = xOf(l, stem.staff, stem.tip) + lw = stemW + if (stem.chord) { + // One stem through the chord, from the innermost head out to the tip. + const inner = + out < 0 + ? Math.max(...stem.notes.map((n) => n.pitch)) + : Math.min(...stem.notes.map((n) => n.pitch)) + const y = yOf(l, d0) + side + ctx!.strokeStyle = rgba(rgb, alphaAt(d0)) + line(xOf(l, stem.staff, inner), y, tipX, y) + continue + } + for (const n of stem.notes) { + const d = d0 + (n.t - stem.t0) + const y = yOf(l, d) + side + ctx!.strokeStyle = rgba(rgb, alphaAt(d)) + line(xOf(l, stem.staff, n.pitch), y, tipX, y) + } + if (stem.beams === 0) continue + const nearest = d1 < 0 ? d1 : d0 > 0 ? d0 : 0 + ctx!.strokeStyle = rgba(rgb, alphaAt(nearest)) + lw = sp * 0.36 + const yA = yOf(l, d0) + side + stemW / 2 + const yB = yOf(l, d1) + side - stemW / 2 + const offset = sp * 0.18 * out + line(tipX - offset, yA, tipX - offset, yB) + if (stem.beams === 2) { + const x2 = tipX - offset - out * sp * 0.75 + line(x2, yA, x2, yB) + } } + } - if (note.pitch < 0 || note.pitch > 8) { - const ledgerPitch = note.pitch < 0 ? -2 : 10 - const lx0 = pitchX(note.staff, ledgerPitch - 0.9, gap, sp) - const lx1 = pitchX(note.staff, ledgerPitch + 0.9, gap, sp) - const la = project(lx0, y) - const lb = project(lx1, y) - ctx.strokeStyle = rgba(color, 0.2 + intens * 0.3) - ctx.lineWidth = 1 - ctx.beginPath() - ctx.moveTo(la.x, la.y) - ctx.lineTo(lb.x, lb.y) - ctx.stroke() + // Ledger lines, parallel to the staff lines. + lw = 1 + for (const n of s.notes) { + if (n.pitch > -2 && n.pitch < 10) continue + for (const d of spots(l, n.t)) { + const y = yOf(l, d) + ctx!.strokeStyle = rgba(rgb, alphaAt(d) * 0.7) + const from = n.pitch < 0 ? -2 : 10 + const step = n.pitch < 0 ? -2 : 2 + for (let p = from; step < 0 ? p >= n.pitch : p <= n.pitch; p += step) { + const x = xOf(l, n.staff, p) + line(x, y - ry * 1.7, x, y + ry * 1.7) + } } + } - if (intens > 0.04 && bloom > 0) { - const halo = (5 + intens * 9) * bloom - glowBlob(pt, halo, (dark ? 0.55 : 0.28) * intens * Math.min(1, bloom)) + // Glow under the heads that are playing. + if (bloom > 0) { + ctx!.globalCompositeOperation = dark ? "lighter" : "source-over" + for (const n of s.notes) { + for (const d of spots(l, n.t)) { + const lit = litAt(d) + if (lit < 0.03) continue + const r = sp * (1.1 + lit * 1.5) * bloom + glow( + xOf(l, n.staff, n.pitch), + yOf(l, d), + r, + r, + lit * (dark ? 0.7 : 0.32) * Math.min(1, bloom) * opacity + ) + } } + ctx!.globalCompositeOperation = "source-over" + } - ctx.globalCompositeOperation = "source-over" - ctx.save() - if (intens > 0.2 && bloom > 0) { - ctx.shadowColor = rgba(color, dark ? 0.9 : 0.45) - ctx.shadowBlur = (3.5 + intens * 5) * bloom - } - drawOval(pt, headR, rgba(color, clamp(alpha, 0.2, 1))) - ctx.restore() - ctx.globalCompositeOperation = bloomOp - - if (d <= 0 && d > -0.08 && intens > 0.7) { - const id = (note.t * 1000 + note.staff * 17 + note.pitch) | 0 - if (!hit.has(id)) { - hit.add(id) - flares.push({ - x, - y: playY, - age: 0, - life: 0.38, - size: 10, - }) - if (hit.size > 400) hit.clear() + // Noteheads, with accidentals just before them in reading order (below). + ctx!.font = `${Math.round(sp * 1.5)}px Georgia, "Times New Roman", serif` + for (const n of s.notes) { + for (const d of spots(l, n.t)) { + const fog = enter(xOf(l, n.staff, n.pitch), yOf(l, d)) + if (fog <= 0) { + leave() + continue } + ctx!.globalAlpha = fog + const color = rgba(rgb, alphaAt(d)) + ctx!.beginPath() + ctx!.ellipse(0, 0, rx, ry, 0.35, 0, Math.PI * 2) + if (n.hollow) { + ctx!.strokeStyle = color + ctx!.lineWidth = Math.max(1.2, sp * 0.16) + ctx!.stroke() + } else { + ctx!.fillStyle = color + ctx!.fill() + } + if (n.acc !== 0) { + ctx!.fillStyle = color + ctx!.fillText(n.acc > 0 ? "♯" : "♭", 0, ry + sp * 0.95) + } + leave() } } + } - for (let i = flares.length - 1; i >= 0; i--) { - const flare = flares[i]! - flare.age += dt - const t = flare.age / flare.life - if (t >= 1) { - flares.splice(i, 1) - continue + function drawPlayhead(l: Layout, bloom: number) { + const y = Math.round(l.playY) + 0.5 + if (bloom > 0) { + ctx!.globalCompositeOperation = dark ? "lighter" : "source-over" + const alpha = (dark ? 0.35 : 0.12) * Math.min(1, bloom) + const band = l.sp * 1.4 * Math.max(0.6, bloom) + // In slices, so the band follows the perspective across the plane. + const slices = view.depthless ? 1 : 24 + const span = (view.x1 - view.x0) / slices + const src = glowSprite.width / slices + for (let i = 0; i < slices; i++) { + const fog = enter(view.x0 + (i + 0.5) * span, y) + if (fog > 0) { + ctx!.globalAlpha = alpha * fog + ctx!.drawImage( + glowSprite, + i * src, + 0, + src, + glowSprite.height, + -span / 2, + -band, + span, + band * 2 + ) + } + leave() } - const pt = project(flare.x, flare.y) - const fade = Math.sin((1 - t) * Math.PI) - glowBlob(pt, (7 + t * 6) * bloom, (dark ? 0.55 : 0.28) * fade) - ctx.save() - ctx.translate(pt.x, pt.y) - ctx.rotate(cam.z) - const flareW = 22 * bloom * (1 + t * 0.4) * pt.s - const flareH = 1.4 * pt.s - const fg = ctx.createLinearGradient(-flareW, 0, flareW, 0) - fg.addColorStop(0, rgba(color, 0)) - fg.addColorStop(0.5, rgba(color, (dark ? 0.7 : 0.4) * fade)) - fg.addColorStop(1, rgba(color, 0)) - ctx.fillStyle = fg - ctx.fillRect(-flareW, -flareH, flareW * 2, flareH * 2) - ctx.restore() + ctx!.globalCompositeOperation = "source-over" } + ctx!.strokeStyle = rgba(rgb, dark ? 0.75 : 0.55) + lw = 1 + line(view.x0, y, view.x1, y, 12) + } - ctx.globalCompositeOperation = "source-over" + function drawFlares(l: Layout, bloom: number) { + if (flares.length === 0 || bloom <= 0) return + ctx!.globalCompositeOperation = dark ? "lighter" : "source-over" + for (const f of flares) { + const k = f.age / FLARE_LIFE + const fade = (1 - k) * (1 - k) + const x = xOf(l, f.staff, f.pitch) + const r = l.sp * (1.6 + k * 1.4) * bloom + glow(x, l.playY, r, r, fade * (dark ? 0.8 : 0.4)) + // A streak along the playhead. + const streak = l.sp * (2.5 + k * 3) * bloom + glow(x, l.playY, streak, 1.5, fade * (dark ? 0.9 : 0.5)) + } + ctx!.globalCompositeOperation = "source-over" + } - raf = requestAnimationFrame(frame) + /** Top and bottom fade into the background — in the canvas, so it costs nothing and blurs nothing. */ + function drawEdgeFade(w: number, h: number) { + const f = h * EDGE_FADE + // Erase toward the edges rather than paint over them, so the fade + // lands on whatever background the parent has. + const solid = "rgba(0,0,0,1)" + const clear = "rgba(0,0,0,0)" + ctx!.globalCompositeOperation = "destination-out" + const top = ctx!.createLinearGradient(0, 0, 0, f) + top.addColorStop(0, solid) + top.addColorStop(1, clear) + ctx!.fillStyle = top + ctx!.fillRect(0, 0, w, f) + const bottom = ctx!.createLinearGradient(0, h - f, 0, h) + bottom.addColorStop(0, clear) + bottom.addColorStop(1, solid) + ctx!.fillStyle = bottom + ctx!.fillRect(0, h - f, w, f) + ctx!.globalCompositeOperation = "source-over" } - raf = requestAnimationFrame(frame) + resize() + wake() return { setOptions(next) { const prevSeed = seedOf(options.seed) - const prevDens = densityOf(options.density) + const prevDensity = densityOf(options.density) + const prevColor = options.color options = { ...options, ...next, @@ -878,19 +1172,28 @@ export function createPhosphorScore( theme: themeOf(next.theme ?? options.theme), } syncTheme() - rgb = parseColor(resolveColor(options.color, dark)) + if (options.color !== prevColor) recolor() if ( seedOf(options.seed) !== prevSeed || - densityOf(options.density) !== prevDens + densityOf(options.density) !== prevDensity ) { - rebuild() + // Crossfade from the old score instead of cutting. + previous = reduce ? null : score + rebuildAge = reduce ? REBUILD_FADE : 0 + score = generateScore(seedOf(options.seed), densityOf(options.density)) + dust = makeDust(seedOf(options.seed)) + flares.length = 0 } + wake() }, destroy() { running = false cancelAnimationFrame(raf) + raf = 0 ro.disconnect() + io.disconnect() mo.disconnect() + document.removeEventListener("visibilitychange", onVisibility) mqReduce.removeEventListener("change", onReduce) mqDark.removeEventListener("change", syncTheme) }, diff --git a/registry/phosphor-score/phosphor-score.svelte b/registry/phosphor-score/phosphor-score.svelte index 3dba72e..472bc17 100644 --- a/registry/phosphor-score/phosphor-score.svelte +++ b/registry/phosphor-score/phosphor-score.svelte @@ -5,20 +5,14 @@ import { onMount } from "svelte" import { createPhosphorScore, - DARK_BG, DEFAULT_DENSITY, DEFAULT_GLOW, DEFAULT_SEED, DEFAULT_SPEED, - resolveDark, type PhosphorScoreInstance, type PhosphorScoreOptions, } from "./phosphor-score-vanilla" - function cn(...parts: Array) { - return parts.filter(Boolean).join(" ") - } - interface Props extends Omit { class?: string } @@ -31,15 +25,16 @@ density = DEFAULT_DENSITY, sway = true, seed = DEFAULT_SEED, + rotateX = 0, + rotateY = 0, + rotateZ = 0, theme = "auto", }: Props = $props() let canvas: HTMLCanvasElement | undefined = $state() - let isDark = $state(false) let instance: PhosphorScoreInstance | null = null onMount(() => { - isDark = resolveDark(theme) if (!canvas) return instance = createPhosphorScore(canvas, { color, @@ -48,10 +43,10 @@ density, sway, seed, + rotateX, + rotateY, + rotateZ, theme, - onThemeChange: (dark) => { - isDark = dark - }, }) return () => { instance?.destroy() @@ -67,6 +62,9 @@ density, sway, seed, + rotateX, + rotateY, + rotateZ, theme, }) }) @@ -76,44 +74,12 @@ data-slot="phosphor-score" role="img" aria-label="Falling phosphor sheet music" - class={cn("absolute inset-0 overflow-hidden bg-background", className)} - style={isDark ? `background-color: ${DARK_BG}` : ""} + class={[ + "absolute inset-0 overflow-hidden", + className, + ] + .filter(Boolean) + .join(" ")} > - - - - - -
diff --git a/registry/phosphor-score/phosphor-score.tsx b/registry/phosphor-score/phosphor-score.tsx index b5b86e9..807796a 100644 --- a/registry/phosphor-score/phosphor-score.tsx +++ b/registry/phosphor-score/phosphor-score.tsx @@ -1,17 +1,15 @@ "use client" -import { useEffect, useRef, useState } from "react" +import { useEffect, useRef } from "react" import { cn } from "@/lib/utils" import { createPhosphorScore, - DARK_BG, DEFAULT_DENSITY, DEFAULT_GLOW, DEFAULT_SEED, DEFAULT_SPEED, - resolveDark, type PhosphorScoreInstance, type PhosphorScoreOptions, } from "./phosphor-score-vanilla" @@ -39,63 +37,10 @@ export type PhosphorScoreProps = Omit & { className?: string } -function PhosphorEdgeFade({ - edge, - dark, -}: { - edge: "top" | "bottom" - dark: boolean -}) { - const isTop = edge === "top" - const pos = isTop ? "top-0" : "bottom-0" - const mask = isTop - ? "mask-[linear-gradient(to_bottom,black,transparent)]" - : "mask-[linear-gradient(to_top,black,transparent)]" - return ( - <> -
-
-
- - ) -} - /** - * Vertical phosphor sheet music — notes fall toward a playhead, bloom, - * then exit in a flare. Follows light and dark. + * Vertical phosphor sheet music — a grand staff scrolls down to a playhead, + * each note blooms and flares as it plays, then fades with an afterglow. + * Follows light and dark. */ export function PhosphorScore({ className, @@ -105,27 +50,13 @@ export function PhosphorScore({ density = DEFAULT_DENSITY, sway = true, seed = DEFAULT_SEED, + rotateX = 0, + rotateY = 0, + rotateZ = 0, theme = "auto", }: PhosphorScoreProps) { const canvasRef = useRef(null) const instanceRef = useRef(null) - const [isDark, setIsDark] = useState(false) - - useEffect(() => { - const sync = () => setIsDark(resolveDark(theme)) - sync() - const mo = new MutationObserver(sync) - mo.observe(document.documentElement, { - attributes: true, - attributeFilter: ["class", "data-theme"], - }) - const mq = window.matchMedia("(prefers-color-scheme: dark)") - mq.addEventListener("change", sync) - return () => { - mo.disconnect() - mq.removeEventListener("change", sync) - } - }, [theme]) useEffect(() => { const canvas = canvasRef.current @@ -137,8 +68,10 @@ export function PhosphorScore({ density, sway, seed, + rotateX, + rotateY, + rotateZ, theme, - onThemeChange: setIsDark, }) return () => { instanceRef.current?.destroy() @@ -156,22 +89,32 @@ export function PhosphorScore({ density, sway, seed, + rotateX, + rotateY, + rotateZ, theme, - onThemeChange: setIsDark, }) - }, [color, glow, speed, density, sway, seed, theme]) + }, [ + color, + glow, + speed, + density, + sway, + seed, + rotateX, + rotateY, + rotateZ, + theme, + ]) return (
- -
) } diff --git a/registry/shader-anime-fire/registry.json b/registry/shader-anime-fire/registry.json new file mode 100644 index 0000000..ea91bce --- /dev/null +++ b/registry/shader-anime-fire/registry.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry.json", + "items": [ + { + "name": "shader-anime-fire", + "type": "registry:ui", + "title": "Shader Anime Fire", + "description": "Cel-shaded WebGL flames for landing heroes — tongues lick up from the bottom in flat bands of heat, and the fire reaches for the pointer.", + "files": [ + { + "path": "shader-anime-fire.tsx", + "type": "registry:ui", + "target": "components/ui/shader-anime-fire.tsx" + } + ] + }, + { + "name": "shader-anime-fire-svelte", + "type": "registry:ui", + "title": "Shader Anime Fire", + "description": "Cel-shaded WebGL flames for landing heroes — tongues lick up from the bottom in flat bands of heat, and the fire reaches for the pointer.", + "files": [ + { + "path": "shader-anime-fire.svelte", + "type": "registry:file", + "target": "src/lib/components/ui/shader-anime-fire.svelte" + } + ], + "dependencies": [] + } + ] +} diff --git a/registry/shader-anime-fire/shader-anime-fire-demo.tsx b/registry/shader-anime-fire/shader-anime-fire-demo.tsx new file mode 100644 index 0000000..f89b6b1 --- /dev/null +++ b/registry/shader-anime-fire/shader-anime-fire-demo.tsx @@ -0,0 +1,199 @@ +"use client" + +import { useLayoutEffect, useMemo } from "react" + +import { Button } from "@/components/ui/button" +import { + ComponentControls, + ControlColors, + ControlSlider, + ControlSwitch, +} from "@/components/component-controls" +import { ComponentPreview } from "@/components/component-preview" +import { useHydratedTheme } from "@/hooks/use-hydrated-theme" +import { usePreviewProps } from "@/hooks/use-preview-props" +import { + DARK_COLORS, + DEFAULT_HEIGHT, + DEFAULT_INTENSITY, + DEFAULT_SPEED, + LIGHT_COLORS, + ShaderAnimeFire, +} from "@/registry/shader-anime-fire/shader-anime-fire" + +function norm(hex: string) { + return hex.trim().toUpperCase() +} + +function colorsEqual(a: string[], b: string[]) { + return ( + a.length === b.length && a.every((color, i) => norm(color) === norm(b[i]!)) + ) +} + +function isStockPalette(colors: string[]) { + return colorsEqual(colors, LIGHT_COLORS) || colorsEqual(colors, DARK_COLORS) +} + +export function ShaderAnimeFireDemo() { + const theme = useHydratedTheme() + const palette = theme === "dark" ? DARK_COLORS : LIGHT_COLORS + + const defaults = useMemo( + () => ({ + speed: DEFAULT_SPEED, + intensity: DEFAULT_INTENSITY, + height: DEFAULT_HEIGHT, + interactive: true, + overlay: true, + dither: false, + pixelSize: 1, + colors: palette, + }), + [palette] + ) + + const { props, updateProp, resetProps, hasChanges, setProps } = + usePreviewProps(defaults) + + useLayoutEffect(() => { + setProps((prev) => { + if (!isStockPalette(prev.colors)) return prev + if (colorsEqual(prev.colors, palette)) return prev + return { ...prev, colors: palette } + }) + }, [palette, setProps]) + + const useAutoTheme = isStockPalette(props.colors) + + return ( + <> + +
+ + {props.overlay ? ( + <> +
+
+

+ Now burning +

+

+ Ship something that catches +

+

+ Flames lick up from the floor and leave the copy clear. Move + the pointer and the fire reaches for it. +

+
+ + +
+
+ + ) : null} +
+ + + + updateProp("colors", colors)} + /> + updateProp("speed", v)} + /> + updateProp("intensity", v)} + /> + updateProp("height", v)} + /> + updateProp("overlay", v)} + /> + updateProp("interactive", v)} + /> + updateProp("dither", v)} + /> + updateProp("pixelSize", v)} + /> + + + ) +} diff --git a/registry/shader-anime-fire/shader-anime-fire-vanilla.ts b/registry/shader-anime-fire/shader-anime-fire-vanilla.ts new file mode 100644 index 0000000..63fb0da --- /dev/null +++ b/registry/shader-anime-fire/shader-anime-fire-vanilla.ts @@ -0,0 +1,546 @@ +export type ShaderAnimeFireTheme = "light" | "dark" | "auto" + +export type ShaderAnimeFireOptions = { + /** + * Three colors, coolest to hottest: ember, flame, core. In dark mode the + * densest heat still burns past the core toward white. + */ + colors?: string[] + /** How fast the flames rise. Default `0.6`. */ + speed?: number + /** Opacity of the fire and its glow, 0–1. Default `1`. */ + intensity?: number + /** How far the flames climb, 0–1. Default `0.45`. */ + height?: number + /** A plume of heat reaches up toward the pointer. Default `true`. */ + interactive?: boolean + /** Ordered Bayer pixels instead of smooth flames. Default `false`. */ + dither?: boolean + /** Dither cell size in CSS px. Default `1`. */ + pixelSize?: number + /** + * Palette mode. Default `auto` follows shadcn / next-themes + * (`html.dark` class) so light and dark swap with the site theme. + */ + theme?: ShaderAnimeFireTheme + /** Fires whenever resolved dark mode changes (CSS fallback). */ + onThemeChange?: (dark: boolean) => void +} + +export type ShaderAnimeFireInstance = { + setOptions: (options: Partial) => void + destroy: () => void +} + +/** Brick, flame orange and marigold — saturated enough to hold on paper. */ +export const LIGHT_COLORS = ["#D8341A", "#F9731E", "#FFBA3A"] +/** Deep ember, flame and gold; the tonemap carries the core to white-hot. */ +export const DARK_COLORS = ["#B4200A", "#FF6512", "#FFC04A"] +const LIGHT_BASE = "#FCFBF9" +const DARK_BASE = "#08090C" + +export const DEFAULT_SPEED = 0.6 +export const DEFAULT_INTENSITY = 1 +export const DEFAULT_HEIGHT = 0.45 + +function fallback(colors: string[], base: string) { + const [ember, flame, core] = colors + return { + backgroundColor: base, + backgroundImage: [ + `radial-gradient(24% 18% at 50% 100%, ${core} 0%, transparent 70%)`, + `radial-gradient(36% 30% at 24% 100%, ${flame} 0%, transparent 72%)`, + `radial-gradient(34% 28% at 76% 100%, ${flame} 0%, transparent 72%)`, + `radial-gradient(90% 50% at 50% 110%, ${ember} 0%, transparent 72%)`, + ].join(", "), + } as const +} + +/** Shown before the first frame and wherever WebGL isn't available. */ +export const LIGHT_FALLBACK = fallback(LIGHT_COLORS, LIGHT_BASE) +export const DARK_FALLBACK = fallback(DARK_COLORS, DARK_BASE) + +const VERT = ` +attribute vec2 a_position; +void main() { + gl_Position = vec4(a_position, 0.0, 1.0); +} +` + +const FRAG = ` +precision highp float; + +uniform vec2 u_resolution; +uniform float u_time; +uniform float u_intensity; +uniform float u_height; +uniform float u_ignite; +uniform float u_dark; +uniform float u_dither; +uniform float u_pixel; +uniform float u_pointer; +uniform vec2 u_mouse; +// Ember, flame, core and the paper / ink underneath, in linear light. +uniform vec3 u_c0; +uniform vec3 u_c1; +uniform vec3 u_c2; +uniform vec3 u_base; + +// Dave Hoskins' hash — no sin(), so it stays stable on mobile GPUs. +float hash(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); +} + +float noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix( + mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), + u.y + ); +} + +float fbm(vec2 p) { + float v = 0.0; + float a = 0.5; + mat2 m = mat2(1.6, 1.2, -1.2, 1.6); + for (int i = 0; i < 3; i++) { + v += a * noise(p); + p = m * p; + a *= 0.5; + } + return v / 0.875; +} + +// Classic Bayer (WebGL1-safe, no bitwise ops) +float bayer2(vec2 c) { + return mod(c.x * 2.0 + c.y * 3.0, 4.0); +} + +float bayer4(vec2 p) { + vec2 c = mod(floor(p), 4.0); + return bayer2(mod(c, 2.0)) * 4.0 + bayer2(floor(c * 0.5)); +} + +float bayer8(vec2 p) { + vec2 c = mod(floor(p), 8.0); + return (bayer4(floor(c * 0.5)) * 4.0 + bayer2(mod(c, 2.0)) + 0.5) / 64.0; +} + +float climbOf() { + return mix(0.18, 0.78, u_height) * u_ignite; +} + +// Temperature: below 0 is air, around 1 is the white-hot bed of the fire. +float temperature(vec2 uv, float aspect, float t, float plume) { + float x = uv.x * aspect; + float climb = max(climbOf(), 0.001); + float y = uv.y / climb; + + // Slow swells along the bed, so the flame line is never flat. + y /= mix(0.6, 1.3, fbm(vec2(x * 1.1 + 3.1, t * 0.16))); + // Under the pointer the fire stretches up toward it. + y /= 1.0 + plume * max(u_mouse.y / climb - 0.4, 0.3); + + // Turbulence rising through the flame: stretched tall so it reads as + // tongues, and swept sideways harder the higher it climbs. + // Few octaves on purpose: smooth, graphic tongues rather than ragged, + // photographic ones. + vec2 q = vec2(x * 4.4, uv.y * 1.5 - t * 1.1); + vec2 w = vec2( + fbm(q * vec2(0.5, 0.8) + vec2(0.0, -t * 0.3)), + fbm(q * 0.6 + vec2(5.2, 1.3 - t * 0.25)) + ); + q += (w - 0.5) * vec2(2.4, 0.8) * (0.3 + y); + float body = fbm(q) - 0.5; + // Ridged: where the finer noise crosses its midpoint it peaks sharply, + // which curls the bands into licks instead of flat stripes. + float licks = 0.2 - abs(fbm(q * vec2(1.5, 2.0) + vec2(0.0, -t * 1.6)) - 0.5) * 2.0; + float turb = body * 0.8 + licks * 0.45; + + float T = 1.0 - y * 1.1 + turb * 2.6 * min(0.5 + y, 1.2); + // Nothing burns far above the tips, however the noise rolls. + return T - smoothstep(1.0, 1.7, y); +} + +// Flat cel bands, hottest innermost. 'hot' is the core pushed toward white. +vec3 bands(float T, vec3 hot) { + float w = 0.012; + vec3 c = mix(u_c0, u_c1, smoothstep(0.3 - w, 0.3 + w, T)); + c = mix(c, u_c2, smoothstep(0.6 - w, 0.6 + w, T)); + return mix(c, hot, smoothstep(1.02 - w, 1.02 + w, T)); +} + +vec3 linearToSrgb(vec3 c) { + c = clamp(c, 0.0, 1.0); + vec3 lo = c * 12.92; + vec3 hi = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055; + return mix(lo, hi, step(vec3(0.0031308), c)); +} + +void main() { + float aspect = u_resolution.x / max(u_resolution.y, 1.0); + float t = u_time; + + vec2 cell = floor(gl_FragCoord.xy / u_pixel); + vec2 uv = gl_FragCoord.xy / u_resolution; + if (u_dither > 0.5) uv = (cell + 0.5) * u_pixel / u_resolution; + + float dx = (uv.x - u_mouse.x) * aspect; + float plume = exp(-dx * dx * 26.0) * u_pointer; + + float T = temperature(uv, aspect, t, plume); + // Dither shakes the band edges by the Bayer threshold, so each boundary + // prints as an ordered-dot blend instead of a hard line. + float q = u_dither > 0.5 ? bayer8(cell) - 0.5 : 0.0; + T += q * 0.14; + float edge = u_dither > 0.5 ? 0.001 : 0.012; + float cover = smoothstep(0.0, edge, T) * mix(0.35, 1.0, u_intensity); + + // The whole fire breathes a little, like a real one lighting a room. + float flicker = 0.86 + 0.14 * noise(vec2(t * 2.3, 4.7)); + float glow = exp(-uv.y / max(climbOf() * 1.7, 0.02) * 3.4) * flicker; + glow += plume * exp(-length(vec2(dx, uv.y - u_mouse.y)) * 5.0) * 0.5; + glow *= u_intensity; + if (u_dither > 0.5) glow = step(0.52 + q * 0.98, glow * 2.0) * 0.5; + + // On ink the core goes near white; on paper white would vanish into the + // page, so it stops at a pale gold. + vec3 hot = mix(u_c2, vec3(1.0), u_dark > 0.5 ? 0.75 : 0.4); + vec3 col = mix(u_base, mix(u_c0, u_c1, 0.4), glow * (u_dark > 0.5 ? 0.08 : 0.12)); + col = mix(col, bands(T, hot), cover); + + vec3 srgb = linearToSrgb(col); + if (u_dither < 0.5) srgb += (hash(gl_FragCoord.xy + fract(t)) - 0.5) * 0.006; + gl_FragColor = vec4(clamp(srgb, 0.0, 1.0), 1.0); +} +` + +function isDev() { + return ( + typeof process !== "undefined" && process.env?.NODE_ENV !== "production" + ) +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +function numberOr(value: unknown, fallback: number, min: number, max: number) { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback + return clamp(value, min, max) +} + +/** Hex → linear sRGB, so the shader can add light the way light adds. */ +function hexToLinear(hex: string): [number, number, number] { + const h = hex.replace("#", "").trim() + const full = + h.length === 3 + ? h + .split("") + .map((c) => c + c) + .join("") + : h.padEnd(6, "0").slice(0, 6) + const n = Number.parseInt(full, 16) + const srgb = Number.isNaN(n) + ? [0.77, 0.38, 0.18] + : [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255] + return srgb.map((c) => + c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 + ) as [number, number, number] +} + +/** Resolves shadcn / next-themes dark mode (`attribute="class"` → `html.dark`). */ +export function isDarkTheme(): boolean { + if (typeof document === "undefined") return false + const root = document.documentElement + if (root.classList.contains("dark")) return true + if (root.classList.contains("light")) return false + const dataTheme = root.getAttribute("data-theme") + if (dataTheme === "dark") return true + if (dataTheme === "light") return false + return window.matchMedia("(prefers-color-scheme: dark)").matches +} + +export function resolveDark(theme: ShaderAnimeFireTheme): boolean { + if (theme === "dark") return true + if (theme === "light") return false + return isDarkTheme() +} + +function compile(gl: WebGLRenderingContext, type: number, source: string) { + const shader = gl.createShader(type) + if (!shader) return null + gl.shaderSource(shader, source) + gl.compileShader(shader) + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + if (isDev()) { + console.warn( + "ShaderAnimeFire: shader failed to compile\n", + gl.getShaderInfoLog(shader) + ) + } + gl.deleteShader(shader) + return null + } + return shader +} + +/** + * A wall of cel-shaded fire along the bottom edge: turbulent tongues in + * flat bands of heat over a warm, flickering glow. Theme-aware; pauses + * off-screen and in hidden tabs; holds a still frame under + * `prefers-reduced-motion`. + */ +export function createShaderAnimeFire( + canvas: HTMLCanvasElement, + initial: ShaderAnimeFireOptions = {} +): ShaderAnimeFireInstance | null { + let options: ShaderAnimeFireOptions = { + interactive: true, + theme: "auto", + ...initial, + } + + const mouse = { x: 0.5, y: 0.5 } + const target = { x: 0.5, y: 0.5 } + // The plume eases in and out, so leaving the hero never snaps. + let pull = 0 + let pullTarget = 0 + let dark = resolveDark(options.theme ?? "auto") + options.onThemeChange?.(dark) + + const gl = canvas.getContext("webgl", { + alpha: false, + antialias: false, + depth: false, + stencil: false, + powerPreference: "high-performance", + }) + if (!gl) return null + + const vs = compile(gl, gl.VERTEX_SHADER, VERT) + const fs = compile(gl, gl.FRAGMENT_SHADER, FRAG) + if (!vs || !fs) { + if (vs) gl.deleteShader(vs) + if (fs) gl.deleteShader(fs) + return null + } + + const program = gl.createProgram() + if (!program) { + gl.deleteShader(vs) + gl.deleteShader(fs) + return null + } + gl.attachShader(program, vs) + gl.attachShader(program, fs) + gl.linkProgram(program) + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + if (isDev()) { + console.warn( + "ShaderAnimeFire: program failed to link\n", + gl.getProgramInfoLog(program) + ) + } + gl.deleteProgram(program) + gl.deleteShader(vs) + gl.deleteShader(fs) + return null + } + gl.useProgram(program) + + const buf = gl.createBuffer() + gl.bindBuffer(gl.ARRAY_BUFFER, buf) + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), + gl.STATIC_DRAW + ) + const loc = gl.getAttribLocation(program, "a_position") + gl.enableVertexAttribArray(loc) + gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0) + + const u = (name: string) => gl.getUniformLocation(program, name) + const uResolution = u("u_resolution") + const uTime = u("u_time") + const uIntensity = u("u_intensity") + const uHeight = u("u_height") + const uIgnite = u("u_ignite") + const uDark = u("u_dark") + const uDither = u("u_dither") + const uPixel = u("u_pixel") + const uPointer = u("u_pointer") + const uMouse = u("u_mouse") + const uStops = [u("u_c0"), u("u_c1"), u("u_c2")] + const uBase = u("u_base") + + // Flame edges are crisp, so render a bit past 1x — but not a full 2x, + // which would quadruple the fbm work on retina screens. + let dpr = 1 + const resize = () => { + const parent = canvas.parentElement + if (!parent) return + const w = parent.clientWidth + const h = parent.clientHeight + if (w <= 0 || h <= 0) return + dpr = Math.min(window.devicePixelRatio || 1, 1.5) + canvas.width = Math.max(1, Math.floor(w * dpr)) + canvas.height = Math.max(1, Math.floor(h * dpr)) + canvas.style.width = `${w}px` + canvas.style.height = `${h}px` + gl.viewport(0, 0, canvas.width, canvas.height) + wake() + } + + let colorsKey = "" + const syncColors = () => { + const source = options.colors?.length + ? options.colors + : dark + ? DARK_COLORS + : LIGHT_COLORS + const key = `${source.join()}|${dark}` + if (key === colorsKey) return + colorsKey = key + uStops.forEach((loc, i) => { + gl.uniform3fv(loc, hexToLinear(source[i % source.length]!)) + }) + gl.uniform3fv(uBase, hexToLinear(dark ? DARK_BASE : LIGHT_BASE)) + } + + const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)") + let onScreen = true + let raf = 0 + let last = 0 + let clock = 0 + // Seconds since the fire first caught; drives the ignite. + let lit = mqReduce.matches ? 10 : 0 + + const moving = () => + onScreen && !mqReduce.matches && document.visibilityState !== "hidden" + + function wake() { + if (raf) return + last = performance.now() + raf = requestAnimationFrame(tick) + } + + // An arrow, not a declaration, so TypeScript keeps `gl` narrowed inside. + const tick = (now: number) => { + raf = 0 + const dt = Math.min(0.05, Math.max(0, (now - last) / 1000)) + last = now + const live = moving() + if (live) { + clock += dt + lit += dt + } else if (mqReduce.matches) { + lit = 10 + } + + mouse.x += (target.x - mouse.x) * 0.08 + mouse.y += (target.y - mouse.y) * 0.08 + pull += (pullTarget - pull) * 0.05 + + syncColors() + const speed = numberOr(options.speed, DEFAULT_SPEED, 0, 4) + const ignite = 1 - (1 - Math.min(lit / 1.1, 1)) ** 3 + const px = Math.max(1, numberOr(options.pixelSize, 1, 1, 64)) * dpr + gl.uniform2f(uResolution, canvas.width, canvas.height) + gl.uniform1f(uTime, clock * speed) + gl.uniform1f( + uIntensity, + numberOr(options.intensity, DEFAULT_INTENSITY, 0, 1) + ) + gl.uniform1f(uHeight, numberOr(options.height, DEFAULT_HEIGHT, 0, 1)) + gl.uniform1f(uIgnite, Math.max(ignite, 0.001)) + gl.uniform1f(uDark, dark ? 1 : 0) + gl.uniform1f(uDither, options.dither ? 1 : 0) + gl.uniform1f(uPixel, options.dither ? px : dpr) + gl.uniform1f(uPointer, options.interactive === false ? 0 : pull) + gl.uniform2f(uMouse, mouse.x, mouse.y) + gl.drawArrays(gl.TRIANGLES, 0, 6) + + const settling = + Math.abs(pull - pullTarget) > 0.002 || + Math.abs(target.x - mouse.x) > 0.001 || + Math.abs(target.y - mouse.y) > 0.001 + if (live || (settling && !mqReduce.matches)) { + raf = requestAnimationFrame(tick) + } + } + + const ro = new ResizeObserver(resize) + if (canvas.parentElement) ro.observe(canvas.parentElement) + const io = new IntersectionObserver(([entry]) => { + onScreen = entry?.isIntersecting ?? true + wake() + }) + io.observe(canvas) + const onVisibility = () => wake() + document.addEventListener("visibilitychange", onVisibility) + const onReduce = () => wake() + mqReduce.addEventListener("change", onReduce) + + const syncTheme = () => { + const next = resolveDark(options.theme ?? "auto") + if (next === dark) return + dark = next + options.onThemeChange?.(dark) + wake() + } + const mo = new MutationObserver(syncTheme) + mo.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "data-theme", "style"], + }) + const mqDark = window.matchMedia("(prefers-color-scheme: dark)") + mqDark.addEventListener("change", syncTheme) + + const onMove = (e: PointerEvent) => { + if (options.interactive === false) return + const rect = canvas.getBoundingClientRect() + if (rect.width <= 0 || rect.height <= 0) return + const x = (e.clientX - rect.left) / rect.width + const y = 1 - (e.clientY - rect.top) / rect.height + const inside = x >= 0 && x <= 1 && y >= 0 && y <= 1 + pullTarget = inside ? 1 : 0 + if (inside) { + target.x = x + target.y = y + } + wake() + } + window.addEventListener("pointermove", onMove, { passive: true }) + + resize() + wake() + + return { + setOptions(next) { + options = { ...options, ...next } + syncTheme() + wake() + }, + destroy() { + cancelAnimationFrame(raf) + raf = 0 + ro.disconnect() + io.disconnect() + mo.disconnect() + document.removeEventListener("visibilitychange", onVisibility) + mqReduce.removeEventListener("change", onReduce) + mqDark.removeEventListener("change", syncTheme) + window.removeEventListener("pointermove", onMove) + gl.deleteProgram(program) + gl.deleteShader(vs) + gl.deleteShader(fs) + gl.deleteBuffer(buf) + }, + } +} diff --git a/registry/shader-anime-fire/shader-anime-fire.svelte b/registry/shader-anime-fire/shader-anime-fire.svelte new file mode 100644 index 0000000..97f0e71 --- /dev/null +++ b/registry/shader-anime-fire/shader-anime-fire.svelte @@ -0,0 +1,112 @@ + + + + + diff --git a/registry/shader-anime-fire/shader-anime-fire.tsx b/registry/shader-anime-fire/shader-anime-fire.tsx new file mode 100644 index 0000000..7e80ce5 --- /dev/null +++ b/registry/shader-anime-fire/shader-anime-fire.tsx @@ -0,0 +1,136 @@ +"use client" + +import { useEffect, useRef, useState } from "react" + +import { cn } from "@/lib/utils" + +import { + createShaderAnimeFire, + DARK_FALLBACK, + DEFAULT_HEIGHT, + DEFAULT_INTENSITY, + DEFAULT_SPEED, + LIGHT_FALLBACK, + resolveDark, + type ShaderAnimeFireInstance, + type ShaderAnimeFireOptions, +} from "./shader-anime-fire-vanilla" + +export { + DARK_COLORS, + DARK_FALLBACK, + DEFAULT_HEIGHT, + DEFAULT_INTENSITY, + DEFAULT_SPEED, + LIGHT_COLORS, + LIGHT_FALLBACK, +} from "./shader-anime-fire-vanilla" +export type { + ShaderAnimeFireInstance, + ShaderAnimeFireOptions, + ShaderAnimeFireTheme, +} from "./shader-anime-fire-vanilla" + +export type ShaderAnimeFireProps = Omit< + ShaderAnimeFireOptions, + "onThemeChange" +> & { + className?: string +} + +/** + * Cel-shaded flames licking up from the bottom edge, over a warm flickering + * glow. Heat reaches for the pointer. Theme-aware. + */ +export function ShaderAnimeFire({ + className, + colors, + speed = DEFAULT_SPEED, + intensity = DEFAULT_INTENSITY, + height = DEFAULT_HEIGHT, + interactive = true, + dither = false, + pixelSize = 1, + theme = "auto", +}: ShaderAnimeFireProps) { + const canvasRef = useRef(null) + const instanceRef = useRef(null) + const [isDark, setIsDark] = useState(false) + + useEffect(() => { + const sync = () => setIsDark(resolveDark(theme)) + sync() + const mo = new MutationObserver(sync) + mo.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "data-theme"], + }) + const mq = window.matchMedia("(prefers-color-scheme: dark)") + mq.addEventListener("change", sync) + return () => { + mo.disconnect() + mq.removeEventListener("change", sync) + } + }, [theme]) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + instanceRef.current = createShaderAnimeFire(canvas, { + colors, + speed, + intensity, + height, + interactive, + dither, + pixelSize, + theme, + onThemeChange: setIsDark, + }) + return () => { + instanceRef.current?.destroy() + instanceRef.current = null + } + // Engine reads live options via setOptions; mount once. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + instanceRef.current?.setOptions({ + colors, + speed, + intensity, + height, + interactive, + dither, + pixelSize, + theme, + onThemeChange: setIsDark, + }) + }, [colors, speed, intensity, height, interactive, dither, pixelSize, theme]) + + const fallback = isDark ? DARK_FALLBACK : LIGHT_FALLBACK + + return ( +
+ {/* Firefox can hand an opaque WebGL canvas straight to the system + compositor, which ignores rounded clips. The mask on the root keeps + it in the page's own layer, so the parent's radius holds. */} + +
+ ) +} diff --git a/registry/shader-fire/shader-fire-demo.tsx b/registry/shader-fire/shader-fire-demo.tsx index 741b485..48e12f2 100644 --- a/registry/shader-fire/shader-fire-demo.tsx +++ b/registry/shader-fire/shader-fire-demo.tsx @@ -42,6 +42,7 @@ export function ShaderFireDemo() { intensity: 0.55, height: 0.45, interactive: true, + overlay: true, dither: false, pixelSize: 1, colors: palette, @@ -79,32 +80,36 @@ export function ShaderFireDemo() { pixelSize={props.pixelSize} colors={useAutoTheme ? undefined : props.colors} /> -
-
-

- Atmosphere -

-

- Heat under the headline -

-

- Sparse tongues rise from the bottom. Copy stays in the quiet - middle; the fire stays a wash. -

-
- - -
-
+ {props.overlay ? ( + <> +
+
+

+ Atmosphere +

+

+ Heat under the headline +

+

+ Sparse tongues rise from the bottom. Copy stays in the quiet + middle; the fire stays a wash. +

+
+ + +
+
+ + ) : null}
@@ -157,6 +162,12 @@ export function ShaderFireDemo() { step={0.05} onChange={(v) => updateProp("height", v)} /> + updateProp("overlay", v)} + />
diff --git a/registry/shader-fire/shader-fire.tsx b/registry/shader-fire/shader-fire.tsx index 5fae2f1..402ef5b 100644 --- a/registry/shader-fire/shader-fire.tsx +++ b/registry/shader-fire/shader-fire.tsx @@ -107,7 +107,7 @@ export function ShaderFire({ data-slot="shader-fire" aria-hidden className={cn( - "pointer-events-none absolute inset-0 overflow-hidden", + "pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit] [mask-image:linear-gradient(#000,#000)]", className )} style={{ @@ -115,7 +115,13 @@ export function ShaderFire({ backgroundImage: fallback.backgroundImage, }} > - + {/* Firefox can hand an opaque WebGL canvas straight to the system + compositor, which ignores rounded clips. The mask on the root keeps + it in the page's own layer, so the parent's radius holds. */} +
) } diff --git a/registry/shader-gradient/shader-gradient-demo.tsx b/registry/shader-gradient/shader-gradient-demo.tsx index 89cf6a7..873a2fd 100644 --- a/registry/shader-gradient/shader-gradient-demo.tsx +++ b/registry/shader-gradient/shader-gradient-demo.tsx @@ -14,6 +14,10 @@ import { useHydratedTheme } from "@/hooks/use-hydrated-theme" import { usePreviewProps } from "@/hooks/use-preview-props" import { DARK_COLORS, + DEFAULT_BLUR, + DEFAULT_GRAIN, + DEFAULT_INTENSITY, + DEFAULT_SPEED, LIGHT_COLORS, ShaderGradient, } from "@/registry/shader-gradient/shader-gradient" @@ -38,10 +42,12 @@ export function ShaderGradientDemo() { const defaults = useMemo( () => ({ - speed: 0.14, - blur: 0.7, - intensity: 0.95, + speed: DEFAULT_SPEED, + blur: DEFAULT_BLUR, + intensity: DEFAULT_INTENSITY, + grain: DEFAULT_GRAIN, interactive: true, + overlay: true, colors: palette, }), [palette] @@ -73,35 +79,40 @@ export function ShaderGradientDemo() { speed={props.speed} blur={props.blur} intensity={props.intensity} + grain={props.grain} interactive={props.interactive} colors={useAutoTheme ? undefined : props.colors} /> -
-
-

- Landing -

-

- A first screen that already feels finished -

-

- Headline and a primary action sit on the wash. The shader stays - in the back. -

-
- - -
-
+ {props.overlay ? ( + <> +
+
+

+ Landing +

+

+ A first screen that already feels finished +

+

+ Headline and a primary action sit on the wash. The shader + stays in the back. +

+
+ + +
+
+ + ) : null}
@@ -110,10 +121,12 @@ export function ShaderGradientDemo() { onReset={resetProps} component="ShaderGradient" snippetProps={{ - speed: props.speed, - blur: props.blur, - intensity: props.intensity, - interactive: props.interactive, + speed: props.speed === DEFAULT_SPEED ? undefined : props.speed, + blur: props.blur === DEFAULT_BLUR ? undefined : props.blur, + intensity: + props.intensity === DEFAULT_INTENSITY ? undefined : props.intensity, + grain: props.grain === DEFAULT_GRAIN ? undefined : props.grain, + interactive: props.interactive ? undefined : false, colors: useAutoTheme ? undefined : props.colors, }} > @@ -122,9 +135,10 @@ export function ShaderGradientDemo() { colors={props.colors} palettes={[ palette, - ["#E07A5F", "#F2CC8F", "#81B29A", "#3D405B"], - ["#0D1B2A", "#1B263B", "#415A77", "#778DA9"], - ["#F72585", "#7209B7", "#3A0CA3", "#4CC9F0"], + // Each cycles back to its first color, so neighbours stay close. + ["#FF6B6B", "#FFB36B", "#FFE08A", "#FF8FB1"], + ["#0B3D91", "#1F7A8C", "#6CC4A1", "#2E5EAA"], + ["#F72585", "#B5179E", "#7209B7", "#4361EE"], ]} onChange={(colors) => updateProp("colors", colors)} /> @@ -152,9 +166,23 @@ export function ShaderGradientDemo() { step={0.05} onChange={(v) => updateProp("intensity", v)} /> + updateProp("grain", v)} + /> + updateProp("overlay", v)} + /> updateProp("interactive", v)} /> diff --git a/registry/shader-gradient/shader-gradient-vanilla.ts b/registry/shader-gradient/shader-gradient-vanilla.ts index 7c2bfdd..9ec2f53 100644 --- a/registry/shader-gradient/shader-gradient-vanilla.ts +++ b/registry/shader-gradient/shader-gradient-vanilla.ts @@ -1,15 +1,20 @@ export type ShaderGradientTheme = "light" | "dark" | "auto" export type ShaderGradientOptions = { - /** Up to 4 hex colors — order: sky, sage, cream, lavender */ + /** + * Up to 4 colors, any CSS hex. The flow cycles through them in order and + * back to the first, so neighbours should sit well together. + */ colors?: string[] - /** Drift speed. Default 0.14 */ + /** Flow speed. Default `0.14`. */ speed?: number - /** Wash softness / size 0–1. Default 0.7 */ + /** Softness, 0–1: higher is broader, calmer bands. Default `0.7`. */ blur?: number - /** Color strength 0–1. Default 0.95 */ + /** Color strength, 0–1; lower lets the paper / ink show through. Default `0.95`. */ intensity?: number - /** Follow pointer gently. Default true */ + /** Film grain, 0–1. `0` turns it off. Default `0.35`. */ + grain?: number + /** The flow swirls gently around the pointer. Default `true`. */ interactive?: boolean /** * Palette mode. Default `auto` follows shadcn / next-themes @@ -25,37 +30,41 @@ export type ShaderGradientInstance = { destroy: () => void } -/** Airy hero wash — sky / sage / cream / lavender on paper */ -export const LIGHT_COLORS = ["#7CB4E0", "#B4D8C4", "#EFE4BC", "#D2D7EC"] +/** Peach, butter, sky and lilac, flowing over warm paper. */ +export const LIGHT_COLORS = ["#F7A48B", "#F9D78E", "#9FCBF0", "#BBA9EE"] /** - * Dusk hues that still read on slate — sky / sage / amber / lilac. - * Bright enough to see, dark enough not to go pastel neon. + * Blue, violet, magenta and coral over near-black ink — a narrow hue arc, + * so every blend on the cycle stays rich instead of passing through grey. */ -export const DARK_COLORS = ["#3A6FA0", "#2F6B52", "#8A6B32", "#4A4D7A"] - -export const LIGHT_FALLBACK = { - backgroundColor: "#FCFBF9", - backgroundImage: [ - "radial-gradient(60% 45% at 60% 20%, #8FC0E4 0%, transparent 70%)", - "radial-gradient(40% 50% at 90% 46%, #8FC0E4 0%, transparent 70%)", - "radial-gradient(30% 40% at 100% 60%, #BEDCCA 0%, transparent 70%)", - "radial-gradient(38% 46% at 4% 56%, #F0E7C4 0%, transparent 70%)", - "radial-gradient(28% 24% at 90% 74%, #F0E7C4 0%, transparent 70%)", - "radial-gradient(40% 38% at 32% 38%, #D5D9EC 0%, transparent 70%)", - ].join(", "), -} as const - -export const DARK_FALLBACK = { - backgroundColor: "#08090C", - backgroundImage: [ - "radial-gradient(60% 45% at 60% 20%, #3A6FA0 0%, transparent 70%)", - "radial-gradient(40% 50% at 90% 46%, #3A6FA0 0%, transparent 70%)", - "radial-gradient(30% 40% at 100% 60%, #2F6B52 0%, transparent 70%)", - "radial-gradient(38% 46% at 4% 56%, #8A6B32 0%, transparent 70%)", - "radial-gradient(28% 24% at 90% 74%, #8A6B32 0%, transparent 70%)", - "radial-gradient(40% 38% at 32% 38%, #4A4D7A 0%, transparent 70%)", - ].join(", "), -} as const +export const DARK_COLORS = ["#3D52F2", "#9150F2", "#E0479F", "#FF7B60"] +const LIGHT_BASE = "#FBF8F4" +const DARK_BASE = "#07080B" + +export const DEFAULT_SPEED = 0.14 +export const DEFAULT_BLUR = 0.7 +export const DEFAULT_INTENSITY = 0.95 +export const DEFAULT_GRAIN = 0.35 + +/** Film grain for the CSS fallback — the same idea, as an SVG turbulence tile. */ +const GRAIN_TILE = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 0.16 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E")` + +function fallback(colors: string[], base: string) { + const [a, b, c, d] = colors + return { + backgroundColor: base, + backgroundImage: [ + GRAIN_TILE, + `radial-gradient(70% 60% at 18% 22%, ${a} 0%, transparent 70%)`, + `radial-gradient(60% 60% at 82% 30%, ${b} 0%, transparent 70%)`, + `radial-gradient(70% 60% at 70% 88%, ${c} 0%, transparent 70%)`, + `radial-gradient(55% 55% at 12% 86%, ${d} 0%, transparent 70%)`, + ].join(", "), + } as const +} + +/** Shown before the first frame and wherever WebGL isn't available. */ +export const LIGHT_FALLBACK = fallback(LIGHT_COLORS, LIGHT_BASE) +export const DARK_FALLBACK = fallback(DARK_COLORS, DARK_BASE) const VERT = ` attribute vec2 a_position; @@ -69,36 +78,43 @@ precision highp float; uniform vec2 u_resolution; uniform float u_time; -uniform float u_speed; uniform float u_blur; uniform float u_intensity; -uniform float u_dark; +uniform float u_grain; +uniform float u_grainSeed; +uniform float u_pixel; +uniform float u_pointer; +uniform vec2 u_mouse; +// Palette stops and the paper / ink underneath, in OKLab. +uniform vec3 u_c0; uniform vec3 u_c1; uniform vec3 u_c2; uniform vec3 u_c3; -uniform vec3 u_c4; -uniform vec2 u_mouse; +uniform vec3 u_base; +// Dave Hoskins' hash — no sin(), so it stays stable on mobile GPUs. float hash(vec2 p) { - return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); } float noise(vec2 p) { vec2 i = floor(p); vec2 f = fract(p); - float a = hash(i); - float b = hash(i + vec2(1.0, 0.0)); - float c = hash(i + vec2(0.0, 1.0)); - float d = hash(i + vec2(1.0, 1.0)); vec2 u = f * f * (3.0 - 2.0 * f); - return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y; + return mix( + mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x), + mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x), + u.y + ); } float fbm(vec2 p) { float v = 0.0; float a = 0.5; mat2 m = mat2(1.6, 1.2, -1.2, 1.6); - for (int i = 0; i < 4; i++) { + for (int i = 0; i < 3; i++) { v += a * noise(p); p = m * p; a *= 0.5; @@ -106,119 +122,107 @@ float fbm(vec2 p) { return v; } -// Super-gaussian field — elliptical, rotatable, flat-topped. -// The flat top is what stops each wash from showing a bright center dot. -float smear(vec2 p, vec2 c, vec2 r, float ang, float plateau) { - vec2 d = p - c; - float s = sin(ang); - float co = cos(ang); - d = vec2(d.x * co + d.y * s, -d.x * s + d.y * co) / r; - return exp(-pow(dot(d, d) + 0.0001, plateau)); +// Weight of the stop at 'at' on a cyclic 0–1 track: a smoothed hat, so any +// two neighbouring weights always sum to one. +float stop(float v, float at) { + float d = abs(v - at); + d = min(d, 1.0 - d); + return smoothstep(0.0, 1.0, 1.0 - clamp(d * 4.0, 0.0, 1.0)); } -void main() { - vec2 uv = gl_FragCoord.xy / u_resolution.xy; - float aspect = u_resolution.x / max(u_resolution.y, 1.0); +vec3 palette(float v) { + return u_c0 * stop(v, 0.0) + u_c1 * stop(v, 0.25) + + u_c2 * stop(v, 0.5) + u_c3 * stop(v, 0.75); +} - // Square-ish space so washes keep their shape on any container - vec2 st = vec2(uv.x * aspect, uv.y); - - float t = u_time * u_speed; - - // Low-frequency warp — gives the wash a watercolor edge instead of a clean ellipse - vec2 warp = vec2( - fbm(st * 1.2 + vec2(t * 0.22, t * 0.16)), - fbm(st * 1.2 + vec2(-t * 0.18, t * 0.2) + 5.3) - ) - 0.5; - st += warp * (0.07 + u_blur * 0.08); - st -= (u_mouse - 0.5) * 0.05; - - float grow = 0.85 + u_blur * 0.4; - - // Slow drift — long periods so it never reads as looping - vec2 d1 = vec2(sin(t * 0.71), cos(t * 0.53)) * 0.045; - vec2 d2 = vec2(cos(t * 0.61), sin(t * 0.83)) * 0.04; - vec2 d3 = vec2(sin(t * 0.49), cos(t * 0.77)) * 0.05; - vec2 d4 = vec2(cos(t * 0.87), sin(t * 0.59)) * 0.045; - - // Big sky sweep across the upper right — the dominant mass - float g1 = smear(st, vec2((0.54 + d1.x) * aspect, 0.84 + d1.y), - vec2(0.54, 0.30) * grow, -0.5, 1.9); - // Broad reinforcement, pulled toward the middle of the sweep - float g2 = smear(st, vec2((0.66 + d2.x) * aspect, 0.72 + d2.y), - vec2(0.42, 0.30) * grow, -0.45, 1.9); - // Sky trailing down the right edge - float g3 = smear(st, vec2((0.92 + d2.y) * aspect, 0.50 + d2.x), - vec2(0.26, 0.32) * grow, 0.3, 1.7); - // Sage green hugging the right edge - float g4 = smear(st, vec2((0.99 + d3.x) * aspect, 0.44 + d3.y), - vec2(0.28, 0.34) * grow, 0.0, 1.7); - // Butter cream on the left and low right - float g5 = smear(st, vec2((0.06 + d4.x) * aspect, 0.44 + d4.y), - vec2(0.34, 0.38) * grow, 0.4, 1.8); - float g6 = smear(st, vec2((0.86 + d1.y) * aspect, 0.22 + d1.x), - vec2(0.28, 0.22) * grow, -0.2, 1.6); - // Lavender transition between the sky and the cream - float g7 = smear(st, vec2((0.32 + d3.y) * aspect, 0.60 + d3.x), - vec2(0.32, 0.28) * grow, 0.2, 1.6); - // Very wide base tint so the middle never falls back to bare white - float g8 = smear(st, vec2((0.50 + d4.y) * aspect, 0.40 + d4.x), - vec2(0.95, 0.80) * grow, 0.0, 1.2); - - float w1 = g1 * 1.00; - float w2 = g2 * 0.45; - float w3 = g3 * 0.70; - float w4 = g4 * 0.95; - float w5 = g5 * 0.95; - float w6 = g6 * 0.85; - float w7 = g7 * 0.50; - float w8 = g8 * 0.30; - - float sum = w1 + w2 + w3 + w4 + w5 + w6 + w7 + w8; - - // Neutral pale cast — keeps the open middle from going dead white - vec3 base = mix(u_c4, u_c3, 0.5); - - // Weighted average keeps hues clean — no successive-mix muddiness - vec3 tint = - (u_c1 * w1 + u_c1 * w2 + u_c1 * w3 + u_c2 * w4 + - u_c3 * w5 + u_c3 * w6 + u_c4 * w7 + base * w8) / - max(sum, 0.0001); - - // Screen-style coverage so overlaps deepen smoothly instead of clipping - float cover = 1.0 - - (1.0 - clamp(w1, 0.0, 1.0)) * (1.0 - clamp(w2, 0.0, 1.0)) * - (1.0 - clamp(w3, 0.0, 1.0)) * (1.0 - clamp(w4, 0.0, 1.0)) * - (1.0 - clamp(w5, 0.0, 1.0)) * (1.0 - clamp(w6, 0.0, 1.0)) * - (1.0 - clamp(w7, 0.0, 1.0)) * (1.0 - clamp(w8, 0.0, 1.0)); - - // Let the lower left breathe back to bare paper / slate - float fade = smoothstep(-0.35, 0.55, uv.x * 0.42 + uv.y * 0.72); - cover *= fade * clamp(u_intensity, 0.0, 1.0); - - vec3 col; - if (u_dark < 0.5) { - vec3 paper = vec3(0.988, 0.986, 0.982); - col = mix(paper, tint, clamp(cover, 0.0, 1.0)); - } else { - // Near-black ink — dusk hues lift off the void without going neon - vec3 slate = vec3(0.03, 0.032, 0.042); - float glow = clamp(cover * 0.72, 0.0, 1.0); - col = mix(slate, tint, glow); - } +vec3 oklabToLinear(vec3 c) { + float l = c.x + 0.3963377774 * c.y + 0.2158037573 * c.z; + float m = c.x - 0.1055613458 * c.y - 0.0638541728 * c.z; + float s = c.x - 0.0894841775 * c.y - 1.2914855480 * c.z; + l = l * l * l; + m = m * m * m; + s = s * s * s; + return vec3( + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s + ); +} + +vec3 linearToSrgb(vec3 c) { + c = clamp(c, 0.0, 1.0); + vec3 lo = c * 12.92; + vec3 hi = 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055; + return mix(lo, hi, step(vec3(0.0031308), c)); +} - // Dither — kills banding across these very low-contrast ramps - col += (hash(gl_FragCoord.xy + fract(u_time)) - 0.5) * 0.005; +void main() { + vec2 uv = gl_FragCoord.xy / u_resolution; + float aspect = u_resolution.x / max(u_resolution.y, 1.0); + float scale = mix(2.0, 0.8, u_blur); + vec2 p = vec2(uv.x * aspect, uv.y) * scale; + float t = u_time; + + // A soft swirl around the pointer. + vec2 d = p - vec2(u_mouse.x * aspect, u_mouse.y) * scale; + p += vec2(-d.y, d.x) * exp(-dot(d, d) * 1.4) * 0.4 * u_pointer; + + // Domain warping: noise fed back through itself twice gives slow, + // liquid bands that never read as a loop. + vec2 q = vec2( + fbm(p + vec2(0.0, 0.12 * t)), + fbm(p + vec2(5.2, 1.3) - 0.1 * t) + ); + vec2 r = vec2( + fbm(p + 2.2 * q + vec2(1.7, 9.2) + 0.07 * t), + fbm(p + 2.2 * q + vec2(8.3, 2.8) - 0.06 * t) + ); + float f = fbm(p + 2.0 * r); + + // Where on the palette this pixel sits. The track is cyclic, so wrapping + // past the last color flows back into the first without a seam. + float v = fract(f * 2.4 + 0.35 * r.x + 0.02 * t); + vec3 lab = palette(v); + + // Some of the paper / ink breathes through, so it isn't wall-to-wall color. + // Ink dulls far faster than paper does, so on a dark base only a little + // shows, and the color gets a chroma lift to glow against it. + float air = smoothstep(0.28, 0.72, fbm(p * 0.6 - q + 13.0 + 0.03 * t)); + float floorMix = mix(0.82, 0.45, u_base.x); + lab.yz *= mix(1.15, 1.0, u_base.x); + lab = mix(u_base, lab, u_intensity * mix(floorMix, 1.0, air)); + + vec3 col = linearToSrgb(oklabToLinear(lab)); + + // Film grain on a CSS-pixel grid: two hashes averaged for a softer, + // more photographic distribution than flat white noise. + vec2 cell = floor(gl_FragCoord.xy / u_pixel); + float g = (hash(cell + u_grainSeed) + hash(cell.yx - u_grainSeed * 1.7)) * 0.5; + col += (g - 0.5) * u_grain * 0.2; + // Always a hair of dither, so the smooth ramps never band. + col += (hash(cell * 1.31 + u_grainSeed) - 0.5) * 0.004; gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0); } ` function isDev() { - return typeof process !== "undefined" && process.env?.NODE_ENV !== "production" + return ( + typeof process !== "undefined" && process.env?.NODE_ENV !== "production" + ) +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +function numberOr(value: unknown, fallback: number, min: number, max: number) { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback + return clamp(value, min, max) } -function hexToRgb(hex: string): [number, number, number] { +/** Hex → OKLab, so the shader can blend without going muddy. */ +function hexToOklab(hex: string): [number, number, number] { const h = hex.replace("#", "").trim() const full = h.length === 3 @@ -228,8 +232,20 @@ function hexToRgb(hex: string): [number, number, number] { .join("") : h.padEnd(6, "0").slice(0, 6) const n = Number.parseInt(full, 16) - if (Number.isNaN(n)) return [0.72, 0.86, 0.94] - return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255] + const srgb = Number.isNaN(n) + ? [0.5, 0.5, 0.5] + : [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255] + const [r, g, b] = srgb.map((c) => + c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4 + ) as [number, number, number] + const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) + const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) + const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) + return [ + 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, + ] } /** Resolves shadcn / next-themes dark mode (`attribute="class"` → `html.dark`). */ @@ -269,31 +285,26 @@ function compile(gl: WebGLRenderingContext, type: number, source: string) { } /** - * Quiet WebGL atmosphere for heroes and empty states — soft-focus color - * fields behind UI. Theme-aware light / dusk. + * A grainy liquid gradient for heroes and empty states. The palette flows + * through slow domain-warped noise, blends in OKLab so neighbours never go + * muddy, and carries a film grain. Theme-aware; pauses off-screen and in + * hidden tabs; holds a still frame under `prefers-reduced-motion`. */ export function createShaderGradient( canvas: HTMLCanvasElement, initial: ShaderGradientOptions = {} ): ShaderGradientInstance | null { - let options: Required< - Pick< - ShaderGradientOptions, - "speed" | "blur" | "intensity" | "interactive" | "theme" - > - > & - ShaderGradientOptions = { - speed: 0.14, - blur: 0.7, - intensity: 0.95, + let options: ShaderGradientOptions = { interactive: true, theme: "auto", ...initial, } const mouse = { x: 0.5, y: 0.5 } - const targetMouse = { x: 0.5, y: 0.5 } - let reduce = false + const target = { x: 0.5, y: 0.5 } + // Pointer pull eases in and out, so leaving the hero never snaps. + let pull = 0 + let pullTarget = 0 let dark = resolveDark(options.theme ?? "auto") options.onThemeChange?.(dark) @@ -348,46 +359,110 @@ export function createShaderGradient( gl.enableVertexAttribArray(loc) gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0) - const uResolution = gl.getUniformLocation(program, "u_resolution") - const uTime = gl.getUniformLocation(program, "u_time") - const uSpeed = gl.getUniformLocation(program, "u_speed") - const uBlur = gl.getUniformLocation(program, "u_blur") - const uIntensity = gl.getUniformLocation(program, "u_intensity") - const uDark = gl.getUniformLocation(program, "u_dark") - const uC1 = gl.getUniformLocation(program, "u_c1") - const uC2 = gl.getUniformLocation(program, "u_c2") - const uC3 = gl.getUniformLocation(program, "u_c3") - const uC4 = gl.getUniformLocation(program, "u_c4") - const uMouse = gl.getUniformLocation(program, "u_mouse") - - let raf = 0 - let running = true - const start = performance.now() - let frozenTime = 0 - + const u = (name: string) => gl.getUniformLocation(program, name) + const uResolution = u("u_resolution") + const uTime = u("u_time") + const uBlur = u("u_blur") + const uIntensity = u("u_intensity") + const uGrain = u("u_grain") + const uGrainSeed = u("u_grainSeed") + const uPixel = u("u_pixel") + const uPointer = u("u_pointer") + const uMouse = u("u_mouse") + const uStops = [u("u_c0"), u("u_c1"), u("u_c2"), u("u_c3")] + const uBase = u("u_base") + + // The gradient is soft enough that one sample per CSS pixel is plenty; + // it also keeps the grain the same size on every screen. + let pixel = 1 const resize = () => { const parent = canvas.parentElement if (!parent) return - const dpr = Math.min(window.devicePixelRatio || 1, 2) const w = parent.clientWidth const h = parent.clientHeight if (w <= 0 || h <= 0) return - canvas.width = Math.max(1, Math.floor(w * dpr)) - canvas.height = Math.max(1, Math.floor(h * dpr)) + pixel = Math.min(window.devicePixelRatio || 1, 1) + canvas.width = Math.max(1, Math.floor(w * pixel)) + canvas.height = Math.max(1, Math.floor(h * pixel)) canvas.style.width = `${w}px` canvas.style.height = `${h}px` gl.viewport(0, 0, canvas.width, canvas.height) + wake() } - resize() - const ro = new ResizeObserver(resize) - if (canvas.parentElement) ro.observe(canvas.parentElement) + let colorsKey = "" + const syncColors = () => { + const source = options.colors?.length + ? options.colors + : dark + ? DARK_COLORS + : LIGHT_COLORS + const key = `${source.join()}|${dark}` + if (key === colorsKey) return + colorsKey = key + uStops.forEach((loc, i) => { + gl.uniform3fv(loc, hexToOklab(source[i % source.length]!)) + }) + gl.uniform3fv(uBase, hexToOklab(dark ? DARK_BASE : LIGHT_BASE)) + } const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)") - const onReduce = () => { - reduce = mqReduce.matches + let onScreen = true + let raf = 0 + let last = 0 + let clock = 0 + + const moving = () => + onScreen && !mqReduce.matches && document.visibilityState !== "hidden" + + function wake() { + if (raf) return + last = performance.now() + raf = requestAnimationFrame(tick) + } + + // An arrow, not a declaration, so TypeScript keeps `gl` narrowed inside. + const tick = (now: number) => { + raf = 0 + const dt = Math.min(0.05, Math.max(0, (now - last) / 1000)) + last = now + const live = moving() + if (live) clock += dt + + mouse.x += (target.x - mouse.x) * 0.05 + mouse.y += (target.y - mouse.y) * 0.05 + pull += (pullTarget - pull) * 0.04 + + syncColors() + const speed = numberOr(options.speed, DEFAULT_SPEED, 0, 4) + gl.uniform2f(uResolution, canvas.width, canvas.height) + gl.uniform1f(uTime, clock * speed * 4) + gl.uniform1f(uBlur, numberOr(options.blur, DEFAULT_BLUR, 0, 1)) + gl.uniform1f( + uIntensity, + numberOr(options.intensity, DEFAULT_INTENSITY, 0, 1) + ) + gl.uniform1f(uGrain, numberOr(options.grain, DEFAULT_GRAIN, 0, 1)) + // Grain re-rolls at 24 fps, like film, and holds still when paused. + gl.uniform1f(uGrainSeed, (Math.floor(clock * 24) % 997) * 13.7) + gl.uniform1f(uPixel, pixel) + gl.uniform1f(uPointer, options.interactive === false ? 0 : pull) + gl.uniform2f(uMouse, mouse.x, mouse.y) + gl.drawArrays(gl.TRIANGLES, 0, 6) + + if (live) raf = requestAnimationFrame(tick) } - onReduce() + + const ro = new ResizeObserver(resize) + if (canvas.parentElement) ro.observe(canvas.parentElement) + const io = new IntersectionObserver(([entry]) => { + onScreen = entry?.isIntersecting ?? true + wake() + }) + io.observe(canvas) + const onVisibility = () => wake() + document.addEventListener("visibilitychange", onVisibility) + const onReduce = () => wake() mqReduce.addEventListener("change", onReduce) const syncTheme = () => { @@ -395,6 +470,7 @@ export function createShaderGradient( if (next === dark) return dark = next options.onThemeChange?.(dark) + wake() } const mo = new MutationObserver(syncTheme) mo.observe(document.documentElement, { @@ -405,66 +481,36 @@ export function createShaderGradient( mqDark.addEventListener("change", syncTheme) const onMove = (e: PointerEvent) => { - if (!options.interactive) return - const parent = canvas.parentElement - if (!parent) return - const rect = parent.getBoundingClientRect() + if (options.interactive === false) return + const rect = canvas.getBoundingClientRect() if (rect.width <= 0 || rect.height <= 0) return - targetMouse.x = (e.clientX - rect.left) / rect.width - targetMouse.y = 1 - (e.clientY - rect.top) / rect.height - } - window.addEventListener("pointermove", onMove, { passive: true }) - - const tick = (now: number) => { - if (!running) return - - if (reduce) { - if (frozenTime === 0) frozenTime = (now - start) / 1000 - } else { - frozenTime = 0 + const x = (e.clientX - rect.left) / rect.width + const y = 1 - (e.clientY - rect.top) / rect.height + const inside = x >= 0 && x <= 1 && y >= 0 && y <= 1 + pullTarget = inside ? 1 : 0 + if (inside) { + target.x = x + target.y = y } - - const time = reduce ? frozenTime : (now - start) / 1000 - const paletteSrc = options.colors ?? (dark ? DARK_COLORS : LIGHT_COLORS) - const palette = [0, 1, 2, 3].map((i) => - hexToRgb(paletteSrc[i % paletteSrc.length] ?? LIGHT_COLORS[i]!) - ) - - mouse.x += (targetMouse.x - mouse.x) * 0.03 - mouse.y += (targetMouse.y - mouse.y) * 0.03 - - gl.uniform2f(uResolution, canvas.width, canvas.height) - gl.uniform1f(uTime, time) - gl.uniform1f(uSpeed, reduce ? 0 : (options.speed ?? 0.14)) - gl.uniform1f(uBlur, Math.min(1, Math.max(0, options.blur ?? 0.7))) - gl.uniform1f(uIntensity, Math.min(1, Math.max(0, options.intensity ?? 0.95))) - gl.uniform1f(uDark, dark ? 1 : 0) - gl.uniform3f(uC1, palette[0]![0], palette[0]![1], palette[0]![2]) - gl.uniform3f(uC2, palette[1]![0], palette[1]![1], palette[1]![2]) - gl.uniform3f(uC3, palette[2]![0], palette[2]![1], palette[2]![2]) - gl.uniform3f(uC4, palette[3]![0], palette[3]![1], palette[3]![2]) - gl.uniform2f( - uMouse, - options.interactive ? mouse.x : 0.5, - options.interactive ? mouse.y : 0.5 - ) - - gl.drawArrays(gl.TRIANGLES, 0, 6) - raf = requestAnimationFrame(tick) } + window.addEventListener("pointermove", onMove, { passive: true }) - raf = requestAnimationFrame(tick) + resize() + wake() return { setOptions(next) { options = { ...options, ...next } syncTheme() + wake() }, destroy() { - running = false cancelAnimationFrame(raf) + raf = 0 ro.disconnect() + io.disconnect() mo.disconnect() + document.removeEventListener("visibilitychange", onVisibility) mqReduce.removeEventListener("change", onReduce) mqDark.removeEventListener("change", syncTheme) window.removeEventListener("pointermove", onMove) diff --git a/registry/shader-gradient/shader-gradient.svelte b/registry/shader-gradient/shader-gradient.svelte index 1ea7c3e..c6205bf 100644 --- a/registry/shader-gradient/shader-gradient.svelte +++ b/registry/shader-gradient/shader-gradient.svelte @@ -6,6 +6,10 @@ import { createShaderGradient, DARK_FALLBACK, + DEFAULT_BLUR, + DEFAULT_GRAIN, + DEFAULT_INTENSITY, + DEFAULT_SPEED, LIGHT_FALLBACK, resolveDark, type ShaderGradientInstance, @@ -23,9 +27,10 @@ let { class: className = "", colors, - speed = 0.14, - blur = 0.7, - intensity = 0.95, + speed = DEFAULT_SPEED, + blur = DEFAULT_BLUR, + intensity = DEFAULT_INTENSITY, + grain = DEFAULT_GRAIN, interactive = true, theme = "auto", }: Props = $props() @@ -58,6 +63,7 @@ speed, blur, intensity, + grain, interactive, theme, onThemeChange: (dark) => { @@ -82,6 +88,7 @@ speed, blur, intensity, + grain, interactive, theme, }) @@ -93,8 +100,11 @@ diff --git a/registry/shader-gradient/shader-gradient.tsx b/registry/shader-gradient/shader-gradient.tsx index fbb01eb..8cf642a 100644 --- a/registry/shader-gradient/shader-gradient.tsx +++ b/registry/shader-gradient/shader-gradient.tsx @@ -7,6 +7,10 @@ import { cn } from "@/lib/utils" import { createShaderGradient, DARK_FALLBACK, + DEFAULT_BLUR, + DEFAULT_GRAIN, + DEFAULT_INTENSITY, + DEFAULT_SPEED, LIGHT_FALLBACK, resolveDark, type ShaderGradientInstance, @@ -16,6 +20,10 @@ import { export { DARK_COLORS, DARK_FALLBACK, + DEFAULT_BLUR, + DEFAULT_GRAIN, + DEFAULT_INTENSITY, + DEFAULT_SPEED, LIGHT_COLORS, LIGHT_FALLBACK, } from "./shader-gradient-vanilla" @@ -33,15 +41,16 @@ export type ShaderGradientProps = Omit< } /** - * Quiet WebGL atmosphere for heroes and empty states — soft-focus color - * fields behind UI. Theme-aware light / dusk. + * A grainy liquid gradient for heroes and empty states — the palette flows + * through slow warped noise under a film grain. Theme-aware. */ export function ShaderGradient({ className, colors, - speed = 0.14, - blur = 0.7, - intensity = 0.95, + speed = DEFAULT_SPEED, + blur = DEFAULT_BLUR, + intensity = DEFAULT_INTENSITY, + grain = DEFAULT_GRAIN, interactive = true, theme = "auto", }: ShaderGradientProps) { @@ -73,6 +82,7 @@ export function ShaderGradient({ speed, blur, intensity, + grain, interactive, theme, onThemeChange: setIsDark, @@ -91,11 +101,12 @@ export function ShaderGradient({ speed, blur, intensity, + grain, interactive, theme, onThemeChange: setIsDark, }) - }, [colors, speed, blur, intensity, interactive, theme]) + }, [colors, speed, blur, intensity, grain, interactive, theme]) const fallback = isDark ? DARK_FALLBACK : LIGHT_FALLBACK @@ -104,7 +115,7 @@ export function ShaderGradient({ data-slot="shader-gradient" aria-hidden className={cn( - "pointer-events-none absolute inset-0 overflow-hidden", + "pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit] [mask-image:linear-gradient(#000,#000)]", className )} style={{ @@ -112,7 +123,13 @@ export function ShaderGradient({ backgroundImage: fallback.backgroundImage, }} > - + {/* Firefox can hand an opaque WebGL canvas straight to the system + compositor, which ignores rounded clips. The mask on the root keeps + it in the page's own layer, so the parent's radius holds. */} +
) } diff --git a/registry/shader-sky/shader-sky-demo.tsx b/registry/shader-sky/shader-sky-demo.tsx index 0cec085..42e1f43 100644 --- a/registry/shader-sky/shader-sky-demo.tsx +++ b/registry/shader-sky/shader-sky-demo.tsx @@ -25,6 +25,7 @@ const DEFAULTS = { scale: 0.4, variation: 0.7, interactive: false, + lightning: false, glass: false, glassSize: 7, overlay: false, @@ -50,6 +51,7 @@ export function ShaderSkyDemo() { scale={props.scale} variation={props.variation} interactive={props.interactive} + lightning={props.lightning} glass={props.glass} glassSize={props.glassSize} colors={props.colors} @@ -99,6 +101,7 @@ export function ShaderSkyDemo() { scale: props.scale, variation: props.variation, interactive: props.interactive ? true : undefined, + lightning: props.lightning ? true : undefined, glass: props.glass ? true : undefined, glassSize: props.glass ? props.glassSize : undefined, colors: props.colors, @@ -170,6 +173,12 @@ export function ShaderSkyDemo() { checked={props.interactive} onChange={(v) => updateProp("interactive", v)} /> + updateProp("lightning", v)} + /> 0.5 && d > 0.0) { + // Wispy erosion at the rims, rising slowly like convection. + float e = noise3(q * 6.1 + vec3(0.0, -t * 0.6, 0.0)); + d = clamp(d - e * mix(0.08, 0.26, u_variation) * (1.0 - d), 0.0, 1.0); + } + return d; } void main() { @@ -201,39 +209,105 @@ void main() { uv += glassLocal * 0.0032; } - vec2 field = skyField(uv, aspect, t); - float density = field.x; - float shade = field.y; - - float h = pow(clamp(uv.y, 0.0, 1.0), 0.76); - vec3 sky = mix(u_c2, u_c1, h); + vec2 s = (uv - 0.5) * vec2(aspect, 1.0); + s += (u_mouse - 0.5) * vec2(0.06, 0.03); + vec3 dir = normalize(vec3(s.x, s.y + TILT, 1.0)); + vec3 sunDir = normalize(vec3(0.85, 0.95, 1.0)); + float mu = dot(dir, sunDir); + + // Storm palettes (cloud darker than the horizon) mute the sun. + float storm = smoothstep(0.02, -0.12, luma(u_c3) - luma(u_c2)); + float sunAmt = 1.0 - storm * 0.75; + vec3 sunTint = mix(vec3(1.0, 0.96, 0.88), u_c3, 0.3); + + // --- Sky --------------------------------------------------------------- + // Below the horizon there is only haze. + float up = clamp(dir.y, 0.0, 1.0); + vec3 sky = mix(u_c2, u_c1, pow(smoothstep(0.0, 0.85, up), 0.6)); + vec3 haze = mix(u_c2, vec3(1.0), 0.3 * luma(u_c2)); + sky = mix(sky, haze, exp(-up * 9.0) * 0.6); + float glow = pow(max(mu, 0.0), 8.0) * 0.14 + pow(max(mu, 0.0), 120.0) * 0.3; + sky += sunTint * glow * sunAmt; + + // --- Clouds ------------------------------------------------------------ + float freq = mix(2.2, 0.9, u_scale); + // Wind carries the field sideways and toward the viewer. + vec3 drift = vec3(t * 0.5, 0.0, t * 0.2); + float sigma = mix(10.0, 26.0, u_intensity); + vec3 sunLight = u_c3 * mix(1.12, 1.0, storm) * mix(vec3(1.0), sunTint, sunAmt * 0.5); + float phase = mix(phaseHG(mu, -0.2), phaseHG(mu, 0.7), 0.12); + + vec3 L = vec3(0.0); + float T = 1.0; + float hitAt = 0.0; + if (dir.y > 0.02) { + float t0 = BASE / dir.y; + if (t0 < FAR) { + float t1 = min(TOP / dir.y, t0 + 5.0); + float dt = (t1 - t0) / float(STEPS); + // Jitter the start per pixel so the steps don't band. + float tt = t0 + dt * hash(gl_FragCoord.xy); + for (int i = 0; i < STEPS; i++) { + vec3 p = dir * tt; + vec3 q = p * freq + drift; + float hf = (p.y - BASE) / (TOP - BASE); + float cov = coverageAt(q.xz); + float d = cov > 0.02 ? density(q, hf, cov, t, 1.0) : 0.0; + if (d > 0.003) { + if (hitAt == 0.0) hitAt = tt; + // March toward the sun for self-shadowing. + float od = 0.0; + for (int j = 0; j < LIGHT_STEPS; j++) { + vec3 lp = p + sunDir * (0.05 + float(j) * 0.12); + float lh = (lp.y - BASE) / (TOP - BASE); + od += density(lp * freq + drift, lh, cov, t, 0.0); + } + od *= 0.12 * sigma * 0.5; + // Beer–Lambert with a softer tail standing in for multiple scattering. + float beer = max(exp(-od), exp(-od * 0.25) * 0.6); + float powder = 1.0 - exp(-d * 5.0); + vec3 ambient = mix(u_c4 * mix(0.82, 0.62, storm), mix(u_c1, u_c3, 0.6), hf); + vec3 lum = sunLight * beer * phase * mix(1.0, powder, 0.35) + ambient * 0.72; + float dT = exp(-d * sigma * dt); + L += T * (1.0 - dT) * lum; + T *= dT; + if (T < 0.02) break; + } + tt += dt; + } + // Aerial perspective: distant clouds melt into the horizon haze. + float fog = 1.0 - exp(-max(hitAt, t0) * 0.05); + L = mix(L, haze * (1.0 - T), fog * 0.85); + } + } - vec3 belly = mix(u_c4, u_c1, 0.32) * 0.76; - vec3 body = mix(u_c4, u_c3, 0.58); - vec3 top = mix(u_c3, vec3(1.0), 0.52); - vec3 lit = mix(belly, body, smoothstep(0.12, 0.58, shade)); - lit = mix(lit, top, smoothstep(0.55, 0.98, shade) * 0.72); - lit = mix(lit, belly, (1.0 - shade) * 0.22 * density); + vec3 col = sky * T + L; - float alpha = density * mix(0.55, 1.12, clamp(u_intensity, 0.0, 1.0)); - alpha = clamp(alpha, 0.0, 1.0); - vec3 col = mix(sky, lit, alpha); + // Lightning, lit from inside the clouds it hits. + if (u_flash > 0.0) { + vec2 fp = vec2(u_flashPos.x * aspect, u_flashPos.y - 0.5); + float bloom = exp(-dot(s - fp, s - fp) * 3.2); + vec3 bolt = vec3(0.86, 0.9, 1.0); + col += bolt * u_flash * (bloom * (1.0 - T) * 0.95 + 0.05); + } if (u_glass > 0.5) { float pane = smoothstep(0.50, 0.22, length(glassLocal)); col *= mix(0.93, 1.0, pane); vec2 hl = glassLocal - vec2(-0.16, 0.18); col += exp(-dot(hl, hl) * 55.0) * 0.035; - col = mix(col, vec3(dot(col, vec3(0.299, 0.587, 0.114))), 0.04); - col += (hash(gl_FragCoord.xy) - 0.5) * 0.008; - } else { - col += (hash(gl_FragCoord.xy + fract(u_time)) - 0.5) * 0.008; + col = mix(col, vec3(luma(col)), 0.04); } + col += (hash(gl_FragCoord.xy + fract(u_time) * 91.0) - 0.5) * 0.008; gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0); } ` +/** Soft clouds hide resolution, so cap the canvas to keep the march cheap. */ +const MAX_PIXELS = 720_000 +const NOISE_SIZE = 256 + function isDev() { return ( typeof process !== "undefined" && process.env?.NODE_ENV !== "production" @@ -254,6 +328,36 @@ function hexToRgb(hex: string): [number, number, number] { return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255] } +/** + * Seeded random texture for the shader's value noise. Green repeats red + * offset by (37, 239), which lets one lookup serve two 3D noise slices. + * Seeded so every visit gets the same sky. + */ +function noiseTexels() { + const size = NOISE_SIZE + const data = new Uint8Array(size * size * 4) + let seed = 0x23d + const rand = () => { + seed = (seed + 0x6d2b79f5) | 0 + let r = Math.imul(seed ^ (seed >>> 15), 1 | seed) + r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r + return ((r ^ (r >>> 14)) >>> 0) / 4294967296 + } + for (let i = 0; i < size * size; i++) { + data[i * 4] = Math.floor(rand() * 256) + } + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + const from = ((y - 239) & (size - 1)) * size + ((x - 37) & (size - 1)) + const i = (y * size + x) * 4 + data[i + 1] = data[from * 4]! + data[i + 2] = 0 + data[i + 3] = 255 + } + } + return data +} + /** Resolves shadcn / next-themes dark mode (`attribute="class"` → `html.dark`). */ export function isDarkTheme(): boolean { if (typeof document === "undefined") return false @@ -291,8 +395,9 @@ function compile(gl: WebGLRenderingContext, type: number, source: string) { } /** - * WebGL sky for heroes — clear blue with drifting clouds in light, - * storm gray in dark. Optional window-glass film. + * WebGL sky for heroes — volumetric clouds drifting across clear blue in + * light, storm gray in dark, with optional lightning. Optional + * window-glass film. */ export function createShaderSky( canvas: HTMLCanvasElement, @@ -308,6 +413,7 @@ export function createShaderSky( | "scale" | "variation" | "interactive" + | "lightning" | "glass" | "glassSize" | "theme" @@ -321,6 +427,7 @@ export function createShaderSky( scale: 0.4, variation: 0.7, interactive: false, + lightning: false, glass: false, glassSize: 7, theme: "auto", @@ -384,6 +491,26 @@ export function createShaderSky( gl.enableVertexAttribArray(loc) gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0) + const noiseTex = gl.createTexture() + gl.activeTexture(gl.TEXTURE0) + gl.bindTexture(gl.TEXTURE_2D, noiseTex) + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + NOISE_SIZE, + NOISE_SIZE, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + noiseTexels() + ) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT) + gl.uniform1i(gl.getUniformLocation(program, "u_noise"), 0) + const uResolution = gl.getUniformLocation(program, "u_resolution") const uTime = gl.getUniformLocation(program, "u_time") const uSpeed = gl.getUniformLocation(program, "u_speed") @@ -399,21 +526,46 @@ export function createShaderSky( const uC3 = gl.getUniformLocation(program, "u_c3") const uC4 = gl.getUniformLocation(program, "u_c4") const uMouse = gl.getUniformLocation(program, "u_mouse") + const uFlash = gl.getUniformLocation(program, "u_flash") + const uFlashPos = gl.getUniformLocation(program, "u_flashPos") let raf = 0 let running = true const start = performance.now() let frozenTime = 0 + // One strike = a bright flicker and a softer echo, then 4–10s of quiet. + const strike = { at: 0, next: 0, x: 0, y: 0 } + const pulse = (s: number) => (s < 0 ? 0 : Math.exp(-s * 14)) + const flashAt = (now: number) => { + if (!options.lightning || reduce) { + strike.next = 0 + return 0 + } + if (strike.next === 0) strike.next = now + 1500 + Math.random() * 2500 + if (now >= strike.next) { + strike.at = now + strike.next = now + 4000 + Math.random() * 6000 + strike.x = (Math.random() - 0.5) * 0.9 + strike.y = 0.55 + Math.random() * 0.35 + } + if (strike.at === 0) return 0 + const s = (now - strike.at) / 1000 + return Math.min(1, pulse(s) + 0.65 * pulse(s - 0.16)) + } + + // Device pixels per CSS pixel actually rendered (after the pixel cap). + let pixelScale = 1 const resize = () => { const parent = canvas.parentElement if (!parent) return - const dpr = Math.min(window.devicePixelRatio || 1, 2) const w = parent.clientWidth const h = parent.clientHeight if (w <= 0 || h <= 0) return - canvas.width = Math.max(1, Math.floor(w * dpr)) - canvas.height = Math.max(1, Math.floor(h * dpr)) + const dpr = Math.min(window.devicePixelRatio || 1, 2) + pixelScale = Math.min(dpr, Math.sqrt(MAX_PIXELS / (w * h))) + canvas.width = Math.max(1, Math.floor(w * pixelScale)) + canvas.height = Math.max(1, Math.floor(h * pixelScale)) canvas.style.width = `${w}px` canvas.style.height = `${h}px` gl.viewport(0, 0, canvas.width, canvas.height) @@ -473,8 +625,7 @@ export function createShaderSky( mouse.x += (targetMouse.x - mouse.x) * 0.03 mouse.y += (targetMouse.y - mouse.y) * 0.03 - const dpr = Math.min(window.devicePixelRatio || 1, 2) - const glassPx = Math.max(2, options.glassSize ?? 7) * dpr + const glassPx = Math.max(2, options.glassSize ?? 7) * pixelScale gl.uniform2f(uResolution, canvas.width, canvas.height) gl.uniform1f(uTime, time) @@ -496,6 +647,9 @@ export function createShaderSky( options.interactive ? mouse.y : 0.5 ) + gl.uniform1f(uFlash, flashAt(now)) + gl.uniform2f(uFlashPos, strike.x, strike.y) + gl.drawArrays(gl.TRIANGLES, 0, 6) raf = requestAnimationFrame(tick) } @@ -519,6 +673,7 @@ export function createShaderSky( gl.deleteShader(vs) gl.deleteShader(fs) gl.deleteBuffer(buf) + gl.deleteTexture(noiseTex) }, } } diff --git a/registry/shader-sky/shader-sky.svelte b/registry/shader-sky/shader-sky.svelte index aae0acd..4e9845b 100644 --- a/registry/shader-sky/shader-sky.svelte +++ b/registry/shader-sky/shader-sky.svelte @@ -29,6 +29,7 @@ scale = 0.4, variation = 0.7, interactive = false, + lightning = false, glass = false, glassSize = 7, theme = "auto", @@ -66,6 +67,7 @@ scale, variation, interactive, + lightning, glass, glassSize, theme, @@ -95,6 +97,7 @@ scale, variation, interactive, + lightning, glass, glassSize, theme, @@ -107,8 +110,11 @@ diff --git a/registry/shader-sky/shader-sky.tsx b/registry/shader-sky/shader-sky.tsx index 835a861..1292f67 100644 --- a/registry/shader-sky/shader-sky.tsx +++ b/registry/shader-sky/shader-sky.tsx @@ -30,7 +30,7 @@ export type ShaderSkyProps = Omit & { /** * WebGL sky for heroes — clear blue with drifting clouds in light, - * storm gray in dark. Optional window-glass film. + * storm gray in dark, with optional lightning. Optional window-glass film. */ export function ShaderSky({ className, @@ -42,6 +42,7 @@ export function ShaderSky({ scale = 0.4, variation = 0.7, interactive = false, + lightning = false, glass = false, glassSize = 7, theme = "auto", @@ -78,6 +79,7 @@ export function ShaderSky({ scale, variation, interactive, + lightning, glass, glassSize, theme, @@ -101,6 +103,7 @@ export function ShaderSky({ scale, variation, interactive, + lightning, glass, glassSize, theme, @@ -115,6 +118,7 @@ export function ShaderSky({ scale, variation, interactive, + lightning, glass, glassSize, theme, @@ -127,7 +131,7 @@ export function ShaderSky({ data-slot="shader-sky" aria-hidden className={cn( - "pointer-events-none absolute inset-0 overflow-hidden", + "pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit] [mask-image:linear-gradient(#000,#000)]", className )} style={{ @@ -135,7 +139,13 @@ export function ShaderSky({ backgroundImage: fallback.backgroundImage, }} > - + {/* Firefox can hand an opaque WebGL canvas straight to the system + compositor, which ignores rounded clips. The mask on the root keeps + it in the page's own layer, so the parent's radius holds. */} +
) } diff --git a/registry/stretchy-footer/registry.json b/registry/stretchy-footer/registry.json index 966ca53..a088a7f 100644 --- a/registry/stretchy-footer/registry.json +++ b/registry/stretchy-footer/registry.json @@ -6,7 +6,6 @@ "type": "registry:ui", "title": "Stretchy Footer", "description": "Dia-style rubber overscroll — rainbow aurora stretches up past the bottom, then snaps back.", - "dependencies": ["motion"], "files": [ { "path": "stretchy-footer.tsx", diff --git a/registry/stretchy-footer/stretchy-footer-demo.tsx b/registry/stretchy-footer/stretchy-footer-demo.tsx index 0ed426a..f7a2ec5 100644 --- a/registry/stretchy-footer/stretchy-footer-demo.tsx +++ b/registry/stretchy-footer/stretchy-footer-demo.tsx @@ -7,27 +7,58 @@ import { ComponentControls, ControlColors, ControlSlider, + ControlSwitch, } from "@/components/component-controls" import { ComponentPreview } from "@/components/component-preview" import { Button } from "@/components/ui/button" import { usePreviewProps } from "@/hooks/use-preview-props" -import { - playStretchyFooterDemo, - StretchyFooter, -} from "@/registry/stretchy-footer/stretchy-footer" import { DEFAULT_BLUR, DEFAULT_COLORS, DEFAULT_COLUMNS, DEFAULT_GLOW, -} from "@/registry/stretchy-footer/stretchy-footer-vanilla" + DEFAULT_MAX_STRETCH, + DEFAULT_STIFFNESS, + playStretchyFooterDemo, + StretchyFooter, +} from "@/registry/stretchy-footer/stretchy-footer" const PREVIEW_DEMO_ID = "stretchy-footer-preview" -const SPECTRUM = ["#FF3B30", "#FFCC00", "#34C759", "#007AFF", "#AF52DE"] -const SUNSET = ["#FF4D00", "#FF8A5B", "#FFC857", "#E63946", "#9B2226"] -const OCEAN = ["#012A4A", "#01497C", "#2A9D8F", "#48CAE4", "#90E0EF"] -const NEON = ["#F72585", "#B5179E", "#7209B7", "#4361EE", "#4CC9F0"] +// Gradient stops, top of each column to the floor — nine, like the default. +const SUNSET = [ + "#FFE8A3", + "#FFD166", + "#FFA94D", + "#FF7B3D", + "#FF3D6E", + "#D6246E", + "#B5179E", + "#3A0CA3", + "#10002B", +] +const OCEAN = [ + "#F1FCFD", + "#CAF0F8", + "#90E0EF", + "#48CAE4", + "#00B4D8", + "#0096C7", + "#0077B6", + "#023E8A", + "#03045E", +] +const NEON = [ + "#B8F2FF", + "#4CC9F0", + "#4895EF", + "#4361EE", + "#7209B7", + "#B5179E", + "#F72585", + "#FF9E00", + "#1A0B2E", +] function norm(hex: string) { return hex.trim().toUpperCase() @@ -46,12 +77,14 @@ export function StretchyFooterDemo() { const defaults = useMemo( () => ({ - colors: SPECTRUM, + colors: DEFAULT_COLORS, maxStretch: 220, columns: DEFAULT_COLUMNS, - stiffness: 380, + stiffness: DEFAULT_STIFFNESS, blur: DEFAULT_BLUR, glow: DEFAULT_GLOW, + flip: false, + rotated: false, }), [] ) @@ -82,7 +115,6 @@ export function StretchyFooterDemo() { >
updateProp("colors", colors)} /> updateProp("columns", v)} /> updateProp("stiffness", v)} /> + updateProp("flip", v)} + /> + updateProp("rotated", v)} + /> ) diff --git a/registry/stretchy-footer/stretchy-footer-spring-vanilla.ts b/registry/stretchy-footer/stretchy-footer-spring-vanilla.ts deleted file mode 100644 index 40823aa..0000000 --- a/registry/stretchy-footer/stretchy-footer-spring-vanilla.ts +++ /dev/null @@ -1,112 +0,0 @@ -export type SpringOptions = { - stiffness: number - damping: number - mass: number - restDelta?: number - restSpeed?: number - /** Start position. Default `0`. */ - initial?: number -} - -export type Spring = { - set: (value: number) => void - get: () => number - onChange: (fn: (value: number) => void) => () => void - destroy: () => void -} - -/** - * Damped spring for the Svelte port (React keeps motion springs). - * Semi-implicit Euler with a 60 Hz substep. - */ -export function createSpring({ - stiffness, - damping, - mass, - restDelta = 0.2, - restSpeed = 0.2, - initial = 0, -}: SpringOptions): Spring { - let current = initial - let target = initial - let velocity = 0 - let raf = 0 - let lastTime = 0 - const listeners = new Set<(value: number) => void>() - - function emit() { - for (const fn of listeners) fn(current) - } - - function integrate(dt: number) { - const springForce = -stiffness * (current - target) - const dampingForce = -damping * velocity - const accel = (springForce + dampingForce) / mass - velocity += accel * dt - current += velocity * dt - } - - function tick(now: number) { - if (!lastTime) lastTime = now - let remaining = Math.min((now - lastTime) / 1000, 0.064) - lastTime = now - - const step = 1 / 60 - while (remaining > 0) { - integrate(Math.min(step, remaining)) - remaining -= step - } - - const settled = - Math.abs(current - target) < restDelta && Math.abs(velocity) < restSpeed - - if (settled) { - current = target - velocity = 0 - raf = 0 - lastTime = 0 - emit() - return - } - - emit() - raf = requestAnimationFrame(tick) - } - - function start() { - if (!raf) { - lastTime = 0 - raf = requestAnimationFrame(tick) - } - } - - return { - set(value) { - target = value - if ( - Math.abs(current - target) < restDelta && - Math.abs(velocity) < restSpeed - ) { - current = target - velocity = 0 - emit() - return - } - start() - }, - get() { - return current - }, - onChange(fn) { - listeners.add(fn) - return () => { - listeners.delete(fn) - } - }, - destroy() { - if (raf) cancelAnimationFrame(raf) - raf = 0 - listeners.clear() - }, - } -} diff --git a/registry/stretchy-footer/stretchy-footer-vanilla.ts b/registry/stretchy-footer/stretchy-footer-vanilla.ts index 82bf9ea..c992ef5 100644 --- a/registry/stretchy-footer/stretchy-footer-vanilla.ts +++ b/registry/stretchy-footer/stretchy-footer-vanilla.ts @@ -1,28 +1,91 @@ +/** Gradient stops, top to bottom: magenta through sunlight to deep navy. */ export const DEFAULT_COLORS = [ - "#FF3B30", - "#FF9500", - "#FFCC00", - "#34C759", - "#00C7BE", - "#32ADE6", - "#007AFF", - "#5856D6", - "#AF52DE", - "#FF2D55", + "#FF1AE6", + "#FF2E5E", + "#FF8A1A", + "#FFC81F", + "#E2E6EC", + "#8DB6E6", + "#2F74DC", + "#1459E3", + "#1C2566", ] -export const DEFAULT_COLUMNS = 48 -/** Gaussian blur on the aurora bars, in px. */ -export const DEFAULT_BLUR = 14 -/** White floor bloom opacity (0–1). */ -export const DEFAULT_GLOW = 0.22 -export const WHEEL_IDLE_MS = 90 -export const PULL_GAIN = 0.7 -export const POP_BOOST = 1.45 +export const DEFAULT_MAX_STRETCH = 280 +export const DEFAULT_COLUMNS = 9 +/** Gaussian blur on the aurora, in px. */ +export const DEFAULT_BLUR = 16 +/** White floor bloom opacity (0–1). Off by default. */ +export const DEFAULT_GLOW = 0 +export const DEFAULT_STIFFNESS = 380 +export const DEFAULT_DAMPING = 32 /** Docs / demos — dispatch to play the rubber stretch without faking wheel input. */ export const STRETCHY_FOOTER_PLAY = "stretchy-footer:play" +/** Stretch per px of overscroll at rest; it falls off toward `maxStretch`. */ +const RUBBER = 0.9 +/** Wheel silence that counts as letting go, in ms. */ +const RELEASE_MS = 110 +/** After letting go, wheel events closer together than this are momentum. */ +const MOMENTUM_GAP_MS = 180 +/** Consecutive shrinking wheel deltas that read as a trackpad coasting. */ +const COAST_EVENTS = 6 +const MASS = 0.35 +const REST = 0.15 +/** Edge columns stand this share of the centre one; the rest step evenly. */ +const EDGE_COLUMN = 0.55 +/** The top of each column fades in over this share of its height. */ +const TOP_FADE = 0.12 +/** Field opacity ramps in over this share of `maxStretch`. */ +const FADE_IN = 0.08 +/** Physics substep, in seconds. */ +const STEP = 1 / 120 + +export type StretchyFooterOptions = { + /** Peak stretch in px. Default `280`. */ + maxStretch?: number + /** + * Gradient stops from the top of each column down to the floor, any CSS + * colors. Every column carries the whole gradient, squeezed to its height. + */ + colors?: string[] + /** Spring stiffness. Default `380`. */ + stiffness?: number + /** Spring damping. Default `32`. */ + damping?: number + /** How many aurora columns, stepping down from the middle. Default `9`. */ + columns?: number + /** Gaussian blur on the aurora, in px. Default `16`. */ + blur?: number + /** White floor bloom opacity (0–1). Default `0`. */ + glow?: number + /** + * Invert the pyramid: the edge columns stand tallest and the middle + * dips, so the wobble ripples in from the sides. Default `false`. + */ + flip?: boolean + /** + * `180` turns the aurora upside down in place: the columns hang from the + * lifted page's edge and the gradient runs floor to top. Default `0`. + */ + rotate?: 0 | 180 + /** + * Optional id for docs demos. `playStretchyFooterDemo({ target })` only + * plays footers whose `demoId` matches. + */ + demoId?: string +} + +export type StretchyFooterMount = { + /** Canvas the aurora paints into. Its parent sets the width. */ + canvas: HTMLCanvasElement + /** Where overscroll is read: a scroll container, or `window`. */ + scroller: HTMLElement | Window + /** Lifts with the stretch: an element, or a selector looked up when needed. */ + content?: HTMLElement | string | null +} + export type StretchyFooterPlayDetail = { /** Peak pull as a fraction of `maxStretch`. Default `0.82`. */ amount?: number @@ -34,31 +97,25 @@ export type StretchyFooterPlayDetail = { scrollRoot?: HTMLElement | null } -export async function scrollToEnd(root?: HTMLElement | null) { - if (root) { - const max = Math.max(0, root.scrollHeight - root.clientHeight) - root.scrollTo({ top: max, behavior: "smooth" }) - const started = performance.now() - while (performance.now() - started < 2000) { - if (root.scrollTop >= max - 2) break - await new Promise((r) => window.setTimeout(r, 32)) - } - root.scrollTo({ top: max }) - return - } +export type StretchyFooterInstance = { + setOptions: (options: Partial) => void + /** Stretch and snap back, even under `prefers-reduced-motion`. */ + play: (detail?: Pick) => void + destroy: () => void +} - const max = Math.max( - 0, - document.documentElement.scrollHeight - window.innerHeight - ) - window.scrollTo({ top: max, behavior: "smooth" }) +export async function scrollToEnd(root?: HTMLElement | null) { + const el = root ?? document.documentElement + const max = () => Math.max(0, el.scrollHeight - el.clientHeight) + const top = () => (root ? root.scrollTop : window.scrollY) + const scroll = (behavior: ScrollBehavior) => + (root ?? window).scrollTo({ top: max(), behavior }) + scroll("smooth") const started = performance.now() - while (performance.now() - started < 2000) { - const top = window.scrollY || document.documentElement.scrollTop - if (top >= max - 2) break + while (performance.now() - started < 2000 && top() < max() - 2) { await new Promise((r) => window.setTimeout(r, 32)) } - window.scrollTo({ top: max }) + scroll("instant") } /** Scroll to the end, then play the stretch on matching footers. */ @@ -66,55 +123,510 @@ export async function playStretchyFooterDemo( detail: StretchyFooterPlayDetail = {} ) { if (typeof window === "undefined") return - await scrollToEnd(detail.scrollRoot) - window.dispatchEvent( - new CustomEvent(STRETCHY_FOOTER_PLAY, { - detail, - }) + new CustomEvent(STRETCHY_FOOTER_PLAY, { detail }) + ) + // Travel out, hold, spring home. + await new Promise((r) => + window.setTimeout(r, (detail.holdMs ?? 700) + 900) ) - - const hold = detail.holdMs ?? 700 - // Travel out + hold + spring home - await new Promise((r) => window.setTimeout(r, hold + 900)) } -export function columnScale(index: number, count: number): number { +/** + * Stepped pyramid: the middle column is tallest, each step out is shorter. + * Flipped, the edges are tallest and each step in is shorter. + */ +export function columnScale( + index: number, + count: number, + flip = false +): number { if (count <= 1) return 1 - const t = (index / (count - 1)) * 2 - 1 - return Math.exp(-t * t * 2.2) + const t = Math.abs((index / (count - 1)) * 2 - 1) + return 1 - (1 - EDGE_COLUMN) * (flip ? 1 - t : t) +} + +/** Overscroll distance → stretch. Linear at first, never past `max`. */ +export function rubberBand(distance: number, max: number): number { + if (max <= 0 || distance <= 0) return 0 + return max * (1 - 1 / ((distance * RUBBER) / max + 1)) +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)) +} + +function numberOr(value: unknown, fallback: number, min: number, max: number) { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback + return clamp(value, min, max) } -/** Stable rgba() for SSR — avoids hex/rgb serialization mismatches. */ -export function hexToRgba(hex: string, alpha: number): string { - const raw = hex.replace("#", "") - const full = - raw.length === 3 - ? raw - .split("") - .map((c) => c + c) - .join("") - : raw.slice(0, 6) - const r = Number.parseInt(full.slice(0, 2), 16) - const g = Number.parseInt(full.slice(2, 4), 16) - const b = Number.parseInt(full.slice(4, 6), 16) - return `rgba(${r}, ${g}, ${b}, ${alpha})` +/** Wheel delta in px, whatever unit the device reports in. */ +function wheelPixels(event: WheelEvent) { + if (event.deltaMode === 1) return event.deltaY * 16 + if (event.deltaMode === 2) return event.deltaY * window.innerHeight + return event.deltaY } -export function applyResistance(current: number, delta: number, max: number): number { - if (max <= 0) return 0 - const t = Math.min(1, Math.max(0, current / max)) - const falloff = (1 - t) * (1 - t * 0.35) - const pop = current < 40 ? POP_BOOST : 1 - return Math.min(max, Math.max(0, current + delta * PULL_GAIN * falloff * pop)) +/** + * True when something between `target` and the scroller can still scroll + * in that direction — a code block or a menu gets the wheel, not us. + */ +function nestedCanScroll( + target: EventTarget | null, + root: Element, + dy: number +) { + let el = target instanceof Element ? target : null + while (el && el !== root && el !== document.documentElement) { + if (el.scrollHeight > el.clientHeight + 1) { + const overflow = getComputedStyle(el).overflowY + if (overflow === "auto" || overflow === "scroll") { + const room = + dy > 0 + ? el.scrollHeight - el.clientHeight - el.scrollTop + : el.scrollTop + if (room > 1) return true + } + } + el = el.parentElement + } + return false } -export function elementAtBottom(el: HTMLElement, slack = 1): boolean { - return el.scrollTop + el.clientHeight >= el.scrollHeight - slack +const STRIP = 256 + +/** + * One column's gradient, `colors` spread from just under the top to the + * floor, with the top fading in. Columns stretch it to their own height. + */ +function makeStrip(colors: string[]) { + const strip = document.createElement("canvas") + strip.width = 1 + strip.height = STRIP + const g = strip.getContext("2d") + if (!g) return strip + const bands = g.createLinearGradient(0, 0, 0, STRIP) + colors.forEach((color, i) => { + const at = colors.length > 1 ? i / (colors.length - 1) : 1 + bands.addColorStop(TOP_FADE * 0.8 + (1 - TOP_FADE * 0.8) * at, color) + }) + g.fillStyle = bands + g.fillRect(0, 0, 1, STRIP) + const fade = g.createLinearGradient(0, 0, 0, STRIP * TOP_FADE) + fade.addColorStop(0, "rgba(0,0,0,0)") + fade.addColorStop(1, "rgba(0,0,0,1)") + g.globalCompositeOperation = "destination-in" + g.fillStyle = fade + g.fillRect(0, 0, 1, STRIP) + return strip } -export function windowAtBottom(slack = 1): boolean { - const doc = document.documentElement - return window.scrollY + window.innerHeight >= doc.scrollHeight - slack +/** Half a white radial glow, flat side down, for the floor bloom. */ +function makeBloom() { + const bloom = document.createElement("canvas") + bloom.width = 128 + bloom.height = 64 + const g = bloom.getContext("2d") + if (!g) return bloom + const grad = g.createRadialGradient(64, 64, 0, 64, 64, 64) + grad.addColorStop(0, "rgba(255,255,255,1)") + grad.addColorStop(1, "rgba(255,255,255,0)") + g.fillStyle = grad + g.fillRect(0, 0, 128, 64) + return bloom +} + +type Spring = { x: number; v: number; target: number } + +function stepSpring(s: Spring, k: number, c: number, dt: number) { + const accel = (-k * (s.x - s.target) - c * s.v) / MASS + s.v += accel * dt + s.x += s.v * dt +} + +const atRest = (s: Spring) => + Math.abs(s.x - s.target) < REST && Math.abs(s.v) < REST + +/** + * Dia-style rubber overscroll. Pull past the bottom of `scroller` and the + * content lifts on a spring while a stepped pyramid of blurred gradient + * columns stretches up from the floor (a valley with `flip`, hanging from + * the page with `rotate: 180`); each column + * has its own spring, so the edge wobbles as it snaps back. Idle unless + * stretched. + */ +export function createStretchyFooter( + mount: StretchyFooterMount, + initial: StretchyFooterOptions = {} +): StretchyFooterInstance | null { + const { canvas, scroller } = mount + const ctx = canvas.getContext("2d") + if (!ctx) return null + + let options: StretchyFooterOptions = { ...initial } + const maxOf = () => numberOr(options.maxStretch, DEFAULT_MAX_STRETCH, 0, 4000) + const blurOf = () => numberOr(options.blur, DEFAULT_BLUR, 0, 64) + const glowOf = () => numberOr(options.glow, DEFAULT_GLOW, 0, 1) + const stiffnessOf = () => + numberOr(options.stiffness, DEFAULT_STIFFNESS, 10, 4000) + const dampingOf = () => numberOr(options.damping, DEFAULT_DAMPING, 0, 400) + const countOf = () => + Math.round(numberOr(options.columns, DEFAULT_COLUMNS, 3, 96)) + /** Overscroll the user has put in, in px. The stretch is its rubber band. */ + let distance = 0 + /** `play()` holds the stretch here, overriding the gesture. */ + let forced: number | null = null + const lift: Spring = { x: 0, v: 0, target: 0 } + let columns: (Spring & { share: number })[] = [] + let strip = document.createElement("canvas") + const bloom = makeBloom() + + const rebuildColumns = () => { + const count = countOf() + const target = lift.target + columns = Array.from({ length: count }, (_, i) => { + const share = columnScale(i, count, options.flip) + return { x: lift.x * share, v: 0, target: target * share, share } + }) + } + const rebuildStrip = () => { + strip = makeStrip(options.colors?.length ? options.colors : DEFAULT_COLORS) + } + + const size = { w: 0, pad: 0, scale: 1 } + const layout = () => { + const parent = canvas.parentElement + const blur = blurOf() + const max = maxOf() + // Room past the edges, so the blur doesn't fade the sides and floor. + const pad = Math.ceil(blur * 2) + // Blurred anyway — half resolution is plenty and half the fill. + const scale = blur >= 6 ? 0.5 : Math.min(window.devicePixelRatio || 1, 2) + size.w = (parent?.clientWidth ?? 0) + pad * 2 + size.pad = pad + size.scale = scale + canvas.width = Math.max(1, Math.ceil(size.w * scale)) + canvas.height = Math.max(1, Math.ceil((max + pad) * scale)) + Object.assign(canvas.style, { + position: "absolute", + left: `${-pad}px`, + bottom: `${-pad}px`, + width: `${size.w}px`, + height: `${max + pad}px`, + filter: blur > 0 ? `blur(${blur}px)` : "", + pointerEvents: "none", + }) + draw() + } + + // The page element that rides the stretch. + let lifted: HTMLElement | null = null + let liftedTransform = "" + const contentEl = () => { + const content = mount.content + if (!content) return null + if (typeof content !== "string") return content + if (lifted?.isConnected && lifted.matches(content)) return lifted + const scope = scroller instanceof HTMLElement ? scroller : document + return ( + scope.querySelector(content) ?? + document.querySelector(content) + ) + } + const liftContent = (y: number) => { + const el = contentEl() + if (el !== lifted) { + if (lifted) lifted.style.transform = liftedTransform + lifted = el + liftedTransform = el?.style.transform ?? "" + } + if (!lifted) return + if (y > 0.2) { + lifted.style.transform = `translate3d(0, ${-y}px, 0)` + lifted.style.willChange = "transform" + } else { + lifted.style.transform = liftedTransform + lifted.style.willChange = "" + } + } + + function draw() { + const stretch = Math.max(0, lift.x) + liftContent(stretch) + const max = maxOf() + const visible = stretch > 0.2 || columns.some((column) => column.x > 0.2) + canvas.style.visibility = visible ? "visible" : "hidden" + if (!visible) return + + const { w, pad, scale } = size + const floor = max * scale + const bottom = canvas.height + ctx!.setTransform(1, 0, 0, 1, 0, 0) + ctx!.clearRect(0, 0, canvas.width, canvas.height) + ctx!.globalAlpha = clamp(stretch / Math.max(1, max * FADE_IN), 0, 1) + // Rotated, spin the drawing 180° about the gap between the lifted page + // and the floor, so the columns hang from the page's edge. + const rotated = options.rotate === 180 + if (rotated) { + ctx!.setTransform(-1, 0, 0, -1, canvas.width, 2 * floor - stretch * scale) + } + + // Columns span the visible width; the outer two run on into the pad. + const count = columns.length + const inner = (w - pad * 2) * scale + const edge = (i: number) => + i <= 0 + ? 0 + : i >= count + ? canvas.width + : Math.round(pad * scale + (inner * i) / count) + for (let i = 0; i < count; i++) { + const h = Math.max(0, columns[i]!.x) * scale + if (h < 0.5) continue + const x0 = edge(i) + const x1 = edge(i + 1) + ctx!.drawImage(strip, x0, floor - h, x1 - x0, h) + // The run-off under the floor would land on the page once rotated. + if (rotated) continue + ctx!.drawImage( + strip, + 0, + STRIP - 1, + 1, + 1, + x0, + floor, + x1 - x0, + bottom - floor + ) + } + + const glow = glowOf() + if (glow > 0) { + const rx = inner * 0.49 + const ry = stretch * 0.35 * scale + ctx!.globalAlpha *= glow + ctx!.drawImage(bloom, w * scale * 0.5 - rx, floor - ry, rx * 2, ry) + } + ctx!.globalAlpha = 1 + ctx!.setTransform(1, 0, 0, 1, 0, 0) + } + + let raf = 0 + let last = 0 + const retarget = () => { + const max = maxOf() + const target = forced ?? rubberBand(distance, max) + lift.target = target + for (const column of columns) column.target = target * column.share + if (!raf) { + last = performance.now() + raf = requestAnimationFrame(frame) + } + } + + function frame(now: number) { + raf = 0 + const dt = Math.min(0.05, Math.max(0, (now - last) / 1000)) + last = now + const k = stiffnessOf() + const c = dampingOf() + const steps = Math.max(1, Math.ceil(dt / STEP)) + for (let n = 0; n < steps; n++) { + stepSpring(lift, k, c, dt / steps) + for (const column of columns) { + // Short edge columns are softer and bouncier than the middle, so the + // top edge ripples out from the centre. + const ck = k * (0.6 + 0.4 * column.share) + stepSpring(column, ck, c * 0.5 * Math.sqrt(ck / k), dt / steps) + } + } + const settled = atRest(lift) && columns.every(atRest) + if (settled) { + lift.x = lift.target + lift.v = 0 + for (const column of columns) { + column.x = column.target + column.v = 0 + } + } + draw() + if (!settled) raf = requestAnimationFrame(frame) + } + + // Gestures. + const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)") + let reduce = mqReduce.matches + let releaseTimer = 0 + let holdTimer = 0 + let lastWheel = 0 + let lastDelta = 0 + let coasting = 0 + let cooling = false + let lastTouchY = 0 + + const root = + scroller instanceof HTMLElement ? scroller : document.documentElement + const atBottom = () => + root.scrollTop + root.clientHeight >= root.scrollHeight - 2 + + const release = () => { + window.clearTimeout(releaseTimer) + releaseTimer = 0 + if (distance <= 0) return + distance = 0 + cooling = true + retarget() + } + const stopPlay = () => { + if (forced === null) return + window.clearTimeout(holdTimer) + forced = null + retarget() + } + + const onWheel = (event: WheelEvent) => { + if (reduce || event.ctrlKey) return + const dy = wheelPixels(event) + if (dy === 0 || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return + stopPlay() + const now = performance.now() + const gap = now - lastWheel + lastWheel = now + + if (distance > 0) { + event.preventDefault() + // A trackpad coasting out: deltas shrink every event. Treat it as + // letting go instead of holding the stretch until momentum ends. + coasting = dy > 0 && Math.abs(dy) < Math.abs(lastDelta) ? coasting + 1 : 0 + lastDelta = dy + if (coasting >= COAST_EVENTS) { + release() + return + } + distance = Math.max(0, distance + dy) + retarget() + window.clearTimeout(releaseTimer) + if (distance > 0) releaseTimer = window.setTimeout(release, RELEASE_MS) + return + } + + // Momentum still arriving after a release shouldn't pull again. + if (cooling) { + if (gap < MOMENTUM_GAP_MS) return + cooling = false + } + if (dy > 0 && atBottom() && !nestedCanScroll(event.target, root, dy)) { + event.preventDefault() + distance = dy + lastDelta = dy + coasting = 0 + retarget() + releaseTimer = window.setTimeout(release, RELEASE_MS) + } + } + + const onTouchStart = (event: TouchEvent) => { + const touch = event.touches[0] + if (!touch) return + stopPlay() + cooling = false + lastTouchY = touch.clientY + } + const onTouchMove = (event: TouchEvent) => { + const touch = event.touches[0] + if (!touch || reduce) return + const dy = lastTouchY - touch.clientY + lastTouchY = touch.clientY + if ( + distance > 0 || + (dy > 0 && atBottom() && !nestedCanScroll(event.target, root, dy)) + ) { + if (event.cancelable) event.preventDefault() + distance = Math.max(0, distance + dy) + retarget() + } + } + const onTouchEnd = () => release() + + const onPlayEvent = (event: Event) => { + const detail = (event as CustomEvent).detail ?? {} + // Scoped footers only answer their own demo; unscoped ones, broadcasts. + if ((detail.target ?? null) !== (options.demoId ?? null)) return + instance.play(detail) + } + + const onReduce = () => { + reduce = mqReduce.matches + if (reduce) release() + } + + const target: HTMLElement | Window = scroller + const prevOverscroll = root.style.overscrollBehaviorY + // The browser's own rubber band would fight ours. + root.style.overscrollBehaviorY = "none" + target.addEventListener("wheel", onWheel as EventListener, { passive: false }) + target.addEventListener("touchstart", onTouchStart as EventListener, { + passive: true, + }) + target.addEventListener("touchmove", onTouchMove as EventListener, { + passive: false, + }) + target.addEventListener("touchend", onTouchEnd) + target.addEventListener("touchcancel", onTouchEnd) + window.addEventListener(STRETCHY_FOOTER_PLAY, onPlayEvent) + mqReduce.addEventListener("change", onReduce) + const ro = new ResizeObserver(layout) + if (canvas.parentElement) ro.observe(canvas.parentElement) + + rebuildStrip() + rebuildColumns() + layout() + + const instance: StretchyFooterInstance = { + setOptions(next) { + const prevColors = options.colors + const prevMax = maxOf() + const prevBlur = blurOf() + const prevFlip = Boolean(options.flip) + options = { ...options, ...next } + if (options.colors !== prevColors) rebuildStrip() + if (countOf() !== columns.length || Boolean(options.flip) !== prevFlip) { + rebuildColumns() + } + if (maxOf() !== prevMax || blurOf() !== prevBlur) layout() + if (forced !== null) forced = Math.min(forced, maxOf()) + retarget() + }, + play({ amount = 0.82, holdMs = 700 } = {}) { + window.clearTimeout(releaseTimer) + window.clearTimeout(holdTimer) + distance = 0 + forced = maxOf() * clamp(amount, 0, 1) + retarget() + holdTimer = window.setTimeout(stopPlay, holdMs) + }, + destroy() { + cancelAnimationFrame(raf) + raf = 0 + window.clearTimeout(releaseTimer) + window.clearTimeout(holdTimer) + root.style.overscrollBehaviorY = prevOverscroll + target.removeEventListener("wheel", onWheel as EventListener) + target.removeEventListener("touchstart", onTouchStart as EventListener) + target.removeEventListener("touchmove", onTouchMove as EventListener) + target.removeEventListener("touchend", onTouchEnd) + target.removeEventListener("touchcancel", onTouchEnd) + window.removeEventListener(STRETCHY_FOOTER_PLAY, onPlayEvent) + mqReduce.removeEventListener("change", onReduce) + ro.disconnect() + if (lifted) { + lifted.style.transform = liftedTransform + lifted.style.willChange = "" + } + }, + } + return instance } diff --git a/registry/stretchy-footer/stretchy-footer.svelte b/registry/stretchy-footer/stretchy-footer.svelte index 64336d2..910ee00 100644 --- a/registry/stretchy-footer/stretchy-footer.svelte +++ b/registry/stretchy-footer/stretchy-footer.svelte @@ -2,30 +2,25 @@ {#snippet aurora()} @@ -396,42 +134,18 @@ class="pointer-events-none absolute inset-x-0 bottom-0 z-20 overflow-hidden" style="height: {maxStretch}px;" > -
-
- {#each bars as bar (bar.key)} -
- {/each} -
- -
-
+
{/snippet} {#if windowScroll} {#if mounted} +
{@render aurora()} @@ -440,27 +154,27 @@ {:else if scrollEl}
{@render aurora()}
{:else} +
- {@render children?.()} +
+ {@render children?.()} +
{@render aurora()}
diff --git a/registry/stretchy-footer/stretchy-footer.tsx b/registry/stretchy-footer/stretchy-footer.tsx index 49fc4a2..2d28fb9 100644 --- a/registry/stretchy-footer/stretchy-footer.tsx +++ b/registry/stretchy-footer/stretchy-footer.tsx @@ -2,48 +2,46 @@ import { useEffect, - useMemo, useRef, - useState, + useSyncExternalStore, type ReactNode, type RefObject, } from "react" import { createPortal } from "react-dom" -import { - motion, - useMotionValue, - useMotionValueEvent, - useReducedMotion, - useSpring, - useTransform, -} from "motion/react" import { cn } from "@/lib/utils" import { - applyResistance, - columnScale, + createStretchyFooter, DEFAULT_BLUR, DEFAULT_COLORS, DEFAULT_COLUMNS, + DEFAULT_DAMPING, DEFAULT_GLOW, - elementAtBottom, - hexToRgba, - POP_BOOST, - PULL_GAIN, - STRETCHY_FOOTER_PLAY, - WHEEL_IDLE_MS, - windowAtBottom, - type StretchyFooterPlayDetail, + DEFAULT_MAX_STRETCH, + DEFAULT_STIFFNESS, + type StretchyFooterInstance, + type StretchyFooterOptions, } from "./stretchy-footer-vanilla" export { + DEFAULT_BLUR, + DEFAULT_COLORS, + DEFAULT_COLUMNS, + DEFAULT_DAMPING, + DEFAULT_GLOW, + DEFAULT_MAX_STRETCH, + DEFAULT_STIFFNESS, playStretchyFooterDemo, STRETCHY_FOOTER_PLAY, } from "./stretchy-footer-vanilla" -export type { StretchyFooterPlayDetail } from "./stretchy-footer-vanilla" +export type { + StretchyFooterInstance, + StretchyFooterOptions, + StretchyFooterPlayDetail, +} from "./stretchy-footer-vanilla" -export type StretchyFooterProps = { +export type StretchyFooterProps = StretchyFooterOptions & { className?: string /** * Page content inside the overflow scroller. @@ -52,13 +50,13 @@ export type StretchyFooterProps = { children?: ReactNode /** * External scroll container. When set, this component only paints the - * aurora overlay and binds overscroll listeners to that element. + * aurora overlay and binds overscroll to that element. * When omitted (and `windowScroll` is false), this component *is* the scroller. */ scrollRef?: RefObject /** * Bind overscroll to the window and paint a fixed bottom aurora. - * Use on full pages (e.g. the component docs) instead of a nested scroller. + * Use on full pages instead of a nested scroller. */ windowScroll?: boolean /** @@ -66,37 +64,16 @@ export type StretchyFooterProps = { * Used with `windowScroll` / `scrollRef`. Default `[data-stretchy-page]`. */ contentSelector?: string - /** Peak stretch in px. Default `280`. */ - maxStretch?: number - /** Spectrum stops across the aurora columns. */ - colors?: string[] - /** Spring stiffness. Default `380`. */ - stiffness?: number - /** Spring damping. Default `32`. */ - damping?: number - /** How many vertical aurora bars. Default `48`. */ - columns?: number - /** Gaussian blur on the aurora bars, in px. Default `14`. */ - blur?: number - /** White floor bloom opacity (0–1). Default `0.22`. */ - glow?: number - /** Accessible label for the decorative field. */ + /** Accessible label for the scroll region. */ label?: string - /** - * Optional id for docs demos. `playStretchyFooterDemo({ target })` only - * animates footers whose `demoId` matches. - */ - demoId?: string } -type OverscrollTarget = - | { kind: "element"; el: HTMLElement } - | { kind: "window" } +const noopSubscribe = () => () => {} /** * Dia-style stretchy overflow — overscroll past the bottom and a rainbow - * field scaleY-stretches from the floor while the page lifts with it. - * GPU transforms only (no height animation) for a clean, snappy rubber band. + * aurora stretches up from the floor while the page lifts with it, then + * springs back with a wobble. Honors `prefers-reduced-motion`. */ export function StretchyFooter({ className, @@ -104,291 +81,86 @@ export function StretchyFooter({ scrollRef, windowScroll = false, contentSelector = "[data-stretchy-page]", - maxStretch = 280, + maxStretch = DEFAULT_MAX_STRETCH, colors = DEFAULT_COLORS, - stiffness = 380, - damping = 32, + stiffness = DEFAULT_STIFFNESS, + damping = DEFAULT_DAMPING, columns = DEFAULT_COLUMNS, blur = DEFAULT_BLUR, glow = DEFAULT_GLOW, + flip = false, + rotate = 0, label = "Stretchy overflow", demoId, }: StretchyFooterProps) { - const reduceMotion = useReducedMotion() ?? false - const internalRef = useRef(null) - const pull = useMotionValue(0) - const stretch = useSpring(pull, { - stiffness, - damping, - mass: 0.35, - restDelta: 0.2, - restSpeed: 0.2, - }) - const touchStartY = useRef(0) - const touchStretch0 = useRef(0) - const wheelTimer = useRef | null>(null) - const demoTimer = useRef | null>(null) - const demoPlayingRef = useRef(false) - const rolledEl = useRef(null) - const [mounted, setMounted] = useState(false) - /** User-triggered demo — plays even when prefers-reduced-motion is on. */ - const [demoPlaying, setDemoPlaying] = useState(false) - demoPlayingRef.current = demoPlaying - // Gate on `mounted` so prefers-reduced-motion can't mismatch SSR HTML. - const reduce = mounted && reduceMotion && !demoPlaying - - useEffect(() => { - setMounted(true) - }, []) - - // 0 → 1 progress drives scaleY (compositor-friendly). - const progress = useTransform(stretch, (v) => - maxStretch <= 0 ? 0 : Math.min(1, Math.max(0, v / maxStretch)) + const canvasRef = useRef(null) + const scrollerRef = useRef(null) + const contentRef = useRef(null) + const instanceRef = useRef(null) + // The window aurora portals to , which only exists on the client. + const onClient = useSyncExternalStore( + noopSubscribe, + () => true, + () => false ) - const pageY = useTransform(stretch, (v) => -v) - const fieldOpacity = useTransform(progress, [0, 0.08, 0.2], [0, 1, 1]) - - const blurPx = Math.max(0, blur) - const glowAlpha = Math.min(1, Math.max(0, glow)) - - const bars = useMemo(() => { - const count = Math.max(8, Math.min(96, Math.floor(columns))) - return Array.from({ length: count }, (_, i) => { - const color = colors[i % colors.length]! - const heightPct = Math.max(28, columnScale(i, count) * 100).toFixed(2) - return { - key: i, - height: `${heightPct}%`, - backgroundImage: `linear-gradient(to top, ${hexToRgba(color, 1)} 0%, ${hexToRgba(color, 0.6)} 45%, rgba(0, 0, 0, 0) 100%)`, - } - }) - }, [colors, columns]) - - // Lift the page in lockstep with the stretch — translate only, no 3D. - useMotionValueEvent(stretch, "change", (v) => { - if (reduceMotion && !demoPlayingRef.current) return - if (!windowScroll && !scrollRef) return - - if (!rolledEl.current?.isConnected) { - rolledEl.current = document.querySelector( - contentSelector - ) as HTMLElement | null - } - const el = rolledEl.current - if (!el) return - - const y = -Math.max(0, v) - el.style.transformOrigin = "50% 100%" - el.style.willChange = "transform" - el.style.transform = `translate3d(0, ${y}px, 0)` - - if (v < 0.2) { - el.style.transform = "translate3d(0, 0, 0)" - el.style.willChange = "" - } - }) + const portalHost = windowScroll && onClient ? document.body : null useEffect(() => { - return () => { - const el = rolledEl.current - if (!el) return - el.style.transform = "" - el.style.transformOrigin = "" - el.style.willChange = "" - } - }, []) - - // Always listen for docs "Show effect" — drives pull directly (no fake wheels). - useEffect(() => { - const clearDemoTimer = () => { - if (demoTimer.current) { - clearTimeout(demoTimer.current) - demoTimer.current = null + const canvas = canvasRef.current + const scroller = windowScroll + ? window + : (scrollRef?.current ?? scrollerRef.current) + if (!canvas || !scroller) return + const content = + windowScroll || scrollRef ? contentSelector : contentRef.current + instanceRef.current = createStretchyFooter( + { canvas, scroller, content }, + { + maxStretch, + colors, + stiffness, + damping, + columns, + blur, + glow, + flip, + rotate, + demoId, } - } - - const onPlay = (event: Event) => { - const detail = - (event as CustomEvent).detail ?? {} - if (detail.target != null && detail.target !== demoId) return - // Scoped footers ignore broadcast plays without a target. - if (detail.target == null && demoId != null) return - - const amount = detail.amount ?? 0.82 - const holdMs = detail.holdMs ?? 700 - - if (wheelTimer.current) { - clearTimeout(wheelTimer.current) - wheelTimer.current = null - } - clearDemoTimer() - demoPlayingRef.current = true - setDemoPlaying(true) - pull.set(Math.min(maxStretch, Math.max(0, maxStretch * amount))) - demoTimer.current = setTimeout(() => { - demoTimer.current = null - pull.set(0) - // Wait for the spring to settle before re-honoring reduced motion. - window.setTimeout(() => { - demoPlayingRef.current = false - setDemoPlaying(false) - }, 500) - }, holdMs) - } - - window.addEventListener(STRETCHY_FOOTER_PLAY, onPlay) + ) return () => { - clearDemoTimer() - window.removeEventListener(STRETCHY_FOOTER_PLAY, onPlay) + instanceRef.current?.destroy() + instanceRef.current = null } - }, [demoId, maxStretch, pull]) + // Options stream in through setOptions; only the wiring remounts. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [windowScroll, scrollRef, contentSelector, portalHost]) useEffect(() => { - if (reduceMotion) return - - let target: OverscrollTarget | null = null - if (windowScroll) { - target = { kind: "window" } - } else { - const el = scrollRef?.current ?? internalRef.current - if (el) target = { kind: "element", el } - } - if (!target) return - - // Kill browser rubber-band so it doesn't fight ours. - const prevOverscroll = - target.kind === "window" - ? document.documentElement.style.overscrollBehaviorY - : target.el.style.overscrollBehaviorY - if (target.kind === "window") { - document.documentElement.style.overscrollBehaviorY = "none" - } else { - target.el.style.overscrollBehaviorY = "none" - } - - const isAtBottom = () => - target.kind === "window" ? windowAtBottom() : elementAtBottom(target.el) - - const clearWheelTimer = () => { - if (wheelTimer.current) { - clearTimeout(wheelTimer.current) - wheelTimer.current = null - } - } - - const pullTo = (next: number) => { - pull.set(next) - } - - const snapBack = () => { - clearWheelTimer() - pull.set(0) - } - - const onWheel = (event: WheelEvent) => { - if (demoTimer.current) { - clearTimeout(demoTimer.current) - demoTimer.current = null - setDemoPlaying(false) - } - const current = pull.get() - const scrollingDown = event.deltaY > 0 - const scrollingUp = event.deltaY < 0 - - if (current > 0.5 || stretch.get() > 0.5) { - event.preventDefault() - if (scrollingUp) { - const next = Math.max(0, current + event.deltaY * PULL_GAIN) - if (next <= 0.5) { - snapBack() - return - } - pullTo(next) - } else if (scrollingDown) { - pullTo(applyResistance(current, event.deltaY, maxStretch)) - } - clearWheelTimer() - wheelTimer.current = setTimeout(snapBack, WHEEL_IDLE_MS) - return - } - - if (scrollingDown && isAtBottom()) { - event.preventDefault() - pullTo(applyResistance(0, event.deltaY, maxStretch)) - clearWheelTimer() - wheelTimer.current = setTimeout(snapBack, WHEEL_IDLE_MS) - } - } - - const onTouchStart = (event: TouchEvent) => { - const t = event.touches[0] - if (!t) return - if (demoTimer.current) { - clearTimeout(demoTimer.current) - demoTimer.current = null - setDemoPlaying(false) - } - touchStartY.current = t.clientY - touchStretch0.current = pull.get() - } - - const onTouchMove = (event: TouchEvent) => { - const t = event.touches[0] - if (!t) return - const dy = touchStartY.current - t.clientY - const current = pull.get() - - if (current > 0.5 || stretch.get() > 0.5 || (dy > 0 && isAtBottom())) { - if (event.cancelable) event.preventDefault() - const boost = current < 40 ? POP_BOOST : 1 - const fromRest = touchStretch0.current + dy * PULL_GAIN * boost - pullTo(Math.min(maxStretch, Math.max(0, fromRest))) - } - } - - const onTouchEnd = () => { - if (pull.get() > 0.5 || stretch.get() > 0.5) snapBack() - } - - const optsWheel = { passive: false } as const - const optsTouchStart = { passive: true } as const - const optsTouchMove = { passive: false } as const - - if (target.kind === "window") { - window.addEventListener("wheel", onWheel, optsWheel) - window.addEventListener("touchstart", onTouchStart, optsTouchStart) - window.addEventListener("touchmove", onTouchMove, optsTouchMove) - window.addEventListener("touchend", onTouchEnd) - window.addEventListener("touchcancel", onTouchEnd) - } else { - const el = target.el - el.addEventListener("wheel", onWheel, optsWheel) - el.addEventListener("touchstart", onTouchStart, optsTouchStart) - el.addEventListener("touchmove", onTouchMove, optsTouchMove) - el.addEventListener("touchend", onTouchEnd) - el.addEventListener("touchcancel", onTouchEnd) - } - - return () => { - clearWheelTimer() - if (target.kind === "window") { - document.documentElement.style.overscrollBehaviorY = prevOverscroll - window.removeEventListener("wheel", onWheel) - window.removeEventListener("touchstart", onTouchStart) - window.removeEventListener("touchmove", onTouchMove) - window.removeEventListener("touchend", onTouchEnd) - window.removeEventListener("touchcancel", onTouchEnd) - } else { - target.el.style.overscrollBehaviorY = prevOverscroll - const el = target.el - el.removeEventListener("wheel", onWheel) - el.removeEventListener("touchstart", onTouchStart) - el.removeEventListener("touchmove", onTouchMove) - el.removeEventListener("touchend", onTouchEnd) - el.removeEventListener("touchcancel", onTouchEnd) - } - } - }, [reduceMotion, scrollRef, windowScroll, maxStretch, pull, stretch]) + instanceRef.current?.setOptions({ + maxStretch, + colors, + stiffness, + damping, + columns, + blur, + glow, + flip, + rotate, + demoId, + }) + }, [ + maxStretch, + colors, + stiffness, + damping, + columns, + blur, + glow, + flip, + rotate, + demoId, + ]) const aurora = (
- - {/* Single blur layer — cheaper than per-bar filters */} -
- {bars.map((bar) => ( -
- ))} -
- - {/* Soft floor bloom */} -
- +
) if (windowScroll) { - if (!mounted) return null - - // Portal to body so `fixed` stays on the viewport — a transformed - // ancestor (the rolling page) would otherwise pin it to the content. + if (!portalHost) return null + // Portal so `fixed` stays on the viewport — the lifted page is + // transformed and would otherwise pin it to the content. return createPortal(
{aurora}
, - document.body + portalHost ) } @@ -456,7 +195,6 @@ export function StretchyFooter({ return (
- - {children} - +
+ {children} +
+
{aurora}
) diff --git a/registry/tangle-footer/registry.json b/registry/tangle-footer/registry.json deleted file mode 100644 index 83afe73..0000000 --- a/registry/tangle-footer/registry.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema/registry.json", - "items": [ - { - "name": "tangle-footer", - "type": "registry:ui", - "title": "Tangle Footer", - "description": "A footer of tangled SVG ribbons — thick dark strokes with white text running along each path via textPath.", - "dependencies": ["motion"], - "files": [ - { - "path": "tangle-footer.tsx", - "type": "registry:ui", - "target": "components/ui/tangle-footer.tsx" - } - ] - }, - { - "name": "tangle-footer-svelte", - "type": "registry:ui", - "title": "Tangle Footer", - "description": "A footer of tangled SVG ribbons — thick dark strokes with white text running along each path via textPath.", - "files": [ - { - "path": "tangle-footer.svelte", - "type": "registry:file", - "target": "src/lib/components/ui/tangle-footer.svelte" - } - ], - "dependencies": [] - } - ] -} diff --git a/registry/tangle-footer/tangle-footer-demo.tsx b/registry/tangle-footer/tangle-footer-demo.tsx deleted file mode 100644 index 2640817..0000000 --- a/registry/tangle-footer/tangle-footer-demo.tsx +++ /dev/null @@ -1,177 +0,0 @@ -"use client" - -import { useLayoutEffect, useMemo } from "react" - -import { - ComponentControls, - ControlColor, - ControlSlider, -} from "@/components/component-controls" -import { ComponentPreview } from "@/components/component-preview" -import { useHydratedTheme } from "@/hooks/use-hydrated-theme" -import { usePreviewProps } from "@/hooks/use-preview-props" -import { cn } from "@/lib/utils" -import { TangleFooter } from "@/registry/tangle-footer/tangle-footer" - -/** Matches TangleFooter theme defaults (uppercase for color inputs). */ -const LIGHT = { - ribbon: "#141414", - textColor: "#F4F0E8", - stage: "#EFEAE2", -} -const DARK = { - ribbon: "#E8E4DC", - textColor: "#161616", - stage: "#121210", -} - -type ThemePalette = typeof LIGHT - -function norm(hex: string) { - return hex.trim().toUpperCase() -} - -function matchesPalette( - ribbon: string, - textColor: string, - stage: string, - palette: ThemePalette -) { - return ( - norm(ribbon) === norm(palette.ribbon) && - norm(textColor) === norm(palette.textColor) && - norm(stage) === norm(palette.stage) - ) -} - -/** True when colors are still one of the built-in theme triples. */ -function isStockThemePalette(ribbon: string, textColor: string, stage: string) { - return ( - matchesPalette(ribbon, textColor, stage, LIGHT) || - matchesPalette(ribbon, textColor, stage, DARK) - ) -} - -const LINES = [ - "Ship something opinionated — less boilerplate, clearer decisions. ", - "Knows what’s going on. Can you check in with them and see what’s next. ", - "The new timeline should be ready by Friday, although it’s probably going to slip. ", - "Open the docs, grab a component, and make it yours in the codebase. ", - "Radiant lines, shader wash, gooey picker — install what you need and move. ", -] - -export function TangleFooterDemo() { - const theme = useHydratedTheme() - const palette: ThemePalette = theme === "dark" ? DARK : LIGHT - - const defaults = useMemo( - () => ({ - height: 280, - seed: 23, - ribbon: palette.ribbon, - textColor: palette.textColor, - stage: palette.stage, - }), - [palette] - ) - - const { props, updateProp, resetProps, hasChanges, setProps } = - usePreviewProps(defaults) - - // Keep stock ribbon/text/stage on the active theme until the user picks custom colors. - useLayoutEffect(() => { - setProps((prev) => { - if (!isStockThemePalette(prev.ribbon, prev.textColor, prev.stage)) { - return prev - } - if (matchesPalette(prev.ribbon, prev.textColor, prev.stage, palette)) { - return prev - } - return { - ...prev, - ribbon: palette.ribbon, - textColor: palette.textColor, - stage: palette.stage, - } - }) - }, [palette, setProps]) - - const useAutoTheme = isStockThemePalette( - props.ribbon, - props.textColor, - props.stage - ) - - return ( - <> - -
-
- -
-
-
- - - updateProp("stage", v)} - /> - updateProp("ribbon", v)} - /> - updateProp("textColor", v)} - /> - updateProp("height", v)} - /> - updateProp("seed", v)} - /> - - - ) -} diff --git a/registry/tangle-footer/tangle-footer-vanilla.ts b/registry/tangle-footer/tangle-footer-vanilla.ts deleted file mode 100644 index d60fe9c..0000000 --- a/registry/tangle-footer/tangle-footer-vanilla.ts +++ /dev/null @@ -1,159 +0,0 @@ -export const DEFAULT_LINES = [ - "Ship something opinionated — less boilerplate, clearer decisions.", - "Knows what’s going on. Can you check in with them and see what’s next.", - "The new timeline should be ready by Friday, although it’s probably going to slip.", - "Open the docs, grab a component, and make it yours in the codebase.", - "Radiant lines, shader wash, gooey picker — install what you need and move.", -] - -export type Ring = { - d: string - cx: number - cy: number - strokeWidth: number - fontSize: number - /** Text repeated enough to blanket the full circumference. */ - text: string - /** Seconds for one full revolution. */ - duration: number - /** Fraction of a turn (0–1) to offset the starting rotation. */ - phase: number - reverse: boolean -} - -export type TangleFooterOptions = { - /** Phrases sampled randomly onto rings. */ - lines?: string[] - /** - * Ribbon (stroke) color. Omit for theme-aware defaults - * (near-black in light, soft cream in dark). - */ - ribbon?: string - /** - * Text fill on the ribbons. Omit for theme-aware defaults - * (cream in light, near-black in dark). - */ - textColor?: string - /** - * Field behind the rings. Omit for theme-aware defaults - * (warm cream in light, near-black in dark). Pass `"transparent"` - * when the parent already paints the stage. - */ - background?: string - /** - * Band height in px. Omit to use half the measured width - * (upper semicircle of a full-width nest). When set shorter - * than that, the nest scales down to fit so rings stay intact. - */ - height?: number - /** Seed for random line / marquee assignment across rings. */ - seed?: number - /** Accessible label. */ - label?: string -} - -export const RING_COUNT = 5 - -export const K = 0.5522847498 -export const STROKE = 28 - -/** Mulberry32 — deterministic PRNG so rings stay stable across re-renders. */ -export function mulberry32(seed: number) { - let t = seed >>> 0 - return () => { - t += 0x6d2b79f5 - let r = Math.imul(t ^ (t >>> 15), 1 | t) - r ^= r + Math.imul(r ^ (r >>> 7), 61 | r) - return ((r ^ (r >>> 14)) >>> 0) / 4294967296 - } -} - -/** Perfect circle as four cubics (container clips to the upper half). */ -export function circlePath(cx: number, cy: number, r: number): string { - const o = r * K - return [ - `M ${(cx + r).toFixed(1)} ${cy.toFixed(1)}`, - `C ${(cx + r).toFixed(1)} ${(cy + o).toFixed(1)} ${(cx + o).toFixed(1)} ${(cy + r).toFixed(1)} ${cx.toFixed(1)} ${(cy + r).toFixed(1)}`, - `C ${(cx - o).toFixed(1)} ${(cy + r).toFixed(1)} ${(cx - r).toFixed(1)} ${(cy + o).toFixed(1)} ${(cx - r).toFixed(1)} ${cy.toFixed(1)}`, - `C ${(cx - r).toFixed(1)} ${(cy - o).toFixed(1)} ${(cx - o).toFixed(1)} ${(cy - r).toFixed(1)} ${cx.toFixed(1)} ${(cy - r).toFixed(1)}`, - `C ${(cx + o).toFixed(1)} ${(cy - r).toFixed(1)} ${(cx + r).toFixed(1)} ${(cy - o).toFixed(1)} ${(cx + r).toFixed(1)} ${cy.toFixed(1)}`, - ].join(" ") -} - -/** - * Tile copy so the ring is fully blanketed with no visible gap. - * Rotation makes the loop seamless, so we only need to cover the - * circumference — no exact tile-width matching required. - */ -export function buildRingCopy( - line: string, - other: string, - mix: boolean, - circumference: number, - fontSize: number -): string { - const unit = mix - ? `${line} · ${other} · ` - : `${line} · ${line} · ${other} · ` - // Bold + letter-spacing runs wider than 0.5em — over-estimate so we never underfill. - const unitWidth = Math.max(unit.length * fontSize * 0.72, 1) - const repeats = Math.max(3, Math.ceil(circumference / unitWidth) + 1) - return unit.repeat(repeats) -} - -/** - * Five concentric circles nested as an upper semicircle. - * Outer radius fits both width and band height so a short band - * still shows complete arches instead of an equatorial clip. - */ -export function buildRings( - width: number, - bandHeight: number, - lines: string[], - seed: number -): Ring[] { - const rand = mulberry32(seed) - const cx = width / 2 - const cy = bandHeight - const strokePad = STROKE / 2 + 2 - // Full-width nest needs height ≈ width/2; if the band is shorter, - // shrink the nest so the upper semicircle stays fully visible. - const outer = Math.max( - Math.min(width / 2 - strokePad, bandHeight - strokePad), - STROKE * 4 - ) - const radii = Array.from( - { length: RING_COUNT }, - (_, i) => (outer * (i + 1)) / RING_COUNT - ) - - const fontSize = Math.min(24, Math.max(16, width * 0.022)) - const pool = lines.length > 0 ? lines : DEFAULT_LINES - - return radii.map((r, i) => { - const line = pool[Math.floor(rand() * pool.length)]! - const other = pool[Math.floor(rand() * pool.length)]! - const circumference = 2 * Math.PI * r - const text = buildRingCopy( - line, - other, - rand() > 0.45, - circumference, - fontSize - ) - - return { - d: circlePath(cx, cy, r), - cx, - cy, - strokeWidth: STROKE, - fontSize, - text, - // Keep angular pace lively but readable; outer rings a touch slower. - duration: 42 + i * 8 + rand() * 10, - phase: rand(), - // Alternate direction each ring (inner → outer) - reverse: i % 2 === 1, - } - }) -} diff --git a/registry/tangle-footer/tangle-footer.svelte b/registry/tangle-footer/tangle-footer.svelte deleted file mode 100644 index 9e8e2c7..0000000 --- a/registry/tangle-footer/tangle-footer.svelte +++ /dev/null @@ -1,171 +0,0 @@ - - - - -
0 ? `${bandHeight}px` : undefined} - style:aspect-ratio={height == null ? "2 / 1" : undefined} -> - {#if showSvg} - - - {#each rings as ring, i} - - {/each} - - - {#each rings as ring, i} - {@const pathId = `${uid}-path-${i}`} - - - - {ring.text} - - - {/each} - - {/if} - -

{lines.join(" ")}

-
- - diff --git a/registry/tangle-footer/tangle-footer.tsx b/registry/tangle-footer/tangle-footer.tsx deleted file mode 100644 index b30c166..0000000 --- a/registry/tangle-footer/tangle-footer.tsx +++ /dev/null @@ -1,177 +0,0 @@ -"use client" - -import { useEffect, useId, useMemo, useRef, useState } from "react" -import { motion, useReducedMotion } from "motion/react" - -import { cn } from "@/lib/utils" - -import { - buildRings, - DEFAULT_LINES, - type TangleFooterOptions, -} from "./tangle-footer-vanilla" - -export type TangleFooterProps = TangleFooterOptions & { - className?: string -} - -/** - * Footer of five nested text rings — full-width upper semicircle. - * The marquee is a continuous rotation of each ring around its center. - * A full turn is inherently seamless, so it runs on GPU-friendly CSS - * transforms with no per-frame layout work. - */ -export function TangleFooter({ - className, - lines = DEFAULT_LINES, - ribbon, - textColor, - background, - height, - seed = 23, - label = "Site footer", -}: TangleFooterProps) { - const reduce = useReducedMotion() ?? false - const uid = useId().replace(/:/g, "") - const rootRef = useRef(null) - const [width, setWidth] = useState(0) - const [paused, setPaused] = useState(false) - - useEffect(() => { - const el = rootRef.current - if (!el) return - - const measure = () => { - setWidth(el.clientWidth) - } - - measure() - const ro = new ResizeObserver(measure) - ro.observe(el) - return () => ro.disconnect() - }, []) - - // Pause the animation while off-screen so it never wastes frames. - useEffect(() => { - const el = rootRef.current - if (!el) return - - const io = new IntersectionObserver( - ([entry]) => setPaused(!(entry?.isIntersecting ?? true)), - { rootMargin: "64px", threshold: 0 } - ) - io.observe(el) - return () => io.disconnect() - }, []) - - const bandHeight = height ?? (width > 0 ? width / 2 : 0) - const rings = useMemo( - () => - width > 0 && bandHeight > 0 - ? buildRings(width, bandHeight, lines, seed) - : [], - [width, bandHeight, lines, seed] - ) - - const spinName = `tangle-spin-${uid}` - - return ( -
0 ? bandHeight : undefined, - aspectRatio: height == null ? "2 / 1" : undefined, - }} - > - - - {width > 0 && bandHeight > 0 ? ( - - - {rings.map((ring, i) => ( - - ))} - - - {rings.map((ring, i) => { - const pathId = `${uid}-path-${i}` - return ( - - - - - {ring.text} - - - - ) - })} - - ) : null} - -

{lines.join(" ")}

-
- ) -} diff --git a/registry/typewriter/registry.json b/registry/typewriter/registry.json new file mode 100644 index 0000000..e6f2a25 --- /dev/null +++ b/registry/typewriter/registry.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://ui.shadcn.com/schema/registry.json", + "items": [ + { + "name": "typewriter", + "type": "registry:ui", + "title": "Typewriter", + "description": "A real, playable typewriter in SVG — each key presses down, its typebar swings up to strike the ribbon, and the carriage steps along. Click it and type.", + "files": [ + { + "path": "typewriter.tsx", + "type": "registry:ui", + "target": "components/ui/typewriter.tsx" + } + ] + }, + { + "name": "typewriter-svelte", + "type": "registry:ui", + "title": "Typewriter", + "description": "A real, playable typewriter in SVG — each key presses down, its typebar swings up to strike the ribbon, and the carriage steps along. Click it and type.", + "files": [ + { + "path": "typewriter.svelte", + "type": "registry:file", + "target": "src/lib/components/ui/typewriter.svelte" + } + ] + } + ] +} diff --git a/registry/typewriter/typewriter-demo.tsx b/registry/typewriter/typewriter-demo.tsx new file mode 100644 index 0000000..074f24b --- /dev/null +++ b/registry/typewriter/typewriter-demo.tsx @@ -0,0 +1,216 @@ +"use client" + +import { useId, useState } from "react" +import { RiRestartLine } from "@remixicon/react" + +import { Button } from "@/components/ui/button" +import { + ComponentControls, + ControlSlider, + ControlSwitch, +} from "@/components/component-controls" +import { ComponentPreview } from "@/components/component-preview" +import { usePreviewProps } from "@/hooks/use-preview-props" +import { cn } from "@/lib/utils" +import { + DEFAULT_COLOR, + DEFAULT_COLUMNS, + DEFAULT_JITTER, + DEFAULT_SPEED, + DEFAULT_TEXT, + Typewriter, +} from "@/registry/typewriter/typewriter" + +const ENAMELS = [ + { name: "Mint", color: DEFAULT_COLOR }, + { name: "Cherry", color: "#D2463A" }, + { name: "Butter", color: "#E6D6A2" }, + { name: "Sky", color: "#86AED8" }, + { name: "Graphite", color: "#2E3135" }, +] + +const DEFAULTS = { + color: DEFAULT_COLOR, + text: DEFAULT_TEXT, + speed: DEFAULT_SPEED, + jitter: DEFAULT_JITTER, + columns: DEFAULT_COLUMNS, + humanize: true, + loop: true, + sound: false, + interactive: true, +} + +export function TypewriterDemo() { + const textId = useId() + const colorId = useId() + const [take, setTake] = useState(0) + const { props, updateProp, resetProps, hasChanges } = + usePreviewProps(DEFAULTS) + + return ( + <> + +
+ + {props.interactive ? ( +

+ Click it and type +

+ ) : null} +
+
+ + +
+
+ + +
+