Site Supply (sitesupply.dev) is the site-component library for agents. Install a free Component in one command, no setup: `npx shadcn@latest add https://site-supply.vercel.app/r/.json` writes one self-contained React file at components/textures/.tsx; mount the component inside an element with position: relative and a size. Pro Components: Copy to Agent on the Component page gives an entitled user a prompt whose URLs carry a day-long token; otherwise register the @textures registry with a Site Supply API key as its bearer header in components.json (see /agents) and run `npx shadcn@latest add @textures/`. # Site Supply > Site Supply is a library of copyable, animated site components: buttons, links, cards and badges that answer the pointer. Each one is a single React component that coding agents like Claude Code, Cursor and Codex add to your project with one prompt. A free set of Components; the rest with Pro. ## Components (free first) - roll (free; use when: CTA, playful): Each letter of the label rolls up out of its line while a twin rolls in from below, staggered from the pointer. /s/roll.md - flood (free; use when: CTA, bold): A filled circle grows from the pointer's entry point to cover the surface, the label inverting inside it; leaving drains it to the exit point. /s/flood.md - spotlight (free; use when: card/pricing card, calm): A soft radial light trails the pointer across the card, its strength following the hover; the copy and frame stay still. /s/spotlight.md - lean (pro; use when: CTA, playful): The surface translates toward the pointer, up to a tenth of the host's height, and the label 1.6 times as far; both spring home on leave. /s/lean.md - nudge (pro; use when: CTA, calm): The arrow slides out of its slot to the right as a twin slides in from the left; the label shifts left 0.15em. Mirrored in RTL. /s/nudge.md - shine (pro; use when: CTA, calm): A soft diagonal sheen sweeps once across the surface from the pointer's side; the label lifts 0.06em. /s/shine.md - trace (pro; use when: CTA, calm): A full-ink outline is revealed around the button from the pointer's nearest point, both ways; the label lifts 0.04em. /s/trace.md - underline (pro; use when: nav link, calm): The underline under the label draws in from the pointer's side and draws out toward the side it left. /s/underline.md - raise (pro; use when: card/pricing card, calm): The card face rises and scales to 1.01 while a pre-drawn shadow fades in beneath it and a corner arrow slides in. /s/raise.md - reveal (pro; use when: card/pricing card, bold): The title rises by the body's height while the body slides up out of a mask beneath it, 40 ms behind; leaving reverses. /s/reveal.md - status (pro; use when: hero/nav link, calm): A ring expands once out of the dot and fades while the label slides a hair toward its end. /s/status.md - tally (pro; use when: hero/pricing card, playful): Each digit column jumps back a few steps and rolls up into its own digit, staggered left to right; separators stay still. /s/tally.md ## Vocabulary - **site component**: A part a website is built from (a button, a link, a card, a badge, a stat) drawn in the project's own font and colours, with a hover, focus and press motion. It fills the element the project already owns and never changes the layout. - **Component**: One item in the Site Supply catalog: a named site component (Roll, Flood, Spotlight …) shipped as one self-contained React file with a measured fit and perf contract. - **host**: The project's own element the Component fills: its ``` #### contained A card, a tile, a stat, a panel: the host has a size and the Component fills it. ```css .host { position: relative; aspect-ratio: 4 / 3; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
``` ### Install - Command: `npx shadcn@latest add https://site-supply.vercel.app/r/roll.json` (one self-contained React file at components/textures/roll.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/roll` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "roll" })` (server: /mcp) - Markdown: /s/roll.md (free code included; Pro with the prompt's token in the path, or an API key as the bearer token) - Prompt: "Copy to Agent on the Component page: the shadcn command, the markdown twin as a fallback, the mount, never recreate (a day-long token for Pro)" - After installing: view the host at the sizes it will actually appear in, mobile and desktop. - Suggested commit: `feat: add Roll component (site-supply.vercel.app/s/roll)` (a suggestion the agent and the human can edit, never a write). ### Code Write these files into the project, then mount the component on a host with position: relative and a size. #### React #### `components/textures/roll.tsx` ```tsx // Roll — a Component by Site Supply · https://sitesupply.dev/s/roll // Install another: npx shadcn@latest add https://sitesupply.dev/r/.json // For agents: https://sitesupply.dev/llms.txt // License: free to use "use client" import { type CSSProperties, useEffect, useRef } from "react" function selfSize(root: HTMLElement | null): void { const host = root?.parentElement if (!root || !host || host.getBoundingClientRect().height > 0) return root.style.minHeight = "240px" } type Pointer = { x: number y: number active: number reduced: boolean w: number h: number } const AMBIENT_SPEED = 1.5 const AMBIENT_CYCLE = 4.5 function engine( root: HTMLElement, draw: (p: Pointer, dt: number) => boolean | void, min: [number, number], ): () => void { const mq = () => matchMedia("(prefers-reduced-motion: reduce)").matches || matchMedia("(update: slow)").matches const touchMq = matchMedia("(hover: none), (pointer: coarse)") const wantsAmbient = () => root.closest("[data-motion]")?.getAttribute("data-motion") === "ambient" const w = (2 * Math.PI) / AMBIENT_CYCLE const phase = Math.random() * 2 * Math.PI let onScreen = false let ambient = false let clock = 0 let hold = 0 let box: { left: number; top: number; w: number; h: number } | null = null let px = 0 let py = 0 let cx = 0 let cy = 0 let active = 0 let target = 0 let vel = 0 let frame = 0 let last = 0 let lastMoveAt = 0 const measure = () => { const r = root.getBoundingClientRect() box = r.width < min[0] || r.height < min[1] ? null : { left: r.left, top: r.top, w: r.width, h: r.height } } const tick = (now: number) => { const dt = Math.min(last ? (now - last) / 1000 : 1 / 60, 1 / 30) last = now let moving = false const reduced = mq() if (ambient && box && now > hold) { if (reduced) target = 0 else { clock += dt * AMBIENT_SPEED const t = clock px = box.w / 2 + (box.w / 2) * (0.76 * Math.sin(w * t + phase) + 0.2 * Math.sin(1.93 * w * t + 1.2 + phase)) py = box.h / 2 + (box.h / 2) * (0.72 * Math.sin(0.75 * w * t + 0.8 + phase) + 0.2 * Math.sin(1.37 * w * t + 2.1 + phase)) target = 1 moving = true } } if (reduced) { active = target vel = 0 } else { const decay = Math.exp(-30 * dt) const off = active - target const lead = vel + 30 * off active = target + (off + lead * dt) * decay vel = (vel - 30 * lead * dt) * decay if (Math.abs(vel) < 0.02 && Math.abs(active - target) < 0.02) { active = target vel = 0 } else moving = true } const more = draw({ x: px, y: py, active, reduced, w: box?.w ?? 0, h: box?.h ?? 0 }, dt) if (moving || more) frame = requestAnimationFrame(tick) else { frame = 0 last = 0 } } const wake = () => { if (!frame) frame = requestAnimationFrame(tick) } const isTouch = (e: Event) => (e as PointerEvent).pointerType === "touch" && touchMq.matches const at = (e: PointerEvent) => { if (!box) return false cx = e.clientX cy = e.clientY px = cx - box.left py = cy - box.top return true } const onEnter = (e: PointerEvent) => { if (isTouch(e)) return measure() } const onMove = (e: PointerEvent) => { if (isTouch(e)) return const t = performance.now() if (!box || t - lastMoveAt > 250) measure() lastMoveAt = t if (!at(e)) return if (!e.isTrusted) hold = t + 1000 target = 1 wake() } const onLeave = (e?: Event) => { if (e && isTouch(e)) return if (e && !e.isTrusted) hold = 0 if (ambient && performance.now() > hold) return target = 0 wake() } const onDown = (e: PointerEvent) => { if (!isTouch(e)) return measure() if (!at(e)) return target = 1 wake() } const onUp = (e: PointerEvent) => { if (!isTouch(e) || ambient) return target = 0 wake() } const onFocus = () => { measure() if (!box) return px = box.w / 2 py = box.h / 2 target = 1 wake() } const onResize = () => { if (box || ambient) measure() if (ambient && !box) ambient = false settle() } const onScroll = () => { if (!box || target !== 1 || (ambient && performance.now() > hold)) return measure() if (!box) return px = cx - box.left py = cy - box.top wake() } const settle = () => { const next = onScreen && !document.hidden && wantsAmbient() if (next === ambient) return ambient = next if (ambient) measure() if (ambient && !box) ambient = false if (!ambient) target = 0 wake() } const io = typeof IntersectionObserver === "function" ? new IntersectionObserver( ([entry]) => { onScreen = !!entry?.isIntersecting settle() }, { threshold: 0.2 }, ) : null io?.observe(root) const onVisibility = () => settle() const reducedMq = matchMedia("(prefers-reduced-motion: reduce)") const onReduced = () => wake() document.addEventListener("visibilitychange", onVisibility) reducedMq.addEventListener?.("change", onReduced) const host: Element = root.closest("a, button, [tabindex]") ?? root.parentElement ?? root root.addEventListener("pointerenter", onEnter) root.addEventListener("pointermove", onMove, { passive: true }) root.addEventListener("pointerleave", onLeave) root.addEventListener("pointerdown", onDown, { passive: true }) root.addEventListener("pointerup", onUp) root.addEventListener("pointercancel", onUp) host.addEventListener("focusin", onFocus) host.addEventListener("focusout", onLeave) window.addEventListener("resize", onResize, { passive: true }) window.addEventListener("scroll", onScroll, { passive: true, capture: true }) return () => { root.removeEventListener("pointerenter", onEnter) root.removeEventListener("pointermove", onMove) root.removeEventListener("pointerleave", onLeave) root.removeEventListener("pointerdown", onDown) root.removeEventListener("pointerup", onUp) root.removeEventListener("pointercancel", onUp) host.removeEventListener("focusin", onFocus) host.removeEventListener("focusout", onLeave) window.removeEventListener("resize", onResize) window.removeEventListener("scroll", onScroll, { capture: true }) document.removeEventListener("visibilitychange", onVisibility) reducedMq.removeEventListener?.("change", onReduced) io?.disconnect() if (frame) cancelAnimationFrame(frame) } } export type RollProps = { label?: string tokens?: Partial> className?: string static?: boolean } const DURATION = 0.32 const STAGGER = 0.022 const RELEASE = 0.24 const outQuart = (p: number) => 1 - (1 - p) ** 4 const HIDDEN: CSSProperties = { position: "absolute", clipPath: "inset(50%)", whiteSpace: "nowrap" } export function Roll({ label = "Get started", tokens, className = "", static: isStatic = false }: RollProps) { const ref = useRef(null) useEffect(() => selfSize(ref.current), []) const letters = useRef<(HTMLSpanElement | null)[]>([]) const chars = Array.from(label) const dir = /[֐-ࣿיִ-﷿ﹰ-]/.test(label) ? "rtl" : "ltr" useEffect(() => { const root = ref.current if (!root || isStatic) return const n = chars.length const q = new Array(n).fill(0) const delay = new Array(n).fill(0) let on = false let t = 0 const write = (i: number) => { const el = letters.current[i] if (el) el.style.transform = q[i] === 0 ? "" : `translateY(${(-100 * outQuart(q[i]!)).toFixed(2)}%)` } return engine( root, ({ x, active, reduced, w }, dt) => { if (!on && active > 0.5) { on = true t = 0 const near = w > 0 ? Math.round((x / w) * (n - 1)) : 0 for (let i = 0; i < n; i++) delay[i] = Math.abs(i - (dir === "rtl" ? n - 1 - near : near)) * STAGGER } else if (on && active < 0.5) on = false let moving = false t += dt for (let i = 0; i < n; i++) { const before = q[i]! if (reduced) q[i] = on ? 1 : 0 else if (on) q[i] = Math.min(1, Math.max(before, (t - delay[i]!) / DURATION)) else q[i] = Math.max(0, before - dt / RELEASE) if (q[i] !== before) write(i) if (q[i] !== (on ? 1 : 0)) moving = true } return moving }, [96, 32], ) }, [isStatic, label]) const style = { "--texture-ink": "var(--foreground)", "--texture-field": "var(--background)", ...tokens, color: "var(--texture-field)", background: "var(--texture-ink)", borderRadius: "999px", } as CSSProperties return (
{label}
) } ```. Live preview: /embed/roll. ### Agent brief ### 1. What it is A pill button surface with its label set in the project's own font. On hover the label rolls up out of its line letter by letter, starting at the letter nearest the pointer and running outward, while an identical label rolls in from below. Use it for a primary call to action. Not for icon buttons: below 96 × 32 it stays static. ### 2. Behaviour timeline - **Idle:** the resting label. Nothing runs: no loops, no listeners doing work. - **Hover-in:** each letter rolls up over 320 ms on an ease-out, 22 ms after its neighbour, outward from the pointer's arrival. - **Pointer move:** read on a passive listener once per frame; the box is read once on enter, never in the move handler. - **Hover-out:** every letter rolls back together in 240 ms from wherever it is; interruptible at any point. - **Reduced motion / slow display:** the hover state is applied in one step; nothing eases. - **Touch:** press — a finger down plays the hover state, lifting it releases; nothing plays by itself. ### 3. Integration - Install: `npx shadcn@latest add https://sitesupply.dev/r/roll.json` (one self-contained React file at components/textures/roll.tsx), or write the file `get_texture` returns exactly as returned, or use the code in this Component's markdown twin (/s/roll.md). - Mount it inside the project's own `
``` #### contained A card, a tile, a stat, a panel: the host has a size and the Component fills it. ```css .host { position: relative; aspect-ratio: 4 / 3; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
``` ### Install - Command: `npx shadcn@latest add https://site-supply.vercel.app/r/flood.json` (one self-contained React file at components/textures/flood.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/flood` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "flood" })` (server: /mcp) - Markdown: /s/flood.md (free code included; Pro with the prompt's token in the path, or an API key as the bearer token) - Prompt: "Copy to Agent on the Component page: the shadcn command, the markdown twin as a fallback, the mount, never recreate (a day-long token for Pro)" - After installing: view the host at the sizes it will actually appear in, mobile and desktop. - Suggested commit: `feat: add Flood component (site-supply.vercel.app/s/flood)` (a suggestion the agent and the human can edit, never a write). ### Code Write these files into the project, then mount the component on a host with position: relative and a size. #### React #### `components/textures/flood.tsx` ```tsx // Flood — a Component by Site Supply · https://sitesupply.dev/s/flood // Install another: npx shadcn@latest add https://sitesupply.dev/r/.json // For agents: https://sitesupply.dev/llms.txt // License: free to use "use client" import { type CSSProperties, useEffect, useRef } from "react" function selfSize(root: HTMLElement | null): void { const host = root?.parentElement if (!root || !host || host.getBoundingClientRect().height > 0) return root.style.minHeight = "240px" } type Pointer = { x: number y: number active: number reduced: boolean w: number h: number } const AMBIENT_SPEED = 1.5 const AMBIENT_CYCLE = 4.5 function engine( root: HTMLElement, draw: (p: Pointer, dt: number) => boolean | void, min: [number, number], ): () => void { const mq = () => matchMedia("(prefers-reduced-motion: reduce)").matches || matchMedia("(update: slow)").matches const touchMq = matchMedia("(hover: none), (pointer: coarse)") const wantsAmbient = () => root.closest("[data-motion]")?.getAttribute("data-motion") === "ambient" const w = (2 * Math.PI) / AMBIENT_CYCLE const phase = Math.random() * 2 * Math.PI let onScreen = false let ambient = false let clock = 0 let hold = 0 let box: { left: number; top: number; w: number; h: number } | null = null let px = 0 let py = 0 let cx = 0 let cy = 0 let active = 0 let target = 0 let vel = 0 let frame = 0 let last = 0 let lastMoveAt = 0 const measure = () => { const r = root.getBoundingClientRect() box = r.width < min[0] || r.height < min[1] ? null : { left: r.left, top: r.top, w: r.width, h: r.height } } const tick = (now: number) => { const dt = Math.min(last ? (now - last) / 1000 : 1 / 60, 1 / 30) last = now let moving = false const reduced = mq() if (ambient && box && now > hold) { if (reduced) target = 0 else { clock += dt * AMBIENT_SPEED const t = clock px = box.w / 2 + (box.w / 2) * (0.76 * Math.sin(w * t + phase) + 0.2 * Math.sin(1.93 * w * t + 1.2 + phase)) py = box.h / 2 + (box.h / 2) * (0.72 * Math.sin(0.75 * w * t + 0.8 + phase) + 0.2 * Math.sin(1.37 * w * t + 2.1 + phase)) target = 1 moving = true } } if (reduced) { active = target vel = 0 } else { const decay = Math.exp(-30 * dt) const off = active - target const lead = vel + 30 * off active = target + (off + lead * dt) * decay vel = (vel - 30 * lead * dt) * decay if (Math.abs(vel) < 0.02 && Math.abs(active - target) < 0.02) { active = target vel = 0 } else moving = true } const more = draw({ x: px, y: py, active, reduced, w: box?.w ?? 0, h: box?.h ?? 0 }, dt) if (moving || more) frame = requestAnimationFrame(tick) else { frame = 0 last = 0 } } const wake = () => { if (!frame) frame = requestAnimationFrame(tick) } const isTouch = (e: Event) => (e as PointerEvent).pointerType === "touch" && touchMq.matches const at = (e: PointerEvent) => { if (!box) return false cx = e.clientX cy = e.clientY px = cx - box.left py = cy - box.top return true } const onEnter = (e: PointerEvent) => { if (isTouch(e)) return measure() } const onMove = (e: PointerEvent) => { if (isTouch(e)) return const t = performance.now() if (!box || t - lastMoveAt > 250) measure() lastMoveAt = t if (!at(e)) return if (!e.isTrusted) hold = t + 1000 target = 1 wake() } const onLeave = (e?: Event) => { if (e && isTouch(e)) return if (e && !e.isTrusted) hold = 0 if (ambient && performance.now() > hold) return target = 0 wake() } const onDown = (e: PointerEvent) => { if (!isTouch(e)) return measure() if (!at(e)) return target = 1 wake() } const onUp = (e: PointerEvent) => { if (!isTouch(e) || ambient) return target = 0 wake() } const onFocus = () => { measure() if (!box) return px = box.w / 2 py = box.h / 2 target = 1 wake() } const onResize = () => { if (box || ambient) measure() if (ambient && !box) ambient = false settle() } const onScroll = () => { if (!box || target !== 1 || (ambient && performance.now() > hold)) return measure() if (!box) return px = cx - box.left py = cy - box.top wake() } const settle = () => { const next = onScreen && !document.hidden && wantsAmbient() if (next === ambient) return ambient = next if (ambient) measure() if (ambient && !box) ambient = false if (!ambient) target = 0 wake() } const io = typeof IntersectionObserver === "function" ? new IntersectionObserver( ([entry]) => { onScreen = !!entry?.isIntersecting settle() }, { threshold: 0.2 }, ) : null io?.observe(root) const onVisibility = () => settle() const reducedMq = matchMedia("(prefers-reduced-motion: reduce)") const onReduced = () => wake() document.addEventListener("visibilitychange", onVisibility) reducedMq.addEventListener?.("change", onReduced) const host: Element = root.closest("a, button, [tabindex]") ?? root.parentElement ?? root root.addEventListener("pointerenter", onEnter) root.addEventListener("pointermove", onMove, { passive: true }) root.addEventListener("pointerleave", onLeave) root.addEventListener("pointerdown", onDown, { passive: true }) root.addEventListener("pointerup", onUp) root.addEventListener("pointercancel", onUp) host.addEventListener("focusin", onFocus) host.addEventListener("focusout", onLeave) window.addEventListener("resize", onResize, { passive: true }) window.addEventListener("scroll", onScroll, { passive: true, capture: true }) return () => { root.removeEventListener("pointerenter", onEnter) root.removeEventListener("pointermove", onMove) root.removeEventListener("pointerleave", onLeave) root.removeEventListener("pointerdown", onDown) root.removeEventListener("pointerup", onUp) root.removeEventListener("pointercancel", onUp) host.removeEventListener("focusin", onFocus) host.removeEventListener("focusout", onLeave) window.removeEventListener("resize", onResize) window.removeEventListener("scroll", onScroll, { capture: true }) document.removeEventListener("visibilitychange", onVisibility) reducedMq.removeEventListener?.("change", onReduced) io?.disconnect() if (frame) cancelAnimationFrame(frame) } } export type FloodProps = { label?: string tokens?: Partial> className?: string static?: boolean } const FILL = 0.32 const DRAIN = 0.24 const outCubic = (p: number) => 1 - (1 - p) ** 3 const REST = "circle(0% at 50% 50%)" const HIDDEN: CSSProperties = { position: "absolute", clipPath: "inset(50%)", whiteSpace: "nowrap" } const LAYER: CSSProperties = { position: "absolute", inset: 0, borderRadius: "inherit", pointerEvents: "none" } const ROW: CSSProperties = { ...LAYER, display: "flex", alignItems: "center", justifyContent: "center", lineHeight: 1.2, fontSize: "min(34cqb, 11cqi)", fontWeight: 500, } const TEXT: CSSProperties = { maxWidth: "100%", boxSizing: "border-box", padding: "0 0.9em", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", } export function Flood({ label = "Get started", tokens, className = "", static: isStatic = false }: FloodProps) { const ref = useRef(null) useEffect(() => selfSize(ref.current), []) const flood = useRef(null) const dir = /[֐-ࣿיִ-﷿ﹰ-]/.test(label) ? "rtl" : "ltr" useEffect(() => { const root = ref.current if (!root || isStatic) return let prev = 0 let on = false let f = 0 let from = 0 let t = 0 let cx = 0 let cy = 0 let R = 0 const reach = (x: number, y: number, w: number, h: number) => Math.hypot(Math.max(x, w - x), Math.max(y, h - y)) return engine( root, ({ x, y, active, reduced, w, h }, dt) => { const el = flood.current if (!el) return false const want = active > prev ? true : active < prev ? false : active > 0.5 prev = active const px = Math.min(Math.max(x, 0), w) const py = Math.min(Math.max(y, 0), h) if (want !== on) { on = want if ((on && f === 0) || (!on && f > 0.999)) { cx = px cy = py } R = reach(cx, cy, w, h) from = f t = 0 } const to = on ? 1 : 0 if (reduced) f = to else { t += dt const p = Math.min(1, t / (on ? FILL : DRAIN)) f = from + (to - from) * outCubic(p) if (p >= 1) f = to } el.style.clipPath = f === 0 ? REST : `circle(${(f * R + (f === 1 ? 1 : 0)).toFixed(2)}px at ${cx.toFixed(1)}px ${cy.toFixed(1)}px)` return f !== to }, [96, 32], ) }, [isStatic, label]) const style = { "--texture-ink": "var(--foreground)", "--texture-field": "var(--background)", ...tokens, color: "var(--texture-ink)", background: "var(--texture-field)", borderRadius: "999px", } as CSSProperties return (
{label}
) } ```. Live preview: /embed/flood. ### Agent brief ### 1. What it is An outline pill button with its label set in the project's own font. On hover the ink floods the surface from the exact point where the pointer came in, a filled circle growing until it covers the farthest corner, and the label turns to the field colour inside the flood. Leaving drains the flood back toward the point where the pointer left. Use it for a secondary or primary call to action that should answer the hand. Not for icon buttons: below 96 × 32 it stays static. ### 2. Behaviour timeline - **Idle:** the outline and the resting label. Nothing runs: no loops, no listeners doing work. - **Hover-in:** a circle centred on the entry point grows to the farthest corner over 320 ms on an ease-out; inside it the surface is ink and the label is field. - **Pointer move:** read on a passive listener once per frame; the flood keeps its entry centre; the box is read once on enter, never in the move handler. - **Hover-out:** a full flood re-centres on the leaving point and drains to nothing there in 240 ms on an ease-out; a flood caught half-way reverses about its own centre. Interruptible at any point. - **Reduced motion / slow display:** the flood is applied and removed in one step; nothing eases. - **Touch:** press — a finger down floods from where it lands, lifting it drains; nothing plays by itself. ### 3. Integration - Install: `npx shadcn@latest add https://sitesupply.dev/r/flood.json` (one self-contained React file at components/textures/flood.tsx), or write the file `get_texture` returns exactly as returned, or use the code in this Component's markdown twin (/s/flood.md). - Mount it inside the project's own `
``` #### contained A card, a tile, a stat, a panel: the host has a size and the Component fills it. ```css .host { position: relative; aspect-ratio: 4 / 3; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
``` ### Install - Command: `npx shadcn@latest add https://site-supply.vercel.app/r/spotlight.json` (one self-contained React file at components/textures/spotlight.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/spotlight` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "spotlight" })` (server: /mcp) - Markdown: /s/spotlight.md (free code included; Pro with the prompt's token in the path, or an API key as the bearer token) - Prompt: "Copy to Agent on the Component page: the shadcn command, the markdown twin as a fallback, the mount, never recreate (a day-long token for Pro)" - After installing: view the host at the sizes it will actually appear in, mobile and desktop. - Suggested commit: `feat: add Spotlight component (site-supply.vercel.app/s/spotlight)` (a suggestion the agent and the human can edit, never a write). ### Code Write these files into the project, then mount the component on a host with position: relative and a size. #### React #### `components/textures/spotlight.tsx` ```tsx // Spotlight — a Component by Site Supply · https://sitesupply.dev/s/spotlight // Install another: npx shadcn@latest add https://sitesupply.dev/r/.json // For agents: https://sitesupply.dev/llms.txt // License: free to use "use client" import { type CSSProperties, useEffect, useRef } from "react" function selfSize(root: HTMLElement | null): void { const host = root?.parentElement if (!root || !host || host.getBoundingClientRect().height > 0) return root.style.minHeight = "240px" } type Pointer = { x: number y: number active: number reduced: boolean w: number h: number } const AMBIENT_SPEED = 1.5 const AMBIENT_CYCLE = 4.5 function engine( root: HTMLElement, draw: (p: Pointer, dt: number) => boolean | void, min: [number, number], ): () => void { const mq = () => matchMedia("(prefers-reduced-motion: reduce)").matches || matchMedia("(update: slow)").matches const touchMq = matchMedia("(hover: none), (pointer: coarse)") const wantsAmbient = () => root.closest("[data-motion]")?.getAttribute("data-motion") === "ambient" const w = (2 * Math.PI) / AMBIENT_CYCLE const phase = Math.random() * 2 * Math.PI let onScreen = false let ambient = false let clock = 0 let hold = 0 let box: { left: number; top: number; w: number; h: number } | null = null let px = 0 let py = 0 let cx = 0 let cy = 0 let active = 0 let target = 0 let vel = 0 let frame = 0 let last = 0 let lastMoveAt = 0 const measure = () => { const r = root.getBoundingClientRect() box = r.width < min[0] || r.height < min[1] ? null : { left: r.left, top: r.top, w: r.width, h: r.height } } const tick = (now: number) => { const dt = Math.min(last ? (now - last) / 1000 : 1 / 60, 1 / 30) last = now let moving = false const reduced = mq() if (ambient && box && now > hold) { if (reduced) target = 0 else { clock += dt * AMBIENT_SPEED const t = clock px = box.w / 2 + (box.w / 2) * (0.76 * Math.sin(w * t + phase) + 0.2 * Math.sin(1.93 * w * t + 1.2 + phase)) py = box.h / 2 + (box.h / 2) * (0.72 * Math.sin(0.75 * w * t + 0.8 + phase) + 0.2 * Math.sin(1.37 * w * t + 2.1 + phase)) target = 1 moving = true } } if (reduced) { active = target vel = 0 } else { const decay = Math.exp(-30 * dt) const off = active - target const lead = vel + 30 * off active = target + (off + lead * dt) * decay vel = (vel - 30 * lead * dt) * decay if (Math.abs(vel) < 0.02 && Math.abs(active - target) < 0.02) { active = target vel = 0 } else moving = true } const more = draw({ x: px, y: py, active, reduced, w: box?.w ?? 0, h: box?.h ?? 0 }, dt) if (moving || more) frame = requestAnimationFrame(tick) else { frame = 0 last = 0 } } const wake = () => { if (!frame) frame = requestAnimationFrame(tick) } const isTouch = (e: Event) => (e as PointerEvent).pointerType === "touch" && touchMq.matches const at = (e: PointerEvent) => { if (!box) return false cx = e.clientX cy = e.clientY px = cx - box.left py = cy - box.top return true } const onEnter = (e: PointerEvent) => { if (isTouch(e)) return measure() } const onMove = (e: PointerEvent) => { if (isTouch(e)) return const t = performance.now() if (!box || t - lastMoveAt > 250) measure() lastMoveAt = t if (!at(e)) return if (!e.isTrusted) hold = t + 1000 target = 1 wake() } const onLeave = (e?: Event) => { if (e && isTouch(e)) return if (e && !e.isTrusted) hold = 0 if (ambient && performance.now() > hold) return target = 0 wake() } const onDown = (e: PointerEvent) => { if (!isTouch(e)) return measure() if (!at(e)) return target = 1 wake() } const onUp = (e: PointerEvent) => { if (!isTouch(e) || ambient) return target = 0 wake() } const onFocus = () => { measure() if (!box) return px = box.w / 2 py = box.h / 2 target = 1 wake() } const onResize = () => { if (box || ambient) measure() if (ambient && !box) ambient = false settle() } const onScroll = () => { if (!box || target !== 1 || (ambient && performance.now() > hold)) return measure() if (!box) return px = cx - box.left py = cy - box.top wake() } const settle = () => { const next = onScreen && !document.hidden && wantsAmbient() if (next === ambient) return ambient = next if (ambient) measure() if (ambient && !box) ambient = false if (!ambient) target = 0 wake() } const io = typeof IntersectionObserver === "function" ? new IntersectionObserver( ([entry]) => { onScreen = !!entry?.isIntersecting settle() }, { threshold: 0.2 }, ) : null io?.observe(root) const onVisibility = () => settle() const reducedMq = matchMedia("(prefers-reduced-motion: reduce)") const onReduced = () => wake() document.addEventListener("visibilitychange", onVisibility) reducedMq.addEventListener?.("change", onReduced) const host: Element = root.closest("a, button, [tabindex]") ?? root.parentElement ?? root root.addEventListener("pointerenter", onEnter) root.addEventListener("pointermove", onMove, { passive: true }) root.addEventListener("pointerleave", onLeave) root.addEventListener("pointerdown", onDown, { passive: true }) root.addEventListener("pointerup", onUp) root.addEventListener("pointercancel", onUp) host.addEventListener("focusin", onFocus) host.addEventListener("focusout", onLeave) window.addEventListener("resize", onResize, { passive: true }) window.addEventListener("scroll", onScroll, { passive: true, capture: true }) return () => { root.removeEventListener("pointerenter", onEnter) root.removeEventListener("pointermove", onMove) root.removeEventListener("pointerleave", onLeave) root.removeEventListener("pointerdown", onDown) root.removeEventListener("pointerup", onUp) root.removeEventListener("pointercancel", onUp) host.removeEventListener("focusin", onFocus) host.removeEventListener("focusout", onLeave) window.removeEventListener("resize", onResize) window.removeEventListener("scroll", onScroll, { capture: true }) document.removeEventListener("visibilitychange", onVisibility) reducedMq.removeEventListener?.("change", onReduced) io?.disconnect() if (frame) cancelAnimationFrame(frame) } } export type SpotlightProps = { title?: string body?: string tokens?: Partial> className?: string static?: boolean } const EASE = 0.25 const DISC = 1.2 const HIDDEN: CSSProperties = { position: "absolute", clipPath: "inset(50%)", whiteSpace: "nowrap" } const RTL = /[֐-ࣿיִ-﷿ﹰ-]/ export function Spotlight({ title = "Ship faster", body = "Everything your team needs to launch, in one place.", tokens, className = "", static: isStatic = false, }: SpotlightProps) { const ref = useRef(null) useEffect(() => selfSize(ref.current), []) const disc = useRef(null) const dir = RTL.test(title + body) ? "rtl" : "ltr" useEffect(() => { const root = ref.current const el = disc.current if (!root || !el || isStatic) return let lit = false let sx = 0 let sy = 0 return engine( root, ({ x, y, active, reduced, w, h }, dt) => { if (active === 0) { if (lit) { lit = false el.style.opacity = "" el.style.transform = "" } return false } if (!lit) { lit = true sx = x sy = y } const k = reduced ? 1 : 1 - (1 - EASE) ** (dt * 60) sx += (x - sx) * k sy += (y - sy) * k if (Math.abs(x - sx) < 0.1) sx = x if (Math.abs(y - sy) < 0.1) sy = y const r = (DISC * Math.min(w, h)) / 2 el.style.transform = `translate(${(sx + r).toFixed(1)}px, ${(sy + r).toFixed(1)}px)` el.style.opacity = active.toFixed(3) return sx !== x || sy !== y }, [200, 140], ) }, [isStatic]) const style = { "--texture-ink": "var(--foreground)", "--texture-field": "var(--background)", ...tokens, color: "var(--texture-ink)", background: "var(--texture-field)", borderRadius: "min(16px, 6cqmin)", } as CSSProperties const titleSize = "min(9cqb, 7cqi)" return (
{title}. {body}