Textures (textures.dev) is the hover-texture library for agents. Install a free Texture in one command, no setup: `npx shadcn@latest add https://textures.dev/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 Textures: Copy to Agent on the Texture page gives an entitled user a prompt whose URLs carry a day-long token; otherwise register the @textures registry with a Textures API key as its bearer header in components.json (see /agents) and run `npx shadcn@latest add @textures/`. # Textures > Textures is a library of animated, hover-reactive brand textures for websites. 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 Textures; the rest with Pro. ## Textures (free first) - settle (free; use when: hero/background, calm): Bars gather toward the pointer within their columns and settle back when it leaves. /s/settle.md - magnet (free; use when: hero/background/card, playful): Dots near the pointer snap together into a solid block, the field around leans toward it out to eleven cells, and all spring back to the grid when it leaves. /s/magnet.md - glow (free; use when: background/card, calm): The bar under the pointer lights up and grows taller; the bars around it step down in a rectangle four cells wide, and all dim again when it leaves. /s/glow.md - tiles (free; use when: background/card/hero, calm): The four tiles under the pointer light up and the rings around them step down in a square of light, cell by cell; all dim again when it leaves. /s/tiles.md - gaze (free; use when: background/hero/card, playful): Dashes under the pointer light up and turn to point at it; the ink falls 10 % per cell to the dim field, and all ease back when it leaves. /s/gaze.md - beam (free; use when: background/hero/card, calm): A beam glides after the pointer: the lines under it swell into bars and part to make room, and all settle back to hairlines when it leaves. /s/beam.md - warp (free; use when: hero/background/image tile, bold): Small rectangular tiles grow wider and taller by horizontal and vertical distance to the pointer, trailing it, then spring back when it leaves. /s/warp.md - current (free; use when: hero/background/card, calm): Dashes near the pointer turn to lie along the direction it moves and relax back to flat as it passes. /s/current.md - curtain (free; use when: hero/background/card, bold): The stripes twist open around the pointer like ribbons, the gaps opening as long tapered spindles, and hang straight again when it leaves. /s/curtain.md - pillars (free; use when: hero/background/card, playful): The cylinders under the pointer rise in a smoothed pyramid, most beneath it and less around, bodies showing between the caps, and settle back as it moves. /s/pillars.md - pinch (free; use when: hero/background/image tile, calm): Every cell is filled by its square. Near the pointer the squares shrink to the smallest unit in a soft dome, then fill their cells again when it leaves. /s/pinch.md - sphere (free; use when: hero/background/card, bold): Inside a ball under the pointer the grid climbs a hill: dots slide outward, swell at the top and shrink to points on the dark side, away from the motion. /s/sphere.md - twist (free; use when: hero/background/card, bold): Every bar turns to point at the pointer and is as thick as its line to it is straight: a cross through the cursor with a twist in each quadrant. /s/twist.md - watch (free; use when: hero/background/card, playful): The dots fill their cells under the pointer, most at the cursor and less with distance, each seated toward it in its cell; the field ripples out in step. /s/watch.md - swell (pro) /s/swell.md - lift (pro) /s/lift.md - bloom (pro) /s/bloom.md - cells (pro) /s/cells.md - quatrefoil (pro) /s/quatrefoil.md - blinds (pro) /s/blinds.md - fill (pro) /s/fill.md - flip (pro) /s/flip.md - fork (pro) /s/fork.md - gears (pro) /s/gears.md - iris (pro) /s/iris.md - mountain (pro) /s/mountain.md - pop (pro) /s/pop.md - rise (pro) /s/rise.md - shear (pro) /s/shear.md - shutter (pro) /s/shutter.md ## Vocabulary The terms (hover texture, brand texture, Texture, Displace, Clear, Glow, pattern families, fit, Light) are defined on /agents.md. ## Agent surfaces - Every page answers `Accept: text/markdown`; every Texture has a .md twin at /s/.md; free Textures have a live preview at /embed/ (a Pro preview opens for a Pro session) - Browse: /textures?q=&category=&tag=&light=1&sort=featured|newest|name (the same query as /api/textures?… in JSON) - Every published Texture's twin, with the free code, in one file: /llms-full.txt - MCP: /mcp (OAuth 2.1; API keys accepted as bearer tokens) - Setup for Claude Code, Codex, Cursor and shadcn: /agents (markdown: /agents.md) - shadcn registry: /r/registry.json lists every Texture, /r/.json is one item (`npx shadcn@latest add @textures/`; Pro with an API key as the bearer token) - The rule for every Texture: never set width or height on the Texture; size the host. ## Every Texture ## Settle > For agents: this file is the source. Write the file under Code exactly as given, at the path it names, and mount the component as Placement says; do not recreate or approximate the animation. Twenty columns of thin bars, denser toward the floor on the left and the ceiling on the right. Under the pointer the bars gather toward it along their columns and relax when it leaves. - Slug: `settle` · Category: lines · Free (MIT) · v1.1.0 - What moves: Bars gather toward the pointer within their columns and settle back when it leaves. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: field, pull, magnet · Use cases: hero, background · Tags: bars, columns, gather - Rails: Measured 2026-10-26 for 1.1.0: 1.68 KB js, p95 7.18 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 3867 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the bars and dots - `--texture-field` (color, default `var(--background)`): The panel behind them ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 112 px up; keeps 732/516 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 732 / 516; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Settle
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/settle.json` (one self-contained React file at components/textures/settle.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/settle` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "settle" })` (server: /mcp) - Markdown: /s/settle.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 Texture 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 Settle hover texture (textures.dev/s/settle)` (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/settle.tsx` ```tsx // Settle — a Texture by Textures · https://textures.dev/s/settle // Install another: npx shadcn@latest add https://textures.dev/r/.json // For agents: https://textures.md // 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 } const AMBIENT_SPEED = 1.5 const AMBIENT_CYCLE = 4.5 type EngineOptions = { ambientSpeed?: number } function engine( root: HTMLElement, draw: (p: Pointer, dt: number) => boolean | void, min: [number, number], options: EngineOptions = {}, ): () => void { const svg = root.querySelector("svg") if (!svg) return () => {} const mq = () => matchMedia("(prefers-reduced-motion: reduce)").matches || matchMedia("(update: slow)").matches const touchMq = matchMedia("(hover: none), (pointer: coarse)") const forced = () => root.closest("[data-motion]")?.getAttribute("data-motion") ?? null const wantsAmbient = () => (forced() ? forced() === "ambient" : touchMq.matches) const speed = AMBIENT_SPEED * (options.ambientSpeed ?? 1) 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; s: number; ox: number; oy: number } | null = null let px = 366 let py = 258 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 = svg.getBoundingClientRect() if (r.width < min[0] || r.height < min[1]) { box = null return } const s = Math.max(r.width / 732, r.height / 516) box = { left: r.left, top: r.top, s, ox: (r.width - 732 * s) / 2, oy: (r.height - 516 * s) / 2 } } 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 && now > hold) { if (reduced) target = 0 else { clock += dt * speed const t = clock px = 366 + 366 * (0.76 * Math.sin(w * t + phase) + 0.2 * Math.sin(1.93 * w * t + 1.2 + phase)) py = 258 + 258 * (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 }, dt) if (moving || more) frame = requestAnimationFrame(tick) else { frame = 0 last = 0 } } const wake = () => { if (!frame) frame = requestAnimationFrame(tick) } const touchIsAmbient = (e: Event) => (e as PointerEvent).pointerType === "touch" && touchMq.matches const onEnter = (e: PointerEvent) => { if (touchIsAmbient(e)) return measure() } const onMove = (e: PointerEvent) => { if (touchIsAmbient(e)) return const t = performance.now() if (!box || t - lastMoveAt > 250) measure() lastMoveAt = t if (!box) return if (!e.isTrusted) hold = performance.now() + 1000 cx = e.clientX cy = e.clientY px = (cx - box.left - box.ox) / box.s py = (cy - box.top - box.oy) / box.s target = 1 wake() } const onLeave = (e?: Event) => { if (e && touchIsAmbient(e)) return if (e && !e.isTrusted) hold = 0 if (ambient && performance.now() > hold) return target = 0 wake() } const onFocus = () => { measure() if (!box) return px = 366 py = 258 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 - box.ox) / box.s py = (cy - box.top - box.oy) / box.s wake() } const settle = () => { const next = onScreen && !document.hidden && wantsAmbient() if (next === ambient) return ambient = next if (ambient) { measure() if (!box) { ambient = false return } wake() } else { target = 0 wake() } } const io = typeof IntersectionObserver === "function" ? new IntersectionObserver( ([entry]) => { onScreen = !!entry?.isIntersecting settle() }, { threshold: 0.2 }, ) : null io?.observe(svg) if (!io) { onScreen = true settle() } const onVisibility = () => settle() const onCapability = () => settle() const reducedMq = matchMedia("(prefers-reduced-motion: reduce)") const onReduced = () => wake() document.addEventListener("visibilitychange", onVisibility) touchMq.addEventListener?.("change", onCapability) 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("pointercancel", onLeave) 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("pointercancel", onLeave) host.removeEventListener("focusin", onFocus) host.removeEventListener("focusout", onLeave) window.removeEventListener("resize", onResize) window.removeEventListener("scroll", onScroll, { capture: true }) document.removeEventListener("visibilitychange", onVisibility) touchMq.removeEventListener?.("change", onCapability) reducedMq.removeEventListener?.("change", onReduced) io?.disconnect() if (frame) cancelAnimationFrame(frame) } } const W = 732 const H = 516 const BAR_W = 31.287 const BAR_H = 8.683 const COLS: [number, number[]][] = [ [17, [47.17, 100.05, 150.79, 197.26, 241.59, 282.18, 320.1, 353.22, 383.13, 411.44, 434.4, 455.23, 473.93, 491.29]], [52.04, [64.54, 116.88, 167.09, 214.62, 257.35, 299.55, 336.4, 370.58, 401.56, 428.8, 452.3, 473.67, 491.29]], [87.08, [26.61, 81.09, 135.57, 186.32, 232.78, 277.11, 318.78, 355.63, 388.74, 419.72, 448.03, 469.93, 491.29]], [122.11, [49.58, 103.53, 156.4, 207.68, 254.68, 298.48, 340.14, 376.46, 410.64, 441.09, 469.39, 491.29]], [157.15, [73.08, 129.7, 183.64, 233.32, 280.85, 323.05, 365.24, 401.56, 437.35, 467.26, 491.29]], [192.19, [42.1, 102.46, 158.01, 211.42, 262.16, 308.63, 352.96, 392.48, 432.01, 464.59, 491.29]], [227.23, [73.08, 132.9, 188.98, 242.93, 293.67, 341.21, 383.4, 424.53, 462.45, 491.29]], [262.26, [44.24, 104.6, 166.02, 222.1, 276.05, 325.72, 373.25, 416.52, 458.18, 491.29]], [297.3, [17, 81.09, 144.12, 202.34, 257.89, 363.11, 408.51, 450.7, 491.29]], [332.34, [54.92, 120.62, 184.18, 245.07, 300.08, 351.89, 403.17, 448.56, 491.29]], [367.38, [17, 59.73, 105.13, 156.4, 208.21, 263.23, 324.11, 387.67, 453.37]], [402.41, [17, 57.59, 99.79, 145.19, 250.41, 305.96, 364.17, 427.2, 491.29]], [437.45, [17, 50.11, 91.78, 135.04, 182.57, 232.25, 286.19, 342.28, 403.7, 464.05]], [472.49, [17, 45.84, 83.76, 124.89, 167.09, 214.62, 265.36, 319.31, 375.39, 435.21]], [507.53, [17, 43.7, 76.29, 115.81, 155.33, 199.67, 246.13, 296.87, 350.29, 405.83, 466.19]], [542.56, [17, 41.04, 70.95, 106.73, 143.05, 185.25, 227.44, 274.98, 324.65, 378.6, 435.21]], [577.6, [17, 38.9, 67.21, 97.65, 131.83, 168.15, 209.82, 253.61, 300.61, 351.89, 404.77, 458.71]], [612.64, [17, 38.36, 60.26, 88.57, 119.55, 152.66, 189.52, 231.18, 275.51, 321.98, 372.72, 427.2, 481.68]], [647.68, [17, 34.63, 55.99, 79.49, 106.73, 137.71, 171.89, 208.75, 250.94, 293.67, 341.21, 391.41, 443.76]], [682.71, [17, 34.36, 53.06, 73.89, 96.86, 125.16, 155.08, 188.19, 226.11, 266.71, 311.04, 357.5, 408.24, 461.12]], ] export type SettleProps = { tokens?: Partial> className?: string static?: boolean } const GAP = BAR_H + 2 const SIGMA_X = 90 const SIGMA_Y = 130 const PULL = 0.85 const EASE = 0.32 export function Settle({ tokens, className = "", static: isStatic = false }: SettleProps) { const ref = useRef(null) useEffect(() => selfSize(ref.current), []) const rects = useRef<(SVGRectElement | null)[][]>(COLS.map(() => [])) useEffect(() => { const root = ref.current if (!root || isStatic) return const ys = COLS.map(([, tops]) => [...tops]) const targets = COLS.map(([, tops]) => [...tops]) return engine( root, ({ x, y, active, reduced }, dt) => { let moving = false const k = reduced ? 1 : 1 - (1 - EASE) ** (dt * 60) COLS.forEach(([cx, tops], i) => { const t = targets[i]! const cur = ys[i]! const dx = cx + BAR_W / 2 - x const wx = Math.exp(-(dx * dx) / (2 * SIGMA_X * SIGMA_X)) tops.forEach((top, j) => { const dy = y - (top + BAR_H / 2) const wy = Math.exp(-(dy * dy) / (2 * SIGMA_Y * SIGMA_Y)) t[j] = top + dy * wx * wy * PULL * active }) for (let j = 1; j < t.length; j++) if (t[j]! < t[j - 1]! + GAP) t[j] = t[j - 1]! + GAP for (let j = t.length - 2; j >= 0; j--) if (t[j]! > t[j + 1]! - GAP) t[j] = t[j + 1]! - GAP for (let j = 0; j < t.length; j++) { const d = t[j]! - cur[j]! const el = rects.current[i]![j] if (Math.abs(d) < 0.03) { if (cur[j] !== t[j]) { cur[j] = t[j]! if (el) el.style.transform = `translateY(${(cur[j]! - tops[j]!).toFixed(2)}px)` } continue } cur[j] = cur[j]! + d * k if (el) el.style.transform = `translateY(${(cur[j]! - tops[j]!).toFixed(2)}px)` moving = true } }) return moving }, [160, 112], ) }, [isStatic]) const style = { "--texture-ink": "var(--foreground)", "--texture-field": "var(--background)", ...tokens, color: "var(--texture-ink)", background: "var(--texture-field)", } as CSSProperties return (
) } ```. Live preview: /embed/settle. ### Agent brief ### 1. What it is Twenty columns of thin bars, denser toward the floor on the left and the ceiling on the right. Under the pointer the bars gather toward it along their columns, never overlapping, and relax when it leaves. Use it as a hero or background panel, or inside a card, wherever a quiet field of marks can answer the pointer. Not for small icons: below 160 × 112 it stays static. ### 2. Behaviour timeline - **Idle:** the resting field. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; bars gather under the pointer along their columns. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. - **Hover-out:** the weight springs back to 0 and every piece eases home from wherever it is; interruptible at any point. - **Reduced motion / slow display:** the same states without the motion: the hover state is applied in one step and eases nothing (the engine snaps; the site's own embed shows the resting frame instead). - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: `npx shadcn@latest add https://textures.dev/r/settle.json` (one self-contained React file at components/textures/settle.tsx), or write the file `get_texture` returns exactly as returned, or use the code in this Texture's markdown twin (/s/settle.md). - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the 732 × 516 panel covers the host and crops at the edges), `contain` available; works from 160 × 112 up; clips. - Tokens on the root: `--texture-ink` (the marks) and `--texture-field` (the panel); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the spring, the reach of the pull or the reduced-motion branch. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Beam (background) · Swell (hero, pro) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/beam.json --- ## Magnet > For agents: this file is the source. Write the file under Code exactly as given, at the path it names, and mount the component as Placement says; do not recreate or approximate the animation. A field of small dots. The pointer is a magnet: the twenty-five nearest dots leave their cells and snap together into one solid block under it, then spring back out when it moves on or leaves. - Slug: `magnet` · Category: grids · Free (MIT) · v1.2.0 - What moves: Dots near the pointer snap together into a solid block, the field around leans toward it out to eleven cells, and all spring back to the grid when it leaves. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: snap, cluster, attract · Use cases: hero, background, card · Tags: dots, grid, magnet - Rails: Measured 2026-10-26 for 1.2.0: 3.46 KB js, p95 24.95 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 4562 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the bars and dots - `--texture-field` (color, default `var(--background)`): The panel behind them ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 112 px up; keeps 732/516 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 732 / 516; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Magnet
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/magnet.json` (one self-contained React file at components/textures/magnet.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/magnet` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "magnet" })` (server: /mcp) - Markdown: /s/magnet.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 Texture 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 Magnet hover texture (textures.dev/s/magnet)` (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/magnet.tsx` ```tsx // Magnet — a Texture by Textures · https://textures.dev/s/magnet // Install another: npx shadcn@latest add https://textures.dev/r/.json // For agents: https://textures.md // License: free to use "use client" import { type CSSProperties, useEffect, useId, useRef, useSyncExternalStore } 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 } const AMBIENT_SPEED = 1.5 const AMBIENT_CYCLE = 4.5 type EngineOptions = { ambientSpeed?: number } function engine( root: HTMLElement, draw: (p: Pointer, dt: number) => boolean | void, min: [number, number], options: EngineOptions = {}, ): () => void { const svg = root.querySelector("svg") if (!svg) return () => {} const mq = () => matchMedia("(prefers-reduced-motion: reduce)").matches || matchMedia("(update: slow)").matches const touchMq = matchMedia("(hover: none), (pointer: coarse)") const forced = () => root.closest("[data-motion]")?.getAttribute("data-motion") ?? null const wantsAmbient = () => (forced() ? forced() === "ambient" : touchMq.matches) const speed = AMBIENT_SPEED * (options.ambientSpeed ?? 1) 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; s: number; ox: number; oy: number } | null = null let px = 366 let py = 258 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 = svg.getBoundingClientRect() if (r.width < min[0] || r.height < min[1]) { box = null return } const s = Math.max(r.width / 732, r.height / 516) box = { left: r.left, top: r.top, s, ox: (r.width - 732 * s) / 2, oy: (r.height - 516 * s) / 2 } } 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 && now > hold) { if (reduced) target = 0 else { clock += dt * speed const t = clock px = 366 + 366 * (0.76 * Math.sin(w * t + phase) + 0.2 * Math.sin(1.93 * w * t + 1.2 + phase)) py = 258 + 258 * (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 }, dt) if (moving || more) frame = requestAnimationFrame(tick) else { frame = 0 last = 0 } } const wake = () => { if (!frame) frame = requestAnimationFrame(tick) } const touchIsAmbient = (e: Event) => (e as PointerEvent).pointerType === "touch" && touchMq.matches const onEnter = (e: PointerEvent) => { if (touchIsAmbient(e)) return measure() } const onMove = (e: PointerEvent) => { if (touchIsAmbient(e)) return const t = performance.now() if (!box || t - lastMoveAt > 250) measure() lastMoveAt = t if (!box) return if (!e.isTrusted) hold = performance.now() + 1000 cx = e.clientX cy = e.clientY px = (cx - box.left - box.ox) / box.s py = (cy - box.top - box.oy) / box.s target = 1 wake() } const onLeave = (e?: Event) => { if (e && touchIsAmbient(e)) return if (e && !e.isTrusted) hold = 0 if (ambient && performance.now() > hold) return target = 0 wake() } const onFocus = () => { measure() if (!box) return px = 366 py = 258 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 - box.ox) / box.s py = (cy - box.top - box.oy) / box.s wake() } const settle = () => { const next = onScreen && !document.hidden && wantsAmbient() if (next === ambient) return ambient = next if (ambient) { measure() if (!box) { ambient = false return } wake() } else { target = 0 wake() } } const io = typeof IntersectionObserver === "function" ? new IntersectionObserver( ([entry]) => { onScreen = !!entry?.isIntersecting settle() }, { threshold: 0.2 }, ) : null io?.observe(svg) if (!io) { onScreen = true settle() } const onVisibility = () => settle() const onCapability = () => settle() const reducedMq = matchMedia("(prefers-reduced-motion: reduce)") const onReduced = () => wake() document.addEventListener("visibilitychange", onVisibility) touchMq.addEventListener?.("change", onCapability) 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("pointercancel", onLeave) 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("pointercancel", onLeave) host.removeEventListener("focusin", onFocus) host.removeEventListener("focusout", onLeave) window.removeEventListener("resize", onResize) window.removeEventListener("scroll", onScroll, { capture: true }) document.removeEventListener("visibilitychange", onVisibility) touchMq.removeEventListener?.("change", onCapability) reducedMq.removeEventListener?.("change", onReduced) io?.disconnect() if (frame) cancelAnimationFrame(frame) } } const W = 732 const H = 516 const COLS = 51 const ROWS = 35 const X0 = 18 const Y0 = 18 const DX = 13.84 const DY = 14 const DOT = 4 const REACH = 2 const DRIFT = 11 const PULL = 5.5 const PACK = 3.7 const POOL = 384 const FIELD_W = (COLS - 1) * DX + DOT const FIELD_H = (ROWS - 1) * DY + DOT export type MagnetProps = { tokens?: Partial> className?: string static?: boolean } type Slot = { cell: number hx: number hy: number x: number y: number vx: number vy: number tx: number ty: number k: number d: number i: number } const STIFFNESS = 760 const ZETA = 0.66 const SPREAD = 0.35 const noise = (seed: number) => { let t = (seed + 0x6d2b79f5) | 0 t = Math.imul(t ^ (t >>> 15), t | 1) t ^= t + Math.imul(t ^ (t >>> 7), t | 61) return ((t ^ (t >>> 14)) >>> 0) / 4294967296 } const homeX = (c: number) => X0 + c * DX const homeY = (r: number) => Y0 + r * DY const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)) const lean = (d: number) => { const u = clamp((DRIFT - d) / (DRIFT - REACH), 0, 1) return PULL * u * u * (3 - 2 * u) } const subscribeNever = () => () => {} export function Magnet({ tokens, className = "", static: isStatic = false }: MagnetProps) { const ref = useRef(null) useEffect(() => selfSize(ref.current), []) const dots = useRef<(SVGRectElement | null)[]>([]) const masks = useRef<(SVGRectElement | null)[]>([]) const poolLive = useSyncExternalStore( subscribeNever, () => true, () => false, ) && !isStatic const uid = useId().replace(/[^a-zA-Z0-9]/g, "") const patternId = `magnet-dots-${uid}` const maskId = `magnet-hole-${uid}` useEffect(() => { const root = ref.current if (!root || isStatic) return const slots: Slot[] = Array.from({ length: POOL }, (_, i) => ({ cell: -1, hx: 0, hy: 0, x: 0, y: 0, vx: 0, vy: 0, tx: 0, ty: 0, k: 0, d: 0, i, })) const byCell = new Map() let prev = -1 const acquire = (cell: number): Slot | null => { const have = byCell.get(cell) if (have) return have let s = slots.find((t) => t.cell === -1) ?? null if (!s) { for (const t of slots) if ( t.tx === t.hx && t.ty === t.hy && (!s || Math.hypot(t.x - t.hx, t.y - t.hy) < Math.hypot(s.x - s.hx, s.y - s.hy)) ) s = t if (!s) return null byCell.delete(s.cell) } const c = cell % COLS const r = Math.floor(cell / COLS) s.cell = cell s.hx = s.x = s.tx = homeX(c) s.hy = s.y = s.ty = homeY(r) s.vx = s.vy = 0 s.k = STIFFNESS * (1 + SPREAD * (noise(cell) * 2 - 1)) s.d = 2 * ZETA * Math.sqrt(s.k) byCell.set(cell, s) const m = masks.current[s.i] const el = dots.current[s.i] if (m) { m.setAttribute("x", (s.hx - 0.75).toFixed(2)) m.setAttribute("y", (s.hy - 0.75).toFixed(2)) m.style.opacity = "1" } if (el) { el.setAttribute("x", s.hx.toFixed(2)) el.setAttribute("y", s.hy.toFixed(2)) el.style.transform = "" el.style.opacity = "1" } return s } const release = (s: Slot) => { byCell.delete(s.cell) s.cell = -1 const m = masks.current[s.i] const el = dots.current[s.i] if (m) m.style.opacity = "0" if (el) { el.style.opacity = "0" el.style.transform = "" } } return engine( root, ({ x, y, active, reduced }, dt) => { const on = active > 0.01 const mx = clamp(x, homeX(REACH) + DOT / 2, homeX(COLS - 1 - REACH) + DOT / 2) const my = clamp(y, homeY(REACH) + DOT / 2, homeY(ROWS - 1 - REACH) + DOT / 2) const cc = on ? Math.round((mx - DOT / 2 - X0) / DX) : -1 const cr = on ? Math.round((my - DOT / 2 - Y0) / DY) : -1 const key = cr * COLS + cc if (key !== prev) { for (const s of byCell.values()) { s.tx = s.hx s.ty = s.hy } prev = key } if (on) { for (let i = -DRIFT; i <= DRIFT; i++) { for (let j = -DRIFT; j <= DRIFT; j++) { const c = cc + i const r = cr + j if (c < 0 || c >= COLS || r < 0 || r >= ROWS) continue const d = Math.hypot(i, j) if (d > DRIFT) continue const s = acquire(r * COLS + c) if (!s) continue if (Math.abs(i) <= REACH && Math.abs(j) <= REACH) { s.tx = mx + i * PACK - DOT / 2 s.ty = my + j * PACK - DOT / 2 } else { const ox = mx - DOT / 2 - s.hx const oy = my - DOT / 2 - s.hy const len = Math.hypot(ox, oy) || 1 const l = lean(d) s.tx = s.hx + (ox / len) * l s.ty = s.hy + (oy / len) * l } } } } let moving = false for (const s of [...byCell.values()]) { if (reduced) { s.x = s.tx s.y = s.ty s.vx = s.vy = 0 } else { const steps = Math.ceil(dt * 240) const h = dt / steps for (let n = 0; n < steps; n++) { s.vx += (-s.k * (s.x - s.tx) - s.d * s.vx) * h s.vy += (-s.k * (s.y - s.ty) - s.d * s.vy) * h s.x += s.vx * h s.y += s.vy * h } } const settled = Math.abs(s.x - s.tx) < 0.02 && Math.abs(s.y - s.ty) < 0.02 && Math.abs(s.vx) < 0.5 && Math.abs(s.vy) < 0.5 if (settled) { s.x = s.tx s.y = s.ty s.vx = s.vy = 0 if (s.tx === s.hx && s.ty === s.hy) { release(s) continue } } else moving = true const el = dots.current[s.i] if (el) el.style.transform = `translate(${(s.x - s.hx).toFixed(2)}px, ${(s.y - s.hy).toFixed(2)}px)` } return moving }, [160, 112], ) }, [isStatic]) const style = { "--texture-ink": "var(--foreground)", "--texture-field": "var(--background)", ...tokens, color: "var(--texture-ink)", background: "var(--texture-field)", } as CSSProperties return (
) } ```. Live preview: /embed/magnet. ### Agent brief ### 1. What it is A field of small dots. The pointer is a magnet: the twenty-five nearest dots leave their cells and snap together into one solid block under it, then spring back out when it moves on or leaves. Use it as a hero or background panel, or inside a card, wherever a quiet field of marks can answer the pointer. Not for small icons: below 160 × 112 it stays static. ### 2. Behaviour timeline - **Idle:** the resting field. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; the nearest dots pack into a block under the pointer. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. - **Hover-out:** the weight springs back to 0 and every piece eases home from wherever it is; interruptible at any point. - **Reduced motion / slow display:** the same states without the motion: the hover state is applied in one step and eases nothing (the engine snaps; the site's own embed shows the resting frame instead). - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: `npx shadcn@latest add https://textures.dev/r/magnet.json` (one self-contained React file at components/textures/magnet.tsx), or write the file `get_texture` returns exactly as returned, or use the code in this Texture's markdown twin (/s/magnet.md). - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the 732 × 516 panel covers the host and crops at the edges), `contain` available; works from 160 × 112 up; clips. - Tokens on the root: `--texture-ink` (the marks) and `--texture-field` (the panel); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the spring, the reach of the pull or the reduced-motion branch. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Glow (background) · Warp (hero) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/glow.json --- ## Swell Eighteen columns of hairline dashes in equal-gap stacks. Under the pointer the dashes thicken into a diamond-shaped bump that squeezes its neighbours; the grid flattens when it leaves. - Slug: `swell` · Category: lines · Pro (LicenseRef-Textures-Pro) · v1.1.0 - What moves: Dashes under the pointer swell up to four times their thickness and squeeze their neighbours; the grid flattens again when it leaves. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: bulge, squeeze, thicken · Use cases: hero, background, card · Tags: grid, dashes, bump - Rails: Measured 2026-10-27 for 1.1.0: 1.65 KB js, p95 9.35 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 3409 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the bars and dots - `--texture-field` (color, default `var(--background)`): The panel behind them ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 112 px up; keeps 732/516 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 732 / 516; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Swell
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/swell.json` (one self-contained React file at components/textures/swell.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/swell` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "swell" })` (server: /mcp) - Markdown: /s/swell.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 Texture 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 Swell hover texture (textures.dev/s/swell)` (a suggestion the agent and the human can edit, never a write). Code: Pro ($19 a month or $180 a year), not unlocked for this request. Agents: if the prompt gave you a link with a token in the path (/s//.md), fetch that exact link; dropping the token is why you see this. Otherwise tell the user it is a Pro Texture and give them https://textures.dev/checkout?texture=swell; never recreate it. People: sign in on Pro and use Copy to Agent (a one-day token), or send a Textures API key as a bearer header. ### Agent brief ### 1. What it is Eighteen columns of hairline dashes stacked with equal gaps. Under the pointer the dashes thicken into a diamond-shaped bump that squeezes its neighbours, and the grid flattens out again when it leaves. Use it as a hero or background panel, or inside a card, wherever a quiet field of marks can answer the pointer. Not for small icons: below 160 × 112 it stays static. ### 2. Behaviour timeline - **Idle:** the resting field. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; dashes thicken in a diamond under the pointer. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. - **Hover-out:** the weight springs back to 0 and every piece eases home from wherever it is; interruptible at any point. - **Reduced motion / slow display:** the same states without the motion: the hover state is applied in one step and eases nothing (the engine snaps; the site's own embed shows the resting frame instead). - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: `npx shadcn@latest add https://textures.dev/r/swell.json` (one self-contained React file at components/textures/swell.tsx), or write the file `get_texture` returns exactly as returned, or use the code in this Texture's markdown twin (/s/swell.md). - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the 732 × 516 panel covers the host and crops at the edges), `contain` available; works from 160 × 112 up; clips. - Tokens on the root: `--texture-ink` (the marks) and `--texture-field` (the panel); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the spring, the reach of the pull or the reduced-motion branch. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Settle (hero) · Beam (background) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/settle.json --- ## Glow > For agents: this file is the source. Write the file under Code exactly as given, at the path it names, and mount the component as Placement says; do not recreate or approximate the animation. A dim grid of bars. The bar under the pointer lights up to white and grows taller; the bars around it step down in a rectangle of light four cells wide. - Slug: `glow` · Category: grids · Free (MIT) · v1.2.0 - What moves: The bar under the pointer lights up and grows taller; the bars around it step down in a rectangle four cells wide, and all dim again when it leaves. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: spotlight, torch, reveal · Use cases: background, card · Tags: grid, bars, light - Rails: Measured 2026-10-26 for 1.2.0: 1.78 KB js, p95 5.72 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 3552 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the bars and dots - `--texture-field` (color, default `var(--background)`): The panel behind them ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 112 px up; keeps 732/516 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 732 / 516; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Glow
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/glow.json` (one self-contained React file at components/textures/glow.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/glow` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "glow" })` (server: /mcp) - Markdown: /s/glow.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 Texture 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 Glow hover texture (textures.dev/s/glow)` (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/glow.tsx` ```tsx // Glow — a Texture by Textures · https://textures.dev/s/glow // Install another: npx shadcn@latest add https://textures.dev/r/.json // For agents: https://textures.md // 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 } const AMBIENT_SPEED = 1.5 const AMBIENT_CYCLE = 4.5 type EngineOptions = { ambientSpeed?: number } function engine( root: HTMLElement, draw: (p: Pointer, dt: number) => boolean | void, min: [number, number], options: EngineOptions = {}, ): () => void { const svg = root.querySelector("svg") if (!svg) return () => {} const mq = () => matchMedia("(prefers-reduced-motion: reduce)").matches || matchMedia("(update: slow)").matches const touchMq = matchMedia("(hover: none), (pointer: coarse)") const forced = () => root.closest("[data-motion]")?.getAttribute("data-motion") ?? null const wantsAmbient = () => (forced() ? forced() === "ambient" : touchMq.matches) const speed = AMBIENT_SPEED * (options.ambientSpeed ?? 1) 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; s: number; ox: number; oy: number } | null = null let px = 366 let py = 258 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 = svg.getBoundingClientRect() if (r.width < min[0] || r.height < min[1]) { box = null return } const s = Math.max(r.width / 732, r.height / 516) box = { left: r.left, top: r.top, s, ox: (r.width - 732 * s) / 2, oy: (r.height - 516 * s) / 2 } } 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 && now > hold) { if (reduced) target = 0 else { clock += dt * speed const t = clock px = 366 + 366 * (0.76 * Math.sin(w * t + phase) + 0.2 * Math.sin(1.93 * w * t + 1.2 + phase)) py = 258 + 258 * (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 }, dt) if (moving || more) frame = requestAnimationFrame(tick) else { frame = 0 last = 0 } } const wake = () => { if (!frame) frame = requestAnimationFrame(tick) } const touchIsAmbient = (e: Event) => (e as PointerEvent).pointerType === "touch" && touchMq.matches const onEnter = (e: PointerEvent) => { if (touchIsAmbient(e)) return measure() } const onMove = (e: PointerEvent) => { if (touchIsAmbient(e)) return const t = performance.now() if (!box || t - lastMoveAt > 250) measure() lastMoveAt = t if (!box) return if (!e.isTrusted) hold = performance.now() + 1000 cx = e.clientX cy = e.clientY px = (cx - box.left - box.ox) / box.s py = (cy - box.top - box.oy) / box.s target = 1 wake() } const onLeave = (e?: Event) => { if (e && touchIsAmbient(e)) return if (e && !e.isTrusted) hold = 0 if (ambient && performance.now() > hold) return target = 0 wake() } const onFocus = () => { measure() if (!box) return px = 366 py = 258 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 - box.ox) / box.s py = (cy - box.top - box.oy) / box.s wake() } const settle = () => { const next = onScreen && !document.hidden && wantsAmbient() if (next === ambient) return ambient = next if (ambient) { measure() if (!box) { ambient = false return } wake() } else { target = 0 wake() } } const io = typeof IntersectionObserver === "function" ? new IntersectionObserver( ([entry]) => { onScreen = !!entry?.isIntersecting settle() }, { threshold: 0.2 }, ) : null io?.observe(svg) if (!io) { onScreen = true settle() } const onVisibility = () => settle() const onCapability = () => settle() const reducedMq = matchMedia("(prefers-reduced-motion: reduce)") const onReduced = () => wake() document.addEventListener("visibilitychange", onVisibility) touchMq.addEventListener?.("change", onCapability) 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("pointercancel", onLeave) 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("pointercancel", onLeave) host.removeEventListener("focusin", onFocus) host.removeEventListener("focusout", onLeave) window.removeEventListener("resize", onResize) window.removeEventListener("scroll", onScroll, { capture: true }) document.removeEventListener("visibilitychange", onVisibility) touchMq.removeEventListener?.("change", onCapability) reducedMq.removeEventListener?.("change", onReduced) io?.disconnect() if (frame) cancelAnimationFrame(frame) } } const W = 732 const H = 516 const COLS = 9 const ROWS = 24 const X0 = 17 const DX = 79 const Y0 = 14 const DY = 21 const BAR_W = 67 const BAR_H = 5 const BASE = 0.1 const LEVELS = [1, 0.8, 0.4, 0.2] const DIAG1 = 0.7 const GROW = 1.2 const EASE_MS = 45 export type GlowProps = { tokens?: Partial> className?: string static?: boolean } const level = (dc: number, dr: number): number => { const c = Math.max(dc, dr) if (c === 1 && dc === 1 && dr === 1) return DIAG1 return LEVELS[c] ?? BASE } export function Glow({ tokens, className = "", static: isStatic = false }: GlowProps) { const ref = useRef(null) useEffect(() => selfSize(ref.current), []) const rects = useRef<(SVGRectElement | null)[]>([]) const cur = useRef(new Float32Array(COLS * ROWS).fill(BASE)) const grow = useRef(0) const lit = useRef(-1) useEffect(() => { const root = ref.current if (!root || isStatic) return return engine( root, ({ x, y, active }, dt) => { const col = Math.min(COLS - 1, Math.max(0, Math.round((x - X0 - BAR_W / 2) / DX))) const row = Math.min(ROWS - 1, Math.max(0, Math.round((y - Y0 - BAR_H / 2) / DY))) const k = 1 - Math.exp((-dt * 1000) / EASE_MS) const v = cur.current let more = false for (let n = 0; n < COLS * ROWS; n++) { const t = BASE + (level(Math.abs((n % COLS) - col), Math.abs(Math.floor(n / COLS) - row)) - BASE) * active const next = v[n]! + (t - v[n]!) * k if (Math.abs(next - t) > 0.002) more = true if (Math.abs(next - v[n]!) < 0.0005) continue v[n] = next const el = rects.current[n] if (el) el.style.opacity = next.toFixed(3) } const litNow = row * COLS + col if (lit.current !== litNow && lit.current >= 0) { const old = rects.current[lit.current] if (old) old.style.transform = "" } lit.current = litNow const g = grow.current + (active - grow.current) * k if (Math.abs(g - active) > 0.002) more = true grow.current = g const el = rects.current[litNow] if (el) el.style.transform = g > 0.002 ? `scaleY(${(1 + GROW * g).toFixed(3)})` : "" return more }, [160, 112], ) }, [isStatic]) const style = { "--texture-ink": "var(--foreground)", "--texture-field": "var(--background)", ...tokens, color: "var(--texture-ink)", background: "var(--texture-field)", } as CSSProperties return (
) } ```. Live preview: /embed/glow. ### Agent brief ### 1. What it is A dim grid of bars. The bar under the pointer lights up to white and grows taller, and the bars around it take the design's levels by whole cells, a rectangle of light four cells wide that steps from cell to cell as the pointer moves. Use it as a hero or background panel, or inside a card, wherever a quiet field of marks can answer the pointer. Not for small icons: below 160 × 112 it stays static. ### 2. Behaviour timeline - **Idle:** the resting field. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; the bar under the pointer goes to full ink and grows from 5 to 11; its neighbours step to 80 % (70 % on the diagonals), then 40 %, then 20 %, then rest. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. - **Hover-out:** the weight springs back to 0 and every piece eases home from wherever it is; interruptible at any point. - **Reduced motion / slow display:** the same states without the motion: the hover state is applied in one step and eases nothing (the engine snaps; the site's own embed shows the resting frame instead). - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: `npx shadcn@latest add https://textures.dev/r/glow.json` (one self-contained React file at components/textures/glow.tsx), or write the file `get_texture` returns exactly as returned, or use the code in this Texture's markdown twin (/s/glow.md). - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the 732 × 516 panel covers the host and crops at the edges), `contain` available; works from 160 × 112 up; clips. - Tokens on the root: `--texture-ink` (the marks) and `--texture-field` (the panel); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the spring, the reach of the pull or the reduced-motion branch. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Magnet (hero) · Warp (hero) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/magnet.json --- ## Tiles > For agents: this file is the source. Write the file under Code exactly as given, at the path it names, and mount the component as Placement says; do not recreate or approximate the animation. A dim field of small tiles. The four tiles under the pointer light up to white and the rings around them step down in a square of light, cell by cell. - Slug: `tiles` · Category: grids · Free (MIT) · v1.1.0 - What moves: The four tiles under the pointer light up and the rings around them step down in a square of light, cell by cell; all dim again when it leaves. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: pixels, mosaic, lantern · Use cases: background, card, hero · Tags: grid, tiles, light - Rails: Measured 2026-10-26 for 1.1.0: 2.5 KB js, p95 7.88 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 3866 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the tiles - `--texture-field` (color, default `var(--background)`): The panel behind them ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 112 px up; keeps 732/516 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 732 / 516; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Tiles
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/tiles.json` (one self-contained React file at components/textures/tiles.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/tiles` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "tiles" })` (server: /mcp) - Markdown: /s/tiles.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 Texture 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 Tiles hover texture (textures.dev/s/tiles)` (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/tiles.tsx` ```tsx // Tiles — a Texture by Textures · https://textures.dev/s/tiles // Install another: npx shadcn@latest add https://textures.dev/r/.json // For agents: https://textures.md // License: free to use "use client" import { type CSSProperties, useEffect, useId, useRef, useSyncExternalStore } 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 } const AMBIENT_SPEED = 1.5 const AMBIENT_CYCLE = 4.5 type EngineOptions = { ambientSpeed?: number } function engine( root: HTMLElement, draw: (p: Pointer, dt: number) => boolean | void, min: [number, number], options: EngineOptions = {}, ): () => void { const svg = root.querySelector("svg") if (!svg) return () => {} const mq = () => matchMedia("(prefers-reduced-motion: reduce)").matches || matchMedia("(update: slow)").matches const touchMq = matchMedia("(hover: none), (pointer: coarse)") const forced = () => root.closest("[data-motion]")?.getAttribute("data-motion") ?? null const wantsAmbient = () => (forced() ? forced() === "ambient" : touchMq.matches) const speed = AMBIENT_SPEED * (options.ambientSpeed ?? 1) 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; s: number; ox: number; oy: number } | null = null let px = 366 let py = 258 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 = svg.getBoundingClientRect() if (r.width < min[0] || r.height < min[1]) { box = null return } const s = Math.max(r.width / 732, r.height / 516) box = { left: r.left, top: r.top, s, ox: (r.width - 732 * s) / 2, oy: (r.height - 516 * s) / 2 } } 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 && now > hold) { if (reduced) target = 0 else { clock += dt * speed const t = clock px = 366 + 366 * (0.76 * Math.sin(w * t + phase) + 0.2 * Math.sin(1.93 * w * t + 1.2 + phase)) py = 258 + 258 * (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 }, dt) if (moving || more) frame = requestAnimationFrame(tick) else { frame = 0 last = 0 } } const wake = () => { if (!frame) frame = requestAnimationFrame(tick) } const touchIsAmbient = (e: Event) => (e as PointerEvent).pointerType === "touch" && touchMq.matches const onEnter = (e: PointerEvent) => { if (touchIsAmbient(e)) return measure() } const onMove = (e: PointerEvent) => { if (touchIsAmbient(e)) return const t = performance.now() if (!box || t - lastMoveAt > 250) measure() lastMoveAt = t if (!box) return if (!e.isTrusted) hold = performance.now() + 1000 cx = e.clientX cy = e.clientY px = (cx - box.left - box.ox) / box.s py = (cy - box.top - box.oy) / box.s target = 1 wake() } const onLeave = (e?: Event) => { if (e && touchIsAmbient(e)) return if (e && !e.isTrusted) hold = 0 if (ambient && performance.now() > hold) return target = 0 wake() } const onFocus = () => { measure() if (!box) return px = 366 py = 258 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 - box.ox) / box.s py = (cy - box.top - box.oy) / box.s wake() } const settle = () => { const next = onScreen && !document.hidden && wantsAmbient() if (next === ambient) return ambient = next if (ambient) { measure() if (!box) { ambient = false return } wake() } else { target = 0 wake() } } const io = typeof IntersectionObserver === "function" ? new IntersectionObserver( ([entry]) => { onScreen = !!entry?.isIntersecting settle() }, { threshold: 0.2 }, ) : null io?.observe(svg) if (!io) { onScreen = true settle() } const onVisibility = () => settle() const onCapability = () => settle() const reducedMq = matchMedia("(prefers-reduced-motion: reduce)") const onReduced = () => wake() document.addEventListener("visibilitychange", onVisibility) touchMq.addEventListener?.("change", onCapability) 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("pointercancel", onLeave) 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("pointercancel", onLeave) host.removeEventListener("focusin", onFocus) host.removeEventListener("focusout", onLeave) window.removeEventListener("resize", onResize) window.removeEventListener("scroll", onScroll, { capture: true }) document.removeEventListener("visibilitychange", onVisibility) touchMq.removeEventListener?.("change", onCapability) reducedMq.removeEventListener?.("change", onReduced) io?.disconnect() if (frame) cancelAnimationFrame(frame) } } const W = 732 const H = 516 const COLS = 33 const ROWS = 25 const X0 = 11 const Y0 = 11 const DX = 21.63 const DY = 20 const TILE_W = 18.03 const TILE_H = 16 const BASE = 0.05 const LEVELS = [1, 0.8, 0.7, 0.35, 0.15, 0.1, 0.08] const RINGS = LEVELS.length - 1 const POOL = 320 const EASE_MS = 45 const FIELD_W = (COLS - 1) * DX + TILE_W const FIELD_H = (ROWS - 1) * DY + TILE_H export type TilesProps = { tokens?: Partial> className?: string static?: boolean } type Slot = { cell: number; o: number; t: number; i: number } const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)) const over = (level: number) => Math.max(0, (level - BASE) / (1 - BASE)) const ring = (c: number, r: number, bc: number, br: number) => Math.max(c < bc ? bc - c : c > bc + 1 ? c - bc - 1 : 0, r < br ? br - r : r > br + 1 ? r - br - 1 : 0) const subscribeNever = () => () => {} export function Tiles({ tokens, className = "", static: isStatic = false }: TilesProps) { const ref = useRef(null) useEffect(() => selfSize(ref.current), []) const tiles = useRef<(SVGRectElement | null)[]>([]) const poolLive = useSyncExternalStore( subscribeNever, () => true, () => false, ) && !isStatic const uid = useId().replace(/[^a-zA-Z0-9]/g, "") const patternId = `tiles-field-${uid}` useEffect(() => { const root = ref.current if (!root || isStatic) return const slots: Slot[] = Array.from({ length: POOL }, (_, i) => ({ cell: -1, o: 0, t: 0, i })) const byCell = new Map() const acquire = (cell: number): Slot | null => { const have = byCell.get(cell) if (have) return have let s = slots.find((x) => x.cell === -1) ?? null if (!s) { for (const x of slots) if (x.t === 0 && (!s || x.o < s.o)) s = x if (!s) return null byCell.delete(s.cell) } s.cell = cell s.o = s.t = 0 byCell.set(cell, s) const el = tiles.current[s.i] if (el) { el.setAttribute("x", (X0 + (cell % COLS) * DX).toFixed(2)) el.setAttribute("y", (Y0 + Math.floor(cell / COLS) * DY).toFixed(2)) } return s } return engine( root, ({ x, y, active, reduced }, dt) => { const bc = clamp(Math.round((x - X0 - TILE_W / 2 - DX / 2) / DX), 0, COLS - 2) const br = clamp(Math.round((y - Y0 - TILE_H / 2 - DY / 2) / DY), 0, ROWS - 2) for (const s of byCell.values()) s.t = 0 if (active > 0.001) { for (let r = Math.max(0, br - RINGS); r <= Math.min(ROWS - 1, br + 1 + RINGS); r++) { for (let c = Math.max(0, bc - RINGS); c <= Math.min(COLS - 1, bc + 1 + RINGS); c++) { const s = acquire(r * COLS + c) if (s) s.t = over(LEVELS[ring(c, r, bc, br)]!) * active } } } const k = reduced ? 1 : 1 - Math.exp((-dt * 1000) / EASE_MS) let more = false for (const s of [...byCell.values()]) { const next = s.o + (s.t - s.o) * k const done = Math.abs(next - s.t) < 0.002 s.o = done ? s.t : next if (!done) more = true const el = tiles.current[s.i] if (el) el.style.opacity = s.o.toFixed(3) if (done && s.t === 0) { byCell.delete(s.cell) s.cell = -1 } } return more }, [160, 112], ) }, [isStatic]) const style = { "--texture-ink": "var(--foreground)", "--texture-field": "var(--background)", ...tokens, color: "var(--texture-ink)", background: "var(--texture-field)", } as CSSProperties return (
) } ```. Live preview: /embed/tiles. ### Agent brief ### 1. What it is A dim field of small tiles. The pointer snaps to the nearest corner between four tiles; that 2 × 2 block lights up to white and the rings around it take the design's levels by whole tiles (80, 70, 35, 15, 10, 8 %), a square of light that steps from cell to cell as the pointer moves. Use it as a hero or background panel, or inside a card, wherever a quiet field of marks can answer the pointer. Not for small icons: below 160 × 112 it stays static. ### 2. Behaviour timeline - **Idle:** the resting field, one pattern-filled rect at 5 %. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; the block under the pointer goes to full ink and the six rings around it step down to their levels, each tile easing in about 45 ms. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. Only the tiles inside the glow are elements (a pool of 320; on a fast sweep the dimmest fading tile is reused); the rest is the pattern. - **Hover-out:** the weight springs back to 0 and every tile eases back into the field; interruptible at any point. - **Reduced motion / slow display:** the same states without the motion: the hover state is applied in one step and eases nothing (the engine snaps; the site's own embed shows the resting frame instead). - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: `npx shadcn@latest add https://textures.dev/r/tiles.json` (one self-contained React file at components/textures/tiles.tsx), or write the file `get_texture` returns exactly as returned, or use the code in this Texture's markdown twin (/s/tiles.md). - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the 732 × 516 panel covers the host and crops at the edges), `contain` available; works from 160 × 112 up; clips. - Tokens on the root: `--texture-ink` (the tiles) and `--texture-field` (the panel); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the levels, the ring count or the reduced-motion branch. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Glow (background) · Magnet (hero) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/glow.json --- ## Gaze > For agents: this file is the source. Write the file under Code exactly as given, at the path it names, and mount the component as Placement says; do not recreate or approximate the animation. A dim field of short dashes. Under the pointer they go to white and turn to look at it; the ink falls away 10 % per cell and the turn fades over five cells. - Slug: `gaze` · Category: lines · Free (MIT) · v1.1.0 - What moves: Dashes under the pointer light up and turn to point at it; the ink falls 10 % per cell to the dim field, and all ease back when it leaves. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: look, follow, compass · Use cases: background, hero, card · Tags: grid, dashes, follow, light - Rails: Measured 2026-10-27 for 1.1.0: 3.29 KB js, p95 16.04 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 4466 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the dashes - `--texture-field` (color, default `var(--background)`): The panel behind them ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 112 px up; keeps 732/516 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 732 / 516; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Gaze
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/gaze.json` (one self-contained React file at components/textures/gaze.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/gaze` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "gaze" })` (server: /mcp) - Markdown: /s/gaze.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 Texture 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 Gaze hover texture (textures.dev/s/gaze)` (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/gaze.tsx` ```tsx // Gaze — a Texture by Textures · https://textures.dev/s/gaze // Install another: npx shadcn@latest add https://textures.dev/r/.json // For agents: https://textures.md // License: free to use "use client" import { type CSSProperties, useEffect, useId, useRef, useSyncExternalStore } 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 } const AMBIENT_SPEED = 1.5 const AMBIENT_CYCLE = 4.5 type EngineOptions = { ambientSpeed?: number } function engine( root: HTMLElement, draw: (p: Pointer, dt: number) => boolean | void, min: [number, number], options: EngineOptions = {}, ): () => void { const svg = root.querySelector("svg") if (!svg) return () => {} const mq = () => matchMedia("(prefers-reduced-motion: reduce)").matches || matchMedia("(update: slow)").matches const touchMq = matchMedia("(hover: none), (pointer: coarse)") const forced = () => root.closest("[data-motion]")?.getAttribute("data-motion") ?? null const wantsAmbient = () => (forced() ? forced() === "ambient" : touchMq.matches) const speed = AMBIENT_SPEED * (options.ambientSpeed ?? 1) 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; s: number; ox: number; oy: number } | null = null let px = 366 let py = 258 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 = svg.getBoundingClientRect() if (r.width < min[0] || r.height < min[1]) { box = null return } const s = Math.max(r.width / 732, r.height / 516) box = { left: r.left, top: r.top, s, ox: (r.width - 732 * s) / 2, oy: (r.height - 516 * s) / 2 } } 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 && now > hold) { if (reduced) target = 0 else { clock += dt * speed const t = clock px = 366 + 366 * (0.76 * Math.sin(w * t + phase) + 0.2 * Math.sin(1.93 * w * t + 1.2 + phase)) py = 258 + 258 * (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 }, dt) if (moving || more) frame = requestAnimationFrame(tick) else { frame = 0 last = 0 } } const wake = () => { if (!frame) frame = requestAnimationFrame(tick) } const touchIsAmbient = (e: Event) => (e as PointerEvent).pointerType === "touch" && touchMq.matches const onEnter = (e: PointerEvent) => { if (touchIsAmbient(e)) return measure() } const onMove = (e: PointerEvent) => { if (touchIsAmbient(e)) return const t = performance.now() if (!box || t - lastMoveAt > 250) measure() lastMoveAt = t if (!box) return if (!e.isTrusted) hold = performance.now() + 1000 cx = e.clientX cy = e.clientY px = (cx - box.left - box.ox) / box.s py = (cy - box.top - box.oy) / box.s target = 1 wake() } const onLeave = (e?: Event) => { if (e && touchIsAmbient(e)) return if (e && !e.isTrusted) hold = 0 if (ambient && performance.now() > hold) return target = 0 wake() } const onFocus = () => { measure() if (!box) return px = 366 py = 258 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 - box.ox) / box.s py = (cy - box.top - box.oy) / box.s wake() } const settle = () => { const next = onScreen && !document.hidden && wantsAmbient() if (next === ambient) return ambient = next if (ambient) { measure() if (!box) { ambient = false return } wake() } else { target = 0 wake() } } const io = typeof IntersectionObserver === "function" ? new IntersectionObserver( ([entry]) => { onScreen = !!entry?.isIntersecting settle() }, { threshold: 0.2 }, ) : null io?.observe(svg) if (!io) { onScreen = true settle() } const onVisibility = () => settle() const onCapability = () => settle() const reducedMq = matchMedia("(prefers-reduced-motion: reduce)") const onReduced = () => wake() document.addEventListener("visibilitychange", onVisibility) touchMq.addEventListener?.("change", onCapability) 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("pointercancel", onLeave) 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("pointercancel", onLeave) host.removeEventListener("focusin", onFocus) host.removeEventListener("focusout", onLeave) window.removeEventListener("resize", onResize) window.removeEventListener("scroll", onScroll, { capture: true }) document.removeEventListener("visibilitychange", onVisibility) touchMq.removeEventListener?.("change", onCapability) reducedMq.removeEventListener?.("change", onReduced) io?.disconnect() if (frame) cancelAnimationFrame(frame) } } const W = 732 const H = 516 const COLS = 32 const ROWS = 25 const X0 = 16 const Y0 = 16 const DX = 22 const DY = 20 const DASH_W = 18 const DASH_H = 4 const BASE = 0.2 const NEAR = 3.0 const REACH = 5.8 const GLOW = 6 const POOL = 256 const MASKS = 256 const EASE_MS = 35 const FIELD_W = (COLS - 1) * DX + DASH_W const FIELD_H = (ROWS - 1) * DY + DASH_H export type GazeProps = { tokens?: Partial> className?: string static?: boolean } type Slot = { cell: number; a: number; ta: number; o: number; to: number; m: number; i: number } const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)) const turn = (d: number) => { const u = clamp((REACH - d) / (REACH - NEAR), 0, 1) return u * u * (3 - 2 * u) } const level = (d: number) => { const u = clamp((REACH - d) / (REACH - 0.5), 0, 1) return BASE + (1 - BASE) * u * u * (3 - 2 * u) } const arc = (from: number, to: number) => ((((to - from) % 180) + 270) % 180) - 90 const subscribeNever = () => () => {} export function Gaze({ tokens, className = "", static: isStatic = false }: GazeProps) { const ref = useRef(null) useEffect(() => selfSize(ref.current), []) const dashes = useRef<(SVGRectElement | null)[]>([]) const masks = useRef<(SVGRectElement | null)[]>([]) const poolLive = useSyncExternalStore( subscribeNever, () => true, () => false, ) && !isStatic const uid = useId().replace(/[^a-zA-Z0-9]/g, "") const patternId = `gaze-field-${uid}` const maskId = `gaze-hole-${uid}` useEffect(() => { const root = ref.current if (!root || isStatic) return const slots: Slot[] = Array.from({ length: POOL }, (_, i) => ({ cell: -1, a: 0, ta: 0, o: 0, to: 0, m: -1, i })) const byCell = new Map() const freeMasks = Array.from({ length: MASKS }, (_, i) => i) const home = (cell: number) => [X0 + (cell % COLS) * DX, Y0 + Math.floor(cell / COLS) * DY] as const const acquire = (cell: number): Slot | null => { const have = byCell.get(cell) if (have) return have let s = slots.find((x) => x.cell === -1) ?? null if (!s) { for (const x of slots) if (x.to === 0 && x.ta === 0 && (!s || x.o < s.o)) s = x if (!s) return null unmask(s) byCell.delete(s.cell) } s.cell = cell s.a = s.ta = s.o = s.to = 0 byCell.set(cell, s) const [hx, hy] = home(cell) const el = dashes.current[s.i] if (el) { el.setAttribute("x", hx.toFixed(2)) el.setAttribute("y", hy.toFixed(2)) el.style.opacity = "0" } return s } const mask = (s: Slot) => { if (s.m >= 0) return const m = freeMasks.pop() if (m === undefined) return s.m = m const [hx, hy] = home(s.cell) const el = masks.current[m] if (el) { el.setAttribute("x", (hx - 0.75).toFixed(2)) el.setAttribute("y", (hy - 0.75).toFixed(2)) el.style.opacity = "1" } } const unmask = (s: Slot) => { if (s.m < 0) return const el = masks.current[s.m] if (el) el.style.opacity = "0" freeMasks.push(s.m) s.m = -1 } const release = (s: Slot) => { unmask(s) byCell.delete(s.cell) s.cell = -1 const el = dashes.current[s.i] if (el) { el.style.opacity = "0" el.style.transform = "" } } return engine( root, ({ x, y, active, reduced }, dt) => { for (const s of byCell.values()) s.ta = s.to = 0 if (active > 0.001) { const pc = (x - X0 - DASH_W / 2) / DX const pr = (y - Y0 - DASH_H / 2) / DY const r0 = clamp(Math.floor(pr - GLOW), 0, ROWS - 1) const r1 = clamp(Math.ceil(pr + GLOW), 0, ROWS - 1) const c0 = clamp(Math.floor(pc - GLOW), 0, COLS - 1) const c1 = clamp(Math.ceil(pc + GLOW), 0, COLS - 1) for (let r = r0; r <= r1; r++) { for (let c = c0; c <= c1; c++) { const d = Math.hypot(pc - c, pr - r) const lift = (level(d) - BASE) * active const w = turn(d) * active if (lift <= 0.001 && w <= 0.001) continue const s = acquire(r * COLS + c) if (!s) continue s.to = lift if (w > 0.001) s.ta = arc(0, (Math.atan2((pr - r) * DY, (pc - c) * DX) * 180) / Math.PI) * w } } } const k = reduced ? 1 : 1 - Math.exp((-dt * 1000) / EASE_MS) let more = false for (const s of [...byCell.values()]) { const step = arc(s.a, s.ta) const aDone = Math.abs(step) < 0.05 s.a = aDone ? s.ta : s.a + step * k const next = s.o + (s.to - s.o) * k const oDone = Math.abs(next - s.to) < 0.002 s.o = oDone ? s.to : next if (!aDone || !oDone) more = true if (aDone && oDone && s.ta === 0 && s.to === 0) { release(s) continue } if (s.a !== 0) mask(s) else if (aDone) unmask(s) const el = dashes.current[s.i] if (el) { el.style.opacity = (s.m >= 0 ? BASE + s.o : s.o / (1 - BASE)).toFixed(3) el.style.transform = s.a !== 0 ? `rotate(${s.a.toFixed(2)}deg)` : "" } } return more }, [160, 112], ) }, [isStatic]) const style = { "--texture-ink": "var(--foreground)", "--texture-field": "var(--background)", ...tokens, color: "var(--texture-ink)", background: "var(--texture-field)", } as CSSProperties const live = { opacity: 0, transformBox: "fill-box", transformOrigin: "center" } as CSSProperties return (
) } ```. Live preview: /embed/gaze. ### Agent brief ### 1. What it is A dim field of short dashes (20 %). Under the pointer the dashes go to full ink and turn to look at it; the ink falls back to the field over the same reach as the turn, which is full within three cells and fades out by six, so the light sits in the turned dashes and never reads as a halo behind them. Everything eases back when the pointer moves on or leaves. Use it as a hero or background panel, or inside a card, wherever a quiet field of marks can answer the pointer. Not for small icons: below 160 × 112 it stays static. ### 2. Behaviour timeline - **Idle:** the resting field at 20 %, one pattern-filled rect. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; the dashes within six cells brighten and turn toward the pointer on one curve, each easing in about 35 ms. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. Only dashes that brighten or turn are elements (a pool of 256; on a fast sweep the dimmest dash on its way back is reused); a turning dash sits over a mask square that hides its resting twin (one mask per pool slot, so no turned dash is ever unmasked); the rest is the pattern. - **Hover-out:** the weight springs back to 0 and every dash dims and turns back to the grid; interruptible at any point. - **Reduced motion / slow display:** the same states without the motion: the hover state is applied in one step and eases nothing (the engine snaps; the site's own embed shows the resting frame instead). - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: `npx shadcn@latest add https://textures.dev/r/gaze.json` (one self-contained React file at components/textures/gaze.tsx), or write the file `get_texture` returns exactly as returned, or use the code in this Texture's markdown twin (/s/gaze.md). - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the 732 × 516 panel covers the host and crops at the edges), `contain` available; works from 160 × 112 up; clips. - Tokens on the root: `--texture-ink` (the dashes) and `--texture-field` (the panel); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the reach, the easing or the reduced-motion branch. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Settle (hero) · Beam (background) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/settle.json --- ## Lift A field of small white containers, each holding a dark circle resting in its corner. The pointer pulls the circles toward it like a magnet, with a wide reach. - Slug: `lift` · Category: circles · Pro (LicenseRef-Textures-Pro) · v1.1.0 - What moves: Circles near the pointer slide toward it inside their containers, climbing as it passes above and sinking as it passes below, and settle back when it leaves. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: magnet, lift, orbs, rise · Use cases: background, hero, card · Tags: grid, circles, magnet - Rails: Measured 2026-10-27 for 1.1.0: 2.86 KB js, p95 16.9 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 4146 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): The containers - `--texture-field` (color, default `var(--background)`): The panel and the circles ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 112 px up; keeps 732/516 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 732 / 516; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Lift
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/lift.json` (one self-contained React file at components/textures/lift.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/lift` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "lift" })` (server: /mcp) - Markdown: /s/lift.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 Texture 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 Lift hover texture (textures.dev/s/lift)` (a suggestion the agent and the human can edit, never a write). Code: Pro ($19 a month or $180 a year), not unlocked for this request. Agents: if the prompt gave you a link with a token in the path (/s//.md), fetch that exact link; dropping the token is why you see this. Otherwise tell the user it is a Pro Texture and give them https://textures.dev/checkout?texture=lift; never recreate it. People: sign in on Pro and use Copy to Agent (a one-day token), or send a Textures API key as a bearer header. ### Agent brief ### 1. What it is A field of small white containers, each holding a dark circle that rests low in its lower-right corner. The pointer is a magnet whose reach spans the field: every circle slides toward it inside its container, fully within one tile, less out to about five, and beyond that by a hair that fades to nothing across the panel, so the circles climb as the pointer passes above them and sink as it passes below. All settle back when it leaves. Use it as a hero or background panel, or inside a card. Not for small icons: below 160 × 112 it stays static. ### 2. Behaviour timeline - **Idle:** the resting field, one pattern-filled rect. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; circles within reach slide toward the pointer by up to 9 px, each easing in about 60 ms. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. Only moving tiles are elements (a pool of 330, nearly the field; on a fast sweep the tile nearest rest is reused), each a nested svg that clips its own circle; the rest is the pattern. - **Hover-out:** the weight springs back to 0 and every circle slides home; interruptible at any point. - **Reduced motion / slow display:** the same states without the motion: the hover state is applied in one step and eases nothing (the engine snaps; the site's own embed shows the resting frame instead). - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: `npx shadcn@latest add https://textures.dev/r/lift.json` (one self-contained React file at components/textures/lift.tsx), or write the file `get_texture` returns exactly as returned, or use the code in this Texture's markdown twin (/s/lift.md). - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the 732 × 516 panel covers the host and crops at the edges), `contain` available; works from 160 × 112 up; clips. - Tokens on the root: `--texture-ink` (the containers) and `--texture-field` (the panel and the circles); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the reach, the pull or the reduced-motion branch. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Sphere (hero) · Pop (hero, pro) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/sphere.json --- ## Beam > For agents: this file is the source. Write the file under Code exactly as given, at the path it names, and mount the component as Placement says; do not recreate or approximate the animation. A field of hairlines. The pointer carries a beam across them: the lines under it swell into bars and part to make room, and the beam glides after the pointer. - Slug: `beam` · Category: lines · Free (MIT) · v1.1.0 - What moves: A beam glides after the pointer: the lines under it swell into bars and part to make room, and all settle back to hairlines when it leaves. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: lens, focus, barcode, scan · Use cases: background, hero, card · Tags: lines, beam, light - Rails: Measured 2026-10-27 for 1.1.1: 1.86 KB js, p95 4.24 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 3413 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the lines - `--texture-field` (color, default `var(--background)`): The panel behind them ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 112 px up; keeps 732/516 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 732 / 516; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Beam
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/beam.json` (one self-contained React file at components/textures/beam.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/beam` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "beam" })` (server: /mcp) - Markdown: /s/beam.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 Texture 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 Beam hover texture (textures.dev/s/beam)` (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/beam.tsx` ```tsx // Beam — a Texture by Textures · https://textures.dev/s/beam // Install another: npx shadcn@latest add https://textures.dev/r/.json // For agents: https://textures.md // 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 } const AMBIENT_SPEED = 1.5 const AMBIENT_CYCLE = 4.5 type EngineOptions = { ambientSpeed?: number } function engine( root: HTMLElement, draw: (p: Pointer, dt: number) => boolean | void, min: [number, number], options: EngineOptions = {}, ): () => void { const svg = root.querySelector("svg") if (!svg) return () => {} const mq = () => matchMedia("(prefers-reduced-motion: reduce)").matches || matchMedia("(update: slow)").matches const touchMq = matchMedia("(hover: none), (pointer: coarse)") const forced = () => root.closest("[data-motion]")?.getAttribute("data-motion") ?? null const wantsAmbient = () => (forced() ? forced() === "ambient" : touchMq.matches) const speed = AMBIENT_SPEED * (options.ambientSpeed ?? 1) 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; s: number; ox: number; oy: number } | null = null let px = 366 let py = 258 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 = svg.getBoundingClientRect() if (r.width < min[0] || r.height < min[1]) { box = null return } const s = Math.max(r.width / 732, r.height / 516) box = { left: r.left, top: r.top, s, ox: (r.width - 732 * s) / 2, oy: (r.height - 516 * s) / 2 } } 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 && now > hold) { if (reduced) target = 0 else { clock += dt * speed const t = clock px = 366 + 366 * (0.76 * Math.sin(w * t + phase) + 0.2 * Math.sin(1.93 * w * t + 1.2 + phase)) py = 258 + 258 * (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 }, dt) if (moving || more) frame = requestAnimationFrame(tick) else { frame = 0 last = 0 } } const wake = () => { if (!frame) frame = requestAnimationFrame(tick) } const touchIsAmbient = (e: Event) => (e as PointerEvent).pointerType === "touch" && touchMq.matches const onEnter = (e: PointerEvent) => { if (touchIsAmbient(e)) return measure() } const onMove = (e: PointerEvent) => { if (touchIsAmbient(e)) return const t = performance.now() if (!box || t - lastMoveAt > 250) measure() lastMoveAt = t if (!box) return if (!e.isTrusted) hold = performance.now() + 1000 cx = e.clientX cy = e.clientY px = (cx - box.left - box.ox) / box.s py = (cy - box.top - box.oy) / box.s target = 1 wake() } const onLeave = (e?: Event) => { if (e && touchIsAmbient(e)) return if (e && !e.isTrusted) hold = 0 if (ambient && performance.now() > hold) return target = 0 wake() } const onFocus = () => { measure() if (!box) return px = 366 py = 258 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 - box.ox) / box.s py = (cy - box.top - box.oy) / box.s wake() } const settle = () => { const next = onScreen && !document.hidden && wantsAmbient() if (next === ambient) return ambient = next if (ambient) { measure() if (!box) { ambient = false return } wake() } else { target = 0 wake() } } const io = typeof IntersectionObserver === "function" ? new IntersectionObserver( ([entry]) => { onScreen = !!entry?.isIntersecting settle() }, { threshold: 0.2 }, ) : null io?.observe(svg) if (!io) { onScreen = true settle() } const onVisibility = () => settle() const onCapability = () => settle() const reducedMq = matchMedia("(prefers-reduced-motion: reduce)") const onReduced = () => wake() document.addEventListener("visibilitychange", onVisibility) touchMq.addEventListener?.("change", onCapability) 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("pointercancel", onLeave) 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("pointercancel", onLeave) host.removeEventListener("focusin", onFocus) host.removeEventListener("focusout", onLeave) window.removeEventListener("resize", onResize) window.removeEventListener("scroll", onScroll, { capture: true }) document.removeEventListener("visibilitychange", onVisibility) touchMq.removeEventListener?.("change", onCapability) reducedMq.removeEventListener?.("change", onReduced) io?.disconnect() if (frame) cancelAnimationFrame(frame) } } const W = 732 const H = 516 const N = 51 const X0 = 15.5 const PITCH = 14 const Y0 = 9.5 const LINE_H = 497 const WIDEN = 15 const SIGMA = 25 const MARGIN = 1.3 const SPREAD = 80 const SETTLE = 220 const FULL = 229 const FAR = 355 const FLOOR = 1 export type BeamProps = { tokens?: Partial> className?: string static?: boolean } const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)) const smooth = (u: number) => u * u * (3 - 2 * u) const widen = (dx: number) => WIDEN * Math.exp(-(dx * dx) / (SIGMA * SIGMA)) const ROOM = (WIDEN * SIGMA * Math.sqrt(Math.PI)) / (4 * PITCH) const shift = (dx: number) => { const a = Math.abs(dx) const room = MARGIN * ROOM * Math.tanh((1.2 * a) / SIGMA) const decay = 1 - smooth(clamp((a - SPREAD) / (SETTLE - SPREAD), 0, 1)) return Math.sign(dx) * room * decay } const ink = (dx: number) => clamp(1 - (1 - FLOOR) * ((Math.abs(dx) - FULL) / (FAR - FULL)), FLOOR, 1) export function Beam({ tokens, className = "", static: isStatic = false }: BeamProps) { const ref = useRef(null) useEffect(() => selfSize(ref.current), []) const lines = useRef<(SVGRectElement | null)[]>([]) useEffect(() => { const root = ref.current if (!root || isStatic) return return engine( root, ({ x, active }) => { const bx = x const more = false for (let i = 0; i < N; i++) { const el = lines.current[i] if (!el) continue const dx = X0 + i * PITCH + 0.5 - bx const w = 1 + widen(dx) * active const s = shift(dx) * active el.style.transform = w > 1.001 || Math.abs(s) > 0.01 ? `translate(${s.toFixed(2)}px, 0) scaleX(${w.toFixed(3)})` : "" el.style.opacity = (FLOOR + (ink(dx) - FLOOR) * active).toFixed(3) } return more }, [160, 112], ) }, [isStatic]) const style = { "--texture-ink": "var(--foreground)", "--texture-field": "var(--background)", ...tokens, color: "var(--texture-ink)", background: "var(--texture-field)", } as CSSProperties const line = { opacity: FLOOR, transformBox: "fill-box", transformOrigin: "center" } as CSSProperties return (
) } ```. Live preview: /embed/beam. ### Agent brief ### 1. What it is A field of 51 hairlines, all full ink. The pointer carries a beam across them: the lines under it swell into bars (up to 16 px) and part to make room, and the beam sits on the pointer with no lag, so the whole field moves with it like a wave. At rest every line is a hairline. Use it as a hero or background panel, or inside a card. Not for small icons: below 160 × 112 it stays static. ### 2. Behaviour timeline - **Idle:** 51 hairlines. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; the beam forms where the pointer is. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the beam's position is the pointer's, with no lag, and every line's width and offset follow from its distance to the beam. 51 elements, one transform each. - **Hover-out:** the weight springs back to 0 and the lines settle to hairlines; interruptible at any point. - **Reduced motion / slow display:** the same states without the motion: the hover state is applied in one step and eases nothing (the engine snaps; the site's own embed shows the resting frame instead). - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: `npx shadcn@latest add https://textures.dev/r/beam.json` (one self-contained React file at components/textures/beam.tsx), or write the file `get_texture` returns exactly as returned, or use the code in this Texture's markdown twin (/s/beam.md). - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the 732 × 516 panel covers the host and crops at the edges), `contain` available; works from 160 × 112 up; clips. - Tokens on the root: `--texture-ink` (the lines) and `--texture-field` (the panel); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the beam's texture or the reduced-motion branch. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Settle (hero) · Swell (hero, pro) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/settle.json --- ## Bloom A checkerboard lattice of slim horizontal and vertical diamonds. Near the pointer they grow up to 3.5× and meet into four-point stars. - Slug: `bloom` · Category: grids · Pro (LicenseRef-Textures-Pro) · v0.1.0 - What moves: Small diamond slivers near the pointer grow larger and meet into star crosses, then spring back to slivers when the pointer leaves. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: diamond lattice, star cross grid, sliver checkerboard, radial diamond scale · Use cases: hero, background, card · Tags: grid, swell, dense, soft, ripple - Rails: Measured 2026-10-26 for 0.1.0: 1.22 KB js, p95 12.3 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 4458 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the marks - `--texture-field` (color, default `var(--background)`): The panel behind them ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 113 px up; keeps 1/0.7047 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 1 / 0.7047; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Bloom
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/bloom.json` (one self-contained React file at components/textures/bloom.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/bloom` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "bloom" })` (server: /mcp) - Markdown: /s/bloom.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 Texture 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 Bloom hover texture (textures.dev/s/bloom)` (a suggestion the agent and the human can edit, never a write). Code: Pro ($19 a month or $180 a year), not unlocked for this request. Agents: if the prompt gave you a link with a token in the path (/s//.md), fetch that exact link; dropping the token is why you see this. Otherwise tell the user it is a Pro Texture and give them https://textures.dev/checkout?texture=bloom; never recreate it. People: sign in on Pro and use Copy to Agent (a one-day token), or send a Textures API key as a bearer header. ### Agent brief ### 1. What it is A checkerboard lattice of slim horizontal and vertical diamonds. Near the pointer they grow up to 3.5× and meet into four-point stars. Use it as a hero or background panel, or inside a card, wherever a field of marks can answer the pointer. Not for small icons: below 160 × 113 it stays static. Recognised as lattice (confidence 0.991) by the Textures engine. ### 2. Behaviour timeline - **Idle:** the resting field. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; small diamond slivers near the pointer grow larger and meet into star crosses, then spring back to slivers when the pointer leaves. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. Falloff: radial, radius 0.89 of the short side, smoothstep. - **Hover-out:** every element springs back (stiffness 170, damping 26); interruptible at any point. - **Reduced motion / slow display:** the same states without easing: everything snaps. - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: run the shadcn command from Copy to Agent (`npx shadcn@latest add https://textures.dev/r/bloom.json`) or write the file from this Texture's markdown twin (/s/bloom.md). One self-contained React file, components/textures/bloom.tsx. - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the panel, aspect 1 / 0.7047, covers the host and crops at the edges), `contain` available; works from 160 × 113 up; clips. - Tokens on the root: `--texture-ink` (the marks) and `--texture-field` (the panel); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the spring, the falloff or the reduced-motion branch; the motion spec lives in `motion.ts` and is read by the shared runtime. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Glow (background) · Magnet (hero) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/glow.json --- ## Cells A grid of cells, each with a small square. Near the pointer the squares swell to fill their cells in a soft dome, trailing it; they shrink back when it leaves. - Slug: `cells` · Category: grids · Pro (LicenseRef-Textures-Pro) · v0.1.0 - What moves: Every cell holds a small square. Near the pointer the squares swell to fill their cells in a soft dome, then shrink back when it leaves. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: dot matrix swell, pixel grid, cell grid, LED board, halftone squares · Use cases: hero, background, image tile · Tags: grid, cells, squares, swell, follow, soft, dense - Rails: Measured 2026-10-26 for 0.1.0: 1.33 KB js, p95 9.04 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 4863 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the marks - `--texture-field` (color, default `var(--background)`): The panel behind them ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 113 px up; keeps 1/0.7049 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 1 / 0.7049; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Cells
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/cells.json` (one self-contained React file at components/textures/cells.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/cells` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "cells" })` (server: /mcp) - Markdown: /s/cells.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 Texture 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 Cells hover texture (textures.dev/s/cells)` (a suggestion the agent and the human can edit, never a write). Code: Pro ($19 a month or $180 a year), not unlocked for this request. Agents: if the prompt gave you a link with a token in the path (/s//.md), fetch that exact link; dropping the token is why you see this. Otherwise tell the user it is a Pro Texture and give them https://textures.dev/checkout?texture=cells; never recreate it. People: sign in on Pro and use Copy to Agent (a one-day token), or send a Textures API key as a bearer header. ### Agent brief ### 1. What it is A grid of small rectangular tiles. Near the pointer, columns widen and rows deepen, stretching tiles into squares, bars and dashes. Use it as a hero or background panel, or inside a card, wherever a field of marks can answer the pointer. Not for small icons: below 160 × 113 it stays static. Recognised as lattice (confidence 0.95) by the Textures engine. ### 2. Behaviour timeline - **Idle:** the resting field. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; small rectangular tiles grow wider and taller by horizontal and vertical distance to the pointer, trailing it, then spring back when it leaves. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. Falloff: radial, radius 0.5 of the short side, smoothstep. - **Hover-out:** every element springs back (stiffness 170, damping 26); interruptible at any point. - **Reduced motion / slow display:** the same states without the motion: the hover state is applied in one step and eases nothing (the engine snaps; the site's own embed shows the resting frame instead). - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: run the shadcn command from Copy to Agent (`npx shadcn@latest add https://textures.dev/r/cells.json`) or write the file from this Texture's markdown twin (/s/cells.md). One self-contained React file, components/textures/cells.tsx. - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the panel, aspect 1 / 0.7053, covers the host and crops at the edges), `contain` available; works from 160 × 113 up; clips. - Tokens on the root: `--texture-ink` (the marks) and `--texture-field` (the panel); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the spring, the falloff or the reduced-motion branch; the motion spec lives in `motion.ts` and is read by the shared runtime. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Glow (background) · Magnet (hero) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/glow.json --- ## Quatrefoil A staggered lattice of four-petal crosses alternating + and × by row, graded in size; crosses near the pointer swell in a soft radial bulge. - Slug: `quatrefoil` · Category: grids · Pro (LicenseRef-Textures-Pro) · v0.1.0 - What moves: Small four-petal crosses near the pointer grow larger in a soft round bulge that trails the pointer and springs back when it leaves. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: cross lattice, plus and x pattern, four-petal grid, quatrefoil field, scale bulge · Use cases: hero, background, card · Tags: grid, dense, swell, follow, soft, ripple - Rails: Measured 2026-10-26 for 0.1.0: 1.23 KB js, p95 7.96 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 4702 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the marks - `--texture-field` (color, default `var(--background)`): The panel behind them ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 113 px up; keeps 1/0.7039 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 1 / 0.7039; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Quatrefoil
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/quatrefoil.json` (one self-contained React file at components/textures/quatrefoil.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/quatrefoil` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "quatrefoil" })` (server: /mcp) - Markdown: /s/quatrefoil.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 Texture 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 Quatrefoil hover texture (textures.dev/s/quatrefoil)` (a suggestion the agent and the human can edit, never a write). Code: Pro ($19 a month or $180 a year), not unlocked for this request. Agents: if the prompt gave you a link with a token in the path (/s//.md), fetch that exact link; dropping the token is why you see this. Otherwise tell the user it is a Pro Texture and give them https://textures.dev/checkout?texture=quatrefoil; never recreate it. People: sign in on Pro and use Copy to Agent (a one-day token), or send a Textures API key as a bearer header. ### Agent brief ### 1. What it is A staggered lattice of four-petal crosses alternating + and × by row, graded in size; crosses near the pointer swell in a soft radial bulge. Use it as a hero or background panel, or inside a card, wherever a field of marks can answer the pointer. Not for small icons: below 160 × 113 it stays static. Recognised as lattice (confidence 0.91) by the Textures engine. ### 2. Behaviour timeline - **Idle:** the resting field. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; small four-petal crosses near the pointer grow larger in a soft round bulge that trails the pointer and springs back when it leaves. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. Falloff: radial, radius 0.47 of the short side, smoothstep. - **Hover-out:** every element springs back (stiffness 170, damping 26); interruptible at any point. - **Reduced motion / slow display:** the same states without easing: everything snaps. - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: run the shadcn command from Copy to Agent (`npx shadcn@latest add https://textures.dev/r/quatrefoil.json`) or write the file from this Texture's markdown twin (/s/quatrefoil.md). One self-contained React file, components/textures/quatrefoil.tsx. - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the panel, aspect 1 / 0.7039, covers the host and crops at the edges), `contain` available; works from 160 × 113 up; clips. - Tokens on the root: `--texture-ink` (the marks) and `--texture-field` (the panel); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the spring, the falloff or the reduced-motion branch; the motion spec lives in `motion.ts` and is read by the shared runtime. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Glow (background) · Magnet (hero) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/glow.json --- ## Warp > For agents: this file is the source. Write the file under Code exactly as given, at the path it names, and mount the component as Placement says; do not recreate or approximate the animation. A grid of small rectangular tiles. Near the pointer, columns widen and rows deepen, stretching tiles into squares, bars and dashes. - Slug: `warp` · Category: grids · Free (MIT) · v0.1.0 - What moves: Small rectangular tiles grow wider and taller by horizontal and vertical distance to the pointer, trailing it, then spring back when it leaves. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: elastic grid, op-art grid, stretching lattice, fisheye grid, Kerry Strand warp · Use cases: hero, background, image tile · Tags: grid, tiles, swell, follow, crisp, dense - Rails: Measured 2026-10-26 for 0.1.0: 1.24 KB js, p95 6.52 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 4618 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the marks - `--texture-field` (color, default `var(--background)`): The panel behind them ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 113 px up; keeps 1/0.7053 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 1 / 0.7053; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Warp
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/warp.json` (one self-contained React file at components/textures/warp.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/warp` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "warp" })` (server: /mcp) - Markdown: /s/warp.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 Texture 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 Warp hover texture (textures.dev/s/warp)` (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/warp.tsx` ```tsx // Warp — a Texture by Textures · https://textures.dev/s/warp // Install another: npx shadcn@latest add https://textures.dev/r/.json // For agents: https://textures.md // License: free to use "use client" import { type CSSProperties, useEffect, useRef, useState } from "react" function selfSize(root: HTMLElement | null): void { const host = root?.parentElement if (!root || !host || host.getBoundingClientRect().height > 0) return root.style.minHeight = "240px" } const ASPECT = 0.7053 const MIN_BOX: [number, number] = [160, 113] type El = { id: string; role: string; d: string; cx: number; cy: number; ny?: number } const PACKED = "7ZRRCoJAFEXvBfvtJ5fRMvpwMbaDiGwLtTuXIDyXMCEU2JeEQzVwBDk8dR5zH3P0ydJGsl0BAAAAAACQDYO2umq6Wp2TFL6neT2urKPw9Uv9mr4288sxv+Pz/S3N63FlHYWvX+rH+cNf/MVfzh/zw99f+Ltjfvz/8Bd/8Rd/8Rd/8Rd/OX/4+5U8ex8Unr57v+PD+vWs6S/O26/zf++PvOQlL3nLyPsA" type Geometry = { elements: El[]; plates: string[] } let pending: Promise | null = null async function unpack(): Promise { const bytes = Uint8Array.from(atob(PACKED), (c) => c.charCodeAt(0)) const v = new DataView( await new Response(new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate-raw"))).arrayBuffer(), ) let o = 0 const u8 = () => v.getUint8(o++) const u16 = () => ((o += 2), v.getUint16(o - 2, true)) const i16 = () => ((o += 2), v.getInt16(o - 2, true)) u8() const ne = u16() const ops = Array.from({ length: ne + u16() }, () => Array.from({ length: u16() }, u8)) let x = 0 let y = 0 const pt = () => `${(x += i16()) / 10000} ${(y += i16()) / 10000}` const paths = ops.map((a) => a .map( (c) => ["M", "L", "Q", "A", "Z"][c] + (c === 2 ? `${pt()} ${pt()}` : c === 3 ? `${i16() / 10000} ${i16() / 10000} ${i16() / 10} ${i16()} ${i16()} ${pt()}` : c === 4 ? "" : pt()), ) .join(""), ) let cx = 0 let cy = 0 const elements = paths .slice(0, ne) .map((d, i): El => ({ id: String(i), role: "motif", d, cx: (cx += i16()) / 10000, cy: (cy += i16()) / 10000 })) return { elements, plates: paths.slice(ne) } } function useGeometry(): Geometry | null { const [geometry, setGeometry] = useState(null) useEffect(() => { let live = true void (pending ??= unpack()).then((g) => live && setGeometry(g)) return () => { live = false } }, []) return geometry } type React_ = { target: string property: "translate" | "scale" | "rotate" | "opacity" along?: "normal" | "tangent" | "toward" | "away" | "x" | "y" max: number map?: "falloff" | "inverse-falloff" | "constant" } type Spec = { ir?: 1 behavior?: "displace" | "clear" | "glow" rest?: "static" pointer: { falloff: { shape?: "radial" | "directional" radius?: number curve?: Curve } } react: React_[] stagger?: { by?: "distance" | "index" | "none"; ms?: number } leave: { settle: "spring" | "ease" spring?: { stiffness: number; damping: number } } reducedMotion: "none" | "static" | "simplified" a11y?: { keyboard?: "center-pulse" | "center-hold" | "none" touch?: "ambient" | "tap-ripple" | "tap-toggle" | "static" } } type Target = { el: SVGGraphicsElement; role: string; index: number; cx: number; cy: number } type Options = { spec: Spec; targets: Target[]; aspect: number; min?: [number, number] } const AMBIENT_SPEED = 1.5 const AMBIENT_CYCLE = 4.5 const LEAVE_K = 900 const LEAVE_MS = 350 function mount(root: HTMLElement, o: Options): () => void { const svg = root.querySelector("svg") if (!svg) return () => {} const { spec } = o const fall = spec.pointer.falloff const rad0 = fall.radius ?? 0.35 const curve = curves[fall.curve ?? "smoothstep"] const short = Math.min(1, o.aspect) const calm = () => matchMedia("(prefers-reduced-motion: reduce)").matches || matchMedia("(update: slow)").matches const reduced = () => spec.reducedMotion !== "none" && calm() const n = o.targets.length const C = spec.react.some((r) => r.property === "scale" && (r.along === "x" || r.along === "y")) ? 3 : 1 const val = new Float32Array(n * C) const vel = new Float32Array(n * C) const along = (d: number) => { const q = Math.abs(d) / (rad0 * short) return q >= 1 ? 0 : curve(1 - q) } const amp = Math.max(1, ...spec.react.map((r) => Math.abs(r.max))) const rules = spec.react.map((r) => ({ ...r, idx: o.targets.map((t, i) => (r.target === "all" || t.role === r.target ? i : -1)).filter((i) => i >= 0), })) const mask = new Uint32Array(n) rules.forEach((r, ri) => { for (const i of r.idx) mask[i]! |= 1 << ri }) const wholeField = false const wrote: string[] = new Array(n).fill("") for (const t of o.targets) { t.el.style.transformBox = "fill-box" t.el.style.transformOrigin = "center" } let box: DOMRect | null = null let px = 0.5 let py = short / 2 let act = 0 let w = 0 let wv = 0 let t0 = 0 let raf = 0 let last = 0 let seen = true let gone = 0 const touchQ = matchMedia("(hover: none), (pointer: coarse)") const wantsAmb = () => { const forced = root.closest("[data-motion]")?.getAttribute("data-motion") return (spec.a11y?.touch ?? "ambient") === "ambient" && (forced ? forced === "ambient" : touchQ.matches) } const wAmb = (2 * Math.PI) / AMBIENT_CYCLE const ph = Math.random() * 2 * Math.PI let amb = false let clock = 0 let hold = 0 const fit = () => { const r = svg.getBoundingClientRect() box = r.width < (o.min?.[0] ?? 0) || r.height < (o.min?.[1] ?? 0) ? null : r } const toUnit = (x: number, y: number) => { if (!box) return px = (x - box.left) / box.width py = ((y - box.top) / box.height) * o.aspect } const tick = (now: number) => { const dt = Math.min(last ? (now - last) / 1000 : 1 / 60, 1 / 30) last = now const snap = reduced() if (amb && now > hold) { if (snap || calm()) act = 0 else { clock += dt * AMBIENT_SPEED const t = clock px = 0.5 + 0.5 * (0.76 * Math.sin(wAmb * t + ph) + 0.2 * Math.sin(1.93 * wAmb * t + 1.2 + ph)) py = (o.aspect / 2) * (1 + 0.72 * Math.sin(0.75 * wAmb * t + 0.8 + ph) + 0.2 * Math.sin(1.37 * wAmb * t + 2.1 + ph)) if (!act) t0 = now gone = 0 act = 1 } } const st = spec.leave.spring ?? { stiffness: 260, damping: 28 } let busy = false if (snap) w = act else if (!act) [w, wv] = spring(w, wv, 0, LEAVE_K, 2 * Math.sqrt(LEAVE_K), dt) else [w, wv] = spring(w, wv, act, LEAVE_K, 2 * Math.sqrt(LEAVE_K), dt) const z = (v: number) => (Math.abs(v) < 1e-5 ? 0 : v) const rad = rad0 * short for (let i = 0; i < n; i++) { const t = o.targets[i]! let dx = px - t.cx let dy = py - t.cy const dist = Math.sqrt(dx * dx + dy * dy) / rad const outside = dist >= 1 && (C < 2 || (along(px - t.cx) === 0 && along(py - t.cy) === 0)) if (outside) { let rest = true for (let c = 0; c < C; c++) { const k = i * C + c if (val[k] !== 0 || vel[k] !== 0) { rest = false break } } if (rest) continue } const on = act && now - t0 >= 0 ? w : 0 const f0 = dist >= 1 ? 0 : curve(1 - dist) const f1 = C > 1 ? along(px - t.cx) : 0 const f2 = C > 2 ? along(py - t.cy) : 0 for (let c = 0; c < C; c++) { const k = i * C + c const goal = act ? (c === 0 ? f0 : c === 1 ? f1 : f2) * on : 0 if (snap) val[k] = goal else if (!act) { const kk = Math.max(st.stiffness, LEAVE_K) ;[val[k], vel[k]] = spring(val[k]!, vel[k]!, goal, kk, 2 * Math.sqrt(kk), dt) as [number, number] } else [val[k], vel[k]] = spring(val[k]!, vel[k]!, goal, st.stiffness, st.damping, dt) as [number, number] if (goal * amp < 0.005 && Math.abs(val[k]!) * amp < 0.005) { val[k] = 0 vel[k] = 0 continue } if ((Math.abs(val[k]! - goal) + Math.abs(vel[k]!) * dt) * amp > 1e-3) busy = true else if (Math.abs(val[k]!) * amp <= 1e-3 && !act) { val[k] = 0 vel[k] = 0 } } if (!wholeField) { let zero = true for (let c = 0; c < C; c++) if (val[i * C + c] !== 0) { zero = false break } if (zero && wrote[i] === "") continue } let sx = 1 let sy = 1 const m = mask[i]! for (let ri = 0; ri < rules.length; ri++) { if (!(m & (1 << ri))) continue const r = rules[ri]! const ch = r.property === "scale" ? (r.along === "x" ? 1 : r.along === "y" ? 2 : 0) : 0 const amt = val[i * C + ch]! * r.max if (r.property === "scale") { if (ch !== 2) sx += amt if (ch !== 1) sy += amt } } const r3 = (v: number) => Math.round(v * 100) / 100 const ssx = r3(1 + z(sx - 1)) const ssy = r3(1 + z(sy - 1)) const next = (ssx !== 1 || ssy !== 1 ? `scale(${ssx},${ssy})` : "") || "translate(0px,0px)" if (next !== wrote[i]) { t.el.style.transform = next wrote[i] = next } } if (Math.abs(w - act) * amp > 1e-3 || Math.abs(wv) * dt * amp > 1e-3) busy = true else if (act) { w = act wv = 0 } if (!act && gone && now - gone > LEAVE_MS) busy = false raf = busy || (amb && !snap && !calm()) ? requestAnimationFrame(tick) : 0 if (!raf) { if (act) last = 0 else rest() } } function rest() { last = 0 w = 0 wv = 0 val.fill(0) vel.fill(0) for (let i = 0; i < n; i++) { const t = o.targets[i]! if (wrote[i] !== "") t.el.style.transform = "" wrote[i] = "" } } const go = () => { if (!raf && (seen || hold > performance.now()) && box) raf = requestAnimationFrame(tick) } const enterAt = (x: number, y: number) => { if (timer) { clearTimeout(timer) timer = 0 } fit() if (!box) return toUnit(x, y) if (!act) t0 = performance.now() gone = 0 act = 1 go() } let timer: ReturnType | 0 = 0 const letGo = () => { if (act) gone = performance.now() act = 0 go() } const leave = (e?: Event) => { if (e && isTouch(e as PointerEvent)) return if (e && !e.isTrusted) hold = 0 if (amb && performance.now() > hold) return return letGo() } const isTouch = (e: PointerEvent) => e.pointerType === "touch" && touchQ.matches const onMove = (e: PointerEvent) => { if (isTouch(e)) return if (!e.isTrusted) hold = performance.now() + 1000 lastX = e.clientX lastY = e.clientY if (!box) fit() toUnit(e.clientX, e.clientY) if (!act) enterAt(e.clientX, e.clientY) else go() } const onIn = (e: PointerEvent) => !isTouch(e) && enterAt(e.clientX, e.clientY) const onDown = (e: PointerEvent) => { if (e.pointerType !== "touch" || touchQ.matches) return enterAt(e.clientX, e.clientY) setTimeout(leave, 600) } const onFoc = () => { fit() if (!box) return px = 0.5 py = o.aspect / 2 t0 = performance.now() gone = 0 act = 1 go() setTimeout(leave, 900) } root.addEventListener("pointerenter", onIn, { passive: true }) root.addEventListener("pointermove", onMove, { passive: true }) root.addEventListener("pointerleave", leave) root.addEventListener("pointerdown", onDown) root.addEventListener("focusin", onFoc) root.addEventListener("focusout", leave) const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => { fit() if (amb) go() }) : null let lastX = 0 let lastY = 0 const onScr = () => { if (!act || amb) return fit() if (box) toUnit(lastX, lastY) go() } window.addEventListener("scroll", onScr, { passive: true, capture: true }) ro?.observe(svg) const io = typeof IntersectionObserver !== "undefined" ? new IntersectionObserver( ([e]) => { seen = !!e?.isIntersecting sync() if (!seen && raf && hold < performance.now()) { cancelAnimationFrame(raf) raf = 0 last = 0 rest() } else go() }, { threshold: 0.2 }, ) : null const sync = () => { const next = seen && !document.hidden && wantsAmb() if (next === amb) return amb = next if (amb) { fit() go() } else { if (act) gone = performance.now() act = 0 go() } } const onVis = () => sync() const reduceQ = matchMedia("(prefers-reduced-motion: reduce)") const onReduce = () => go() document.addEventListener("visibilitychange", onVis) touchQ.addEventListener?.("change", sync) reduceQ.addEventListener?.("change", onReduce) io?.observe(svg) fit() if (!io) sync() return () => { root.removeEventListener("pointerenter", onIn) root.removeEventListener("pointermove", onMove) root.removeEventListener("pointerleave", leave) root.removeEventListener("pointerdown", onDown) root.removeEventListener("focusin", onFoc) root.removeEventListener("focusout", leave) ro?.disconnect() io?.disconnect() window.removeEventListener("scroll", onScr, { capture: true }) document.removeEventListener("visibilitychange", onVis) touchQ.removeEventListener?.("change", sync) reduceQ.removeEventListener?.("change", onReduce) if (timer) clearTimeout(timer) if (raf) cancelAnimationFrame(raf) for (const t of o.targets) { t.el.style.transform = "" } } } const curves = { smoothstep: (t: number) => t * t * (3 - 2 * t), } as const type Curve = keyof typeof curves function spring(value: number, vel0: number, goal: number, stiff: number, damp: number, dt: number): [number, number] { const steps = Math.max(1, Math.ceil(dt * 240)) const h = dt / steps let x = value let v = vel0 for (let n = 0; n < steps; n++) { v += (-stiff * (x - goal) - damp * v) * h x += v * h } return [x, v] } const MOTION: Spec = { ir: 1, behavior: "displace", pointer: { falloff: { shape: "radial", radius: 0.35, curve: "smoothstep", }, }, react: [ { target: "motif", property: "scale", along: "x", max: 2.53, map: "falloff", }, { target: "motif", property: "scale", along: "y", max: 2.37, map: "falloff", }, ], stagger: { by: "none", ms: 0, }, leave: { settle: "spring", spring: { stiffness: 900, damping: 60, }, }, rest: "static", reducedMotion: "static", a11y: { keyboard: "center-pulse", touch: "ambient", }, } export type WarpProps = { tokens?: Partial> className?: string static?: boolean } export function Warp({ tokens, className = "", static: isStatic = false }: WarpProps) { const ref = useRef(null) useEffect(() => selfSize(ref.current), []) const geometry = useGeometry() useEffect(() => { const root = ref.current if (!root || isStatic || !geometry) return const nodes = root.querySelectorAll("svg [data-role]") const targets: Target[] = geometry.elements.map((e, index) => ({ el: nodes[index]!, role: e.role, index, cx: e.cx, cy: e.cy, })) return mount(root, { spec: MOTION, targets, aspect: ASPECT, min: MIN_BOX }) }, [isStatic, geometry]) return ( ) } ```. Live preview: /embed/warp. ### Agent brief ### 1. What it is A grid of small rectangular tiles. Near the pointer, columns widen and rows deepen, stretching tiles into squares, bars and dashes. Use it as a hero or background panel, or inside a card, wherever a field of marks can answer the pointer. Not for small icons: below 160 × 113 it stays static. Recognised as lattice (confidence 0.95) by the Textures engine. ### 2. Behaviour timeline - **Idle:** the resting field. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; small rectangular tiles grow wider and taller by horizontal and vertical distance to the pointer, trailing it, then spring back when it leaves. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. Falloff: radial, radius 0.5 of the short side, smoothstep. - **Hover-out:** every element springs back (stiffness 170, damping 26); interruptible at any point. - **Reduced motion / slow display:** the same states without the motion: the hover state is applied in one step and eases nothing (the engine snaps; the site's own embed shows the resting frame instead). - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: run the shadcn command from Copy to Agent (`npx shadcn@latest add https://textures.dev/r/warp.json`) or write the file from this Texture's markdown twin (/s/warp.md). One self-contained React file, components/textures/warp.tsx. - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the panel, aspect 1 / 0.7053, covers the host and crops at the edges), `contain` available; works from 160 × 113 up; clips. - Tokens on the root: `--texture-ink` (the marks) and `--texture-field` (the panel); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the spring, the falloff or the reduced-motion branch; the motion spec lives in `motion.ts` and is read by the shared runtime. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Glow (background) · Magnet (hero) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/glow.json --- ## Blinds Twenty-seven thin lines. The pointer twists them up: each is a ribbon turning about its centre, opening to a full stripe under the cursor, the white opening as tapered spindles. Curtain, inverted. - Slug: `blinds` · Category: lines · Pro (LicenseRef-Textures-Pro) · v0.1.0 - What moves: The thin lines twist up around the pointer like ribbons turning face-on, the white opening as long tapered spindles, and thin again when it leaves. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: curtain inverted, slats, ribbons, hairlines · Use cases: hero, background, card · Tags: lines, blinds, twist, ribbon, open, liquid - Rails: Measured 2026-10-27 for 0.1.0: 2.49 KB js, p95 6.6 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 3645 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the stripes - `--texture-field` (color, default `var(--background)`): The panel behind them ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 112 px up; keeps 732/516 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 732 / 516; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Blinds
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/blinds.json` (one self-contained React file at components/textures/blinds.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/blinds` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "blinds" })` (server: /mcp) - Markdown: /s/blinds.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 Texture 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 Blinds hover texture (textures.dev/s/blinds)` (a suggestion the agent and the human can edit, never a write). Code: Pro ($19 a month or $180 a year), not unlocked for this request. Agents: if the prompt gave you a link with a token in the path (/s//.md), fetch that exact link; dropping the token is why you see this. Otherwise tell the user it is a Pro Texture and give them https://textures.dev/checkout?texture=blinds; never recreate it. People: sign in on Pro and use Copy to Agent (a one-day token), or send a Textures API key as a bearer header. ### Agent brief ### 1. What it is Twenty-seven thin vertical lines on the 732 × 516 panel, Curtain's ribbons (Figma 119:549) seen edge-on: 2 px lines at a 26 pitch. The pointer twists them up: each line is a ribbon turning about its own centre line, its drawn width the sine of the turn, so it opens to a full 24 px stripe under the cursor (a quarter turn at most) and the white opens widest there. The turn is strongest at the pointer's height and fades along the ribbon in a tall soft bell, so the white opens as long tapered spindles; it fades to nothing eleven stripes away and the lines are thin again when the pointer leaves. Use it as a hero or background panel, or inside a card, wherever a bold field can answer the pointer. Not for small icons: below 160 × 112 it stays static. ### 2. Behaviour timeline - **Idle:** the thin lines, every ribbon edge-on. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; the ribbons under the pointer begin to turn. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. The twist is drawn from the pointer's position every frame (no lag); each ribbon's share of the turn eases with a 30 ms time constant under the cursor and 140 ms eleven stripes out, so the cloth follows like a liquid. The edges are curves, so each stripe's outline is rewritten while it moves. - **Hover-out:** every ribbon eases edge-on again (200 ms time constant); interruptible at any point. - **Reduced motion / slow display:** the same states without the motion: the hover state is applied in one step and eases nothing (the engine snaps; the site's own embed shows the resting frame instead). - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: run the shadcn command from Copy to Agent (`npx shadcn@latest add https://textures.dev/r/blinds.json`) or write the file from this Texture's markdown twin (/s/blinds.md). One self-contained React file, components/textures/blinds.tsx. - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the 732 × 516 panel covers the host and crops at the edges), `contain` available; works from 160 × 112 up; clips. - Tokens on the root: `--texture-ink` (the lines) and `--texture-field` (the panel); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the twist, its bell, the time constants or the reduced-motion branch. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Settle (hero) · Beam (background) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/settle.json --- ## Current > For agents: this file is the source. Write the file under Code exactly as given, at the path it names, and mount the component as Placement says; do not recreate or approximate the animation. A flat field of short dashes. The pointer is a current running through it: dashes within reach turn to lie along the direction it moves, a wake that relaxes back to flat as it passes. - Slug: `current` · Category: lines · Free (MIT) · v0.1.0 - What moves: Dashes near the pointer turn to lie along the direction it moves and relax back to flat as it passes. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: flow, wake, stream, liquid · Use cases: hero, background, card · Tags: dashes, grid, flow, twist, wake, liquid - Rails: Measured 2026-10-27 for 0.1.0: 3.03 KB js, p95 14.33 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 3856 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the dashes - `--texture-field` (color, default `var(--background)`): The panel behind them ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 112 px up; keeps 732/516 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 732 / 516; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Current
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/current.json` (one self-contained React file at components/textures/current.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/current` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "current" })` (server: /mcp) - Markdown: /s/current.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 Texture 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 Current hover texture (textures.dev/s/current)` (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/current.tsx` ```tsx // Current — a Texture by Textures · https://textures.dev/s/current // Install another: npx shadcn@latest add https://textures.dev/r/.json // For agents: https://textures.md // 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 } const AMBIENT_SPEED = 1.5 const AMBIENT_CYCLE = 4.5 type EngineOptions = { ambientSpeed?: number } function engine( root: HTMLElement, draw: (p: Pointer, dt: number) => boolean | void, min: [number, number], options: EngineOptions = {}, ): () => void { const svg = root.querySelector("svg") if (!svg) return () => {} const mq = () => matchMedia("(prefers-reduced-motion: reduce)").matches || matchMedia("(update: slow)").matches const touchMq = matchMedia("(hover: none), (pointer: coarse)") const forced = () => root.closest("[data-motion]")?.getAttribute("data-motion") ?? null const wantsAmbient = () => (forced() ? forced() === "ambient" : touchMq.matches) const speed = AMBIENT_SPEED * (options.ambientSpeed ?? 1) 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; s: number; ox: number; oy: number } | null = null let px = 366 let py = 258 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 = svg.getBoundingClientRect() if (r.width < min[0] || r.height < min[1]) { box = null return } const s = Math.max(r.width / 732, r.height / 516) box = { left: r.left, top: r.top, s, ox: (r.width - 732 * s) / 2, oy: (r.height - 516 * s) / 2 } } 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 && now > hold) { if (reduced) target = 0 else { clock += dt * speed const t = clock px = 366 + 366 * (0.76 * Math.sin(w * t + phase) + 0.2 * Math.sin(1.93 * w * t + 1.2 + phase)) py = 258 + 258 * (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 }, dt) if (moving || more) frame = requestAnimationFrame(tick) else { frame = 0 last = 0 } } const wake = () => { if (!frame) frame = requestAnimationFrame(tick) } const touchIsAmbient = (e: Event) => (e as PointerEvent).pointerType === "touch" && touchMq.matches const onEnter = (e: PointerEvent) => { if (touchIsAmbient(e)) return measure() } const onMove = (e: PointerEvent) => { if (touchIsAmbient(e)) return const t = performance.now() if (!box || t - lastMoveAt > 250) measure() lastMoveAt = t if (!box) return if (!e.isTrusted) hold = performance.now() + 1000 cx = e.clientX cy = e.clientY px = (cx - box.left - box.ox) / box.s py = (cy - box.top - box.oy) / box.s target = 1 wake() } const onLeave = (e?: Event) => { if (e && touchIsAmbient(e)) return if (e && !e.isTrusted) hold = 0 if (ambient && performance.now() > hold) return target = 0 wake() } const onFocus = () => { measure() if (!box) return px = 366 py = 258 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 - box.ox) / box.s py = (cy - box.top - box.oy) / box.s wake() } const settle = () => { const next = onScreen && !document.hidden && wantsAmbient() if (next === ambient) return ambient = next if (ambient) { measure() if (!box) { ambient = false return } wake() } else { target = 0 wake() } } const io = typeof IntersectionObserver === "function" ? new IntersectionObserver( ([entry]) => { onScreen = !!entry?.isIntersecting settle() }, { threshold: 0.2 }, ) : null io?.observe(svg) if (!io) { onScreen = true settle() } const onVisibility = () => settle() const onCapability = () => settle() const reducedMq = matchMedia("(prefers-reduced-motion: reduce)") const onReduced = () => wake() document.addEventListener("visibilitychange", onVisibility) touchMq.addEventListener?.("change", onCapability) 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("pointercancel", onLeave) 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("pointercancel", onLeave) host.removeEventListener("focusin", onFocus) host.removeEventListener("focusout", onLeave) window.removeEventListener("resize", onResize) window.removeEventListener("scroll", onScroll, { capture: true }) document.removeEventListener("visibilitychange", onVisibility) touchMq.removeEventListener?.("change", onCapability) reducedMq.removeEventListener?.("change", onReduced) io?.disconnect() if (frame) cancelAnimationFrame(frame) } } const W = 732 const H = 516 const DASH_H = 5 const XS: number[] = [ 16, 50, 83, 117, 150, 184, 217, 251, 284, 318, 351, 385, 418, 452, 485, 519, 552, 586, 619, 653, 686, ] const WIDTHS: number[] = XS.map((_, i) => (i % 2 === 0 ? 30 : 29)) const ROWS = 24 const Y0 = 16 const DY = (495 - 16) / (ROWS - 1) const COLS = XS.length const DASHES: [number, number, number][] = [] for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) DASHES.push([XS[c]!, Y0 + r * DY, WIDTHS[c]!]) export type CurrentProps = { tokens?: Partial> className?: string static?: boolean } const STEP = 45 const SPAN = 2 const RADIUS = 2.5 const TAU_SNAP = 0.03 const TAU_RING = 0.04 const TAU_CORNER = 0.025 const TAU_HOME = 0.045 const TAU_HOME_RING = 0.03 const DIR_EASE = 0.5 const STILL = 0.4 const HYSTERESIS = 8 const DEAD = 0.05 const arc = (deg: number) => (((deg % 180) + 270) % 180) - 90 export function Current({ tokens, className = "", static: isStatic = false }: CurrentProps) { const ref = useRef(null) useEffect(() => selfSize(ref.current), []) const rects = useRef<(SVGRectElement | null)[]>([]) useEffect(() => { const root = ref.current if (!root || isStatic) return const n = DASHES.length const angle = new Float32Array(n) const target = new Float32Array(n) const tau = new Float32Array(n).fill(TAU_HOME) const home = new Float32Array(n).fill(TAU_HOME) const written = new Float32Array(n) const centres = XS.map((x, i) => x + DASHES[i]![2] / 2) let lx = NaN let ly = NaN let dx = 1 let dy = 0 let step = 0 let lastActive = 0 return engine( root, ({ x, y, active, reduced }, dt) => { if (!Number.isNaN(lx)) { const sx = x - lx const sy = y - ly const len = Math.hypot(sx, sy) if (len > STILL) { const k = reduced ? 1 : 1 - (1 - DIR_EASE) ** (dt * 60) dx += (sx / len - dx) * k dy += (sy / len - dy) * k const m = Math.hypot(dx, dy) || 1 dx /= m dy /= m const deg = ((((Math.atan2(dy, dx) * 180) / Math.PI) % 180) + 180) % 180 const away = Math.abs(arc(deg - step)) if (away > STEP / 2 + HYSTERESIS) step = (Math.round(deg / STEP) * STEP) % 180 } } lx = x ly = y target.fill(0) const on = active > 0.5 || active > lastActive lastActive = active if (on) { let c0 = 0 for (let c = 1; c < COLS; c++) if (Math.abs(centres[c]! - x) < Math.abs(centres[c0]! - x)) c0 = c const r0 = Math.min(ROWS - 1, Math.max(0, Math.round((y - Y0 - DASH_H / 2) / DY))) const reach = Math.floor(RADIUS) for (let j = -reach; j <= reach; j++) for (let k = -reach; k <= reach; k++) { if (j * j + k * k > RADIUS * RADIUS) continue const c = c0 + k * SPAN const r = r0 + j * SPAN if (c < 0 || c >= COLS || r < 0 || r >= ROWS) continue const i = r * COLS + c const ring = Math.max(Math.abs(j), Math.abs(k)) const corner = j !== 0 && k !== 0 target[i] = arc(corner ? step + STEP : step) tau[i] = TAU_SNAP + ring * TAU_RING + (corner ? TAU_CORNER : 0) home[i] = TAU_HOME + ring * TAU_HOME_RING } } let moving = false for (let i = 0; i < n; i++) { const a = angle[i]! const t = target[i]! if (a === t) { if (t === 0) tau[i] = home[i]! continue } let next: number if (reduced) next = t else { const way = arc(t - a) next = a + way * (1 - Math.exp(-dt / tau[i]!)) if (Math.abs(arc(t - next)) < DEAD) next = t } angle[i] = next if (next !== t) moving = true else if (t === 0) tau[i] = home[i]! if (Math.abs(next - written[i]!) > 0.05 || (next === 0 && written[i] !== 0)) { written[i] = next const el = rects.current[i] if (el) el.style.transform = next === 0 ? "" : `rotate(${next.toFixed(2)}deg)` } } return moving }, [160, 112], ) }, [isStatic]) const style = { "--texture-ink": "var(--foreground)", "--texture-field": "var(--background)", ...tokens, color: "var(--texture-ink)", background: "var(--texture-field)", } as CSSProperties return (
) } ```. Live preview: /embed/current. ### Agent brief ### 1. What it is A field of short dashes resting flat, taken 1:1 from the Textures file (Figma 119:111): twenty-one columns and twenty-four rows on the 732 × 516 panel. The pointer is a current running through it: the dashes within reach turn to lie along the direction it is moving, fully under the cursor and fading out over about two cells, so the field twists into a wake behind the pointer and relaxes back to flat as it passes. Use it as a hero or background panel, or inside a card, wherever a quiet field can answer the pointer. Not for small icons: below 160 × 112 it stays static. ### 2. Behaviour timeline - **Idle:** the flat field. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; the dashes under the pointer turn to its direction of travel as soon as it moves. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. The direction is the pointer's last step, smoothed as a vector. A dash under the cursor answers in the same frame (30 ms time constant); at the edge of its reach a dash follows about 200 ms later, so the turn runs through the field like a liquid. - **Hover-out:** every dash eases back to flat (240 ms time constant); interruptible at any point. - **Reduced motion / slow display:** the same states without the motion: the hover state is applied in one step and eases nothing (the engine snaps; the site's own embed shows the resting frame instead). - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: run the shadcn command from Copy to Agent (`npx shadcn@latest add https://textures.dev/r/current.json`) or write the file from this Texture's markdown twin (/s/current.md). One self-contained React file, components/textures/current.tsx. - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the 732 × 516 panel covers the host and crops at the edges), `contain` available; works from 160 × 112 up; clips. - Tokens on the root: `--texture-ink` (the dashes) and `--texture-field` (the panel); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the reach, the time constants or the reduced-motion branch. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Settle (hero) · Beam (background) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/settle.json --- ## Curtain > For agents: this file is the source. Write the file under Code exactly as given, at the path it names, and mount the component as Placement says; do not recreate or approximate the animation. Twenty-seven stripes. The pointer twists them open: each is a ribbon turning about its centre, narrowing to a hairline, the gaps opening as long tapered spindles, straight again when it leaves. - Slug: `curtain` · Category: lines · Free (MIT) · v0.1.0 - What moves: The stripes twist open around the pointer like ribbons, the gaps opening as long tapered spindles, and hang straight again when it leaves. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: blinds, drapes, stripes, part · Use cases: hero, background, card · Tags: stripes, curtain, twist, ribbon, open, liquid, bold - Rails: Measured 2026-10-27 for 0.1.0: 2.47 KB js, p95 7.31 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 3645 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the stripes - `--texture-field` (color, default `var(--background)`): The panel behind them ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 112 px up; keeps 732/516 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 732 / 516; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Curtain
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/curtain.json` (one self-contained React file at components/textures/curtain.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/curtain` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "curtain" })` (server: /mcp) - Markdown: /s/curtain.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 Texture 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 Curtain hover texture (textures.dev/s/curtain)` (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/curtain.tsx` ```tsx // Curtain — a Texture by Textures · https://textures.dev/s/curtain // Install another: npx shadcn@latest add https://textures.dev/r/.json // For agents: https://textures.md // 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 } const AMBIENT_SPEED = 1.5 const AMBIENT_CYCLE = 4.5 type EngineOptions = { ambientSpeed?: number } function engine( root: HTMLElement, draw: (p: Pointer, dt: number) => boolean | void, min: [number, number], options: EngineOptions = {}, ): () => void { const svg = root.querySelector("svg") if (!svg) return () => {} const mq = () => matchMedia("(prefers-reduced-motion: reduce)").matches || matchMedia("(update: slow)").matches const touchMq = matchMedia("(hover: none), (pointer: coarse)") const forced = () => root.closest("[data-motion]")?.getAttribute("data-motion") ?? null const wantsAmbient = () => (forced() ? forced() === "ambient" : touchMq.matches) const speed = AMBIENT_SPEED * (options.ambientSpeed ?? 1) 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; s: number; ox: number; oy: number } | null = null let px = 366 let py = 258 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 = svg.getBoundingClientRect() if (r.width < min[0] || r.height < min[1]) { box = null return } const s = Math.max(r.width / 732, r.height / 516) box = { left: r.left, top: r.top, s, ox: (r.width - 732 * s) / 2, oy: (r.height - 516 * s) / 2 } } 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 && now > hold) { if (reduced) target = 0 else { clock += dt * speed const t = clock px = 366 + 366 * (0.76 * Math.sin(w * t + phase) + 0.2 * Math.sin(1.93 * w * t + 1.2 + phase)) py = 258 + 258 * (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 }, dt) if (moving || more) frame = requestAnimationFrame(tick) else { frame = 0 last = 0 } } const wake = () => { if (!frame) frame = requestAnimationFrame(tick) } const touchIsAmbient = (e: Event) => (e as PointerEvent).pointerType === "touch" && touchMq.matches const onEnter = (e: PointerEvent) => { if (touchIsAmbient(e)) return measure() } const onMove = (e: PointerEvent) => { if (touchIsAmbient(e)) return const t = performance.now() if (!box || t - lastMoveAt > 250) measure() lastMoveAt = t if (!box) return if (!e.isTrusted) hold = performance.now() + 1000 cx = e.clientX cy = e.clientY px = (cx - box.left - box.ox) / box.s py = (cy - box.top - box.oy) / box.s target = 1 wake() } const onLeave = (e?: Event) => { if (e && touchIsAmbient(e)) return if (e && !e.isTrusted) hold = 0 if (ambient && performance.now() > hold) return target = 0 wake() } const onFocus = () => { measure() if (!box) return px = 366 py = 258 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 - box.ox) / box.s py = (cy - box.top - box.oy) / box.s wake() } const settle = () => { const next = onScreen && !document.hidden && wantsAmbient() if (next === ambient) return ambient = next if (ambient) { measure() if (!box) { ambient = false return } wake() } else { target = 0 wake() } } const io = typeof IntersectionObserver === "function" ? new IntersectionObserver( ([entry]) => { onScreen = !!entry?.isIntersecting settle() }, { threshold: 0.2 }, ) : null io?.observe(svg) if (!io) { onScreen = true settle() } const onVisibility = () => settle() const onCapability = () => settle() const reducedMq = matchMedia("(prefers-reduced-motion: reduce)") const onReduced = () => wake() document.addEventListener("visibilitychange", onVisibility) touchMq.addEventListener?.("change", onCapability) 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("pointercancel", onLeave) 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("pointercancel", onLeave) host.removeEventListener("focusin", onFocus) host.removeEventListener("focusout", onLeave) window.removeEventListener("resize", onResize) window.removeEventListener("scroll", onScroll, { capture: true }) document.removeEventListener("visibilitychange", onVisibility) touchMq.removeEventListener?.("change", onCapability) reducedMq.removeEventListener?.("change", onReduced) io?.disconnect() if (frame) cancelAnimationFrame(frame) } } const W = 732 const H = 516 const N = 27 const X0 = 16 const PITCH = 26 const STRIPE = 24 const Y0 = 15 const Y1 = 499 const TWIST_MAX = 90 const REACH = 11 const SIGMA = 170 const W_MIN = 1.2 const SAMPLES = 40 export type CurtainProps = { tokens?: Partial> className?: string static?: boolean } const TAU_NEAR = 0.03 const TAU_FAR = 0.14 const TAU_HOME = 0.2 const DEAD = 0.003 const rest = (i: number) => `M${X0 + i * PITCH} ${Y0}H${X0 + i * PITCH + STRIPE}V${Y1}H${X0 + i * PITCH}Z` const share = (u: number) => { if (u >= REACH) return 0 const v = 1 - u / REACH return v * v * (3 - 2 * v) } const SPAN = 3 * SIGMA const RAD = Math.PI / 180 export function Curtain({ tokens, className = "", static: isStatic = false }: CurtainProps) { const ref = useRef(null) useEffect(() => selfSize(ref.current), []) const paths = useRef<(SVGPathElement | null)[]>([]) useEffect(() => { const root = ref.current if (!root || isStatic) return const open = new Float32Array(N) const drawnOpen = new Float32Array(N) const drawnY = new Float32Array(N) const drawnX = new Float32Array(N) return engine( root, ({ x, y, active, reduced }, dt) => { let moving = false for (let i = 0; i < N; i++) { const left = X0 + i * PITCH const mid = left + STRIPE / 2 const u = Math.abs(mid - x) / PITCH const target = share(u) * active const o = open[i]! let next: number if (reduced) next = target else { const tau = active > 0 ? TAU_NEAR + (TAU_FAR - TAU_NEAR) * Math.min(1, u / REACH) : TAU_HOME next = o + (target - o) * (1 - Math.exp(-dt / tau)) if (Math.abs(next - target) < DEAD) next = target } open[i] = next if (next !== target) moving = true const el = paths.current[i] if (!el) continue if (next === 0) { if (drawnOpen[i] !== 0) { drawnOpen[i] = 0 el.setAttribute("d", rest(i)) } continue } if ( Math.abs(next - drawnOpen[i]!) < 0.002 && Math.abs(y - drawnY[i]!) < 0.25 && Math.abs(x - drawnX[i]!) < 0.25 ) continue drawnOpen[i] = next drawnY[i] = y drawnX[i] = x const top = Math.max(Y0, y - SPAN) const bottom = Math.min(Y1, y + SPAN) const at = (yy: number, which: -1 | 1) => { const dy = (yy - y) / SIGMA const theta = next * TWIST_MAX * Math.exp(-dy * dy) * RAD const w = Math.max(W_MIN, STRIPE * Math.abs(Math.cos(theta))) return (mid + (which * w) / 2).toFixed(2) } let d = `M${at(Y0, -1)} ${Y0}` if (top > Y0) d += `L${at(top, -1)} ${top.toFixed(2)}` for (let k = 1; k <= SAMPLES; k++) { const yy = top + ((bottom - top) * k) / SAMPLES d += `L${at(yy, -1)} ${yy.toFixed(2)}` } if (bottom < Y1) d += `L${at(Y1, -1)} ${Y1}` d += `L${at(Y1, 1)} ${Y1}` if (bottom < Y1) d += `L${at(bottom, 1)} ${bottom.toFixed(2)}` for (let k = SAMPLES - 1; k >= 0; k--) { const yy = top + ((bottom - top) * k) / SAMPLES d += `L${at(yy, 1)} ${yy.toFixed(2)}` } if (top > Y0) d += `L${at(Y0, 1)} ${Y0}` d += "Z" el.setAttribute("d", d) } return moving }, [160, 112], ) }, [isStatic]) const style = { "--texture-ink": "var(--foreground)", "--texture-field": "var(--background)", ...tokens, color: "var(--texture-ink)", background: "var(--texture-field)", } as CSSProperties return (
) } ```. Live preview: /embed/curtain. ### Agent brief ### 1. What it is Twenty-seven vertical stripes, taken 1:1 from the Textures file (Figma 119:549): 24 wide at a 26 pitch on the 732 × 516 panel. The pointer twists them open: each stripe is a ribbon turning about its own centre line, its drawn width the cosine of the turn, so it narrows to a hairline under the cursor (a quarter turn at most, never a spin) and the black opens widest there. The turn is strongest at the pointer's height and fades along the ribbon in a tall soft bell, so the gaps open as long tapered spindles; it fades to nothing eleven stripes away and the curtain hangs straight again when the pointer leaves. Use it as a hero or background panel, or inside a card, wherever a bold field can answer the pointer. Not for small icons: below 160 × 112 it stays static. ### 2. Behaviour timeline - **Idle:** the closed curtain, every stripe full. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; the ribbons under the pointer begin to turn. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. The twist is drawn from the pointer's position every frame (no lag); each ribbon's share of the turn eases with a 30 ms time constant under the cursor and 140 ms eleven stripes out, so the cloth follows like a liquid. The edges are curves, so each stripe's outline is rewritten while it moves. - **Hover-out:** every ribbon eases straight again (200 ms time constant); interruptible at any point. - **Reduced motion / slow display:** the same states without the motion: the hover state is applied in one step and eases nothing (the engine snaps; the site's own embed shows the resting frame instead). - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: run the shadcn command from Copy to Agent (`npx shadcn@latest add https://textures.dev/r/curtain.json`) or write the file from this Texture's markdown twin (/s/curtain.md). One self-contained React file, components/textures/curtain.tsx. - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the 732 × 516 panel covers the host and crops at the edges), `contain` available; works from 160 × 112 up; clips. - Tokens on the root: `--texture-ink` (the stripes) and `--texture-field` (the panel); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the twist, its bell, the time constants or the reduced-motion branch. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Settle (hero) · Beam (background) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/settle.json --- ## Fill A checkerboard of ink diamonds whose fill runs row by row from whole to almost gone. The pointer pulls the field into the tiles beneath it and drags the whole gradient with its height. - Slug: `fill` · Category: grids · Pro (LicenseRef-Textures-Pro) · v0.1.0 - What moves: The field is pulled into the diamonds under the pointer, and the gradient slides with its height under its column, fading to the sides. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: diamond gradient, halftone, tonal field, fill gradient · Use cases: hero, background, card · Tags: grid, diamonds, gradient, fill, halftone, rows - Rails: Measured 2026-10-27 for 0.1.1: 2.63 KB js, p95 5.63 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 3765 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the diamonds - `--texture-field` (color, default `var(--background)`): The panel, which is also what fills the diamonds ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 112 px up; keeps 732/516 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 732 / 516; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Fill
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/fill.json` (one self-contained React file at components/textures/fill.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/fill` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "fill" })` (server: /mcp) - Markdown: /s/fill.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 Texture 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 Fill hover texture (textures.dev/s/fill)` (a suggestion the agent and the human can edit, never a write). Code: Pro ($19 a month or $180 a year), not unlocked for this request. Agents: if the prompt gave you a link with a token in the path (/s//.md), fetch that exact link; dropping the token is why you see this. Otherwise tell the user it is a Pro Texture and give them https://textures.dev/checkout?texture=fill; never recreate it. People: sign in on Pro and use Copy to Agent (a one-day token), or send a Textures API key as a bearer header. ### Agent brief ### 1. What it is A checkerboard of ink diamonds whose fill runs row by row, taken 1:1 from the Textures file (Figma 119:412): 23.87 px squares turned 45° in two interleaved families of 29 × 15, 36 px apart with a 1.6 px seam, across the 732 × 516 panel. The top row is whole ink; from there the field eats each diamond from its bottom vertex as a kite whose bend climbs the vertical diagonal, two rows in the middle are exactly half, and the last row keeps two slivers of ink. The pointer pulls the field into the tiles beneath it: under the cursor the diamonds fill up to seven rows further along the sequence, falling off in a broad bell (150 px), and the pointer's height carries the gradient with it over the full sequence under the pointer's column, fading to either side over about 260 px: at the top the field reaches up to the pointer, at the bottom the ink reaches down to it, at the centre and far to the sides the frame rests. Every tile passes only through the frame's own states. Use it as a hero or background panel, or inside a card, wherever a tonal field can answer the pointer. Not for small icons: below 160 × 112 it stays static. ### 2. Behaviour timeline - **Idle:** the frame's resting gradient. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; the pull gathers under the pointer and the gradient starts toward its height. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. The gradient's offset rides a soft spring (11 rad/s, damping 0.9) toward up to six rows of travel at the panel's edges; fifteen row shapes are rewritten per frame and every column reuses them. - **Hover-out:** the pull fades and the offset springs back to zero: the frame's resting state returns; interruptible at any point. - **Reduced motion / slow display:** the same states without the spring: pull and gradient follow the pointer directly. - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: run the shadcn command from Copy to Agent (`npx shadcn@latest add https://textures.dev/r/fill.json`) or write the file from this Texture's markdown twin (/s/fill.md). One self-contained React file, components/textures/fill.tsx. - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the 732 × 516 panel covers the host and crops at the edges), `contain` available; works from 160 × 112 up; clips. - Tokens on the root: `--texture-ink` (the diamonds) and `--texture-field` (the panel, which is also what fills them); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the row states, the pull, the travel, the spring or the reduced-motion branch. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Glow (background) · Magnet (hero) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/glow.json --- ## Flip A field of dim tiles. The row under the pointer flips on its vertical axis, tile by tile away from the cursor, to its bright face; it holds a moment and turns back in a quick 120 ms turn. - Slug: `flip` · Category: grids · Pro (LicenseRef-Textures-Pro) · v0.1.0 - What moves: The row under the pointer turns tile by tile on its vertical axis, away from the cursor, to its bright face, holds, and turns back. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: row flip, card flip, tiles, shutter row · Use cases: hero, background, card · Tags: tiles, grid, flip, card-flip, rows, board - Rails: Measured 2026-10-27 for 0.1.1: 3.94 KB js, p95 9.54 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 4313 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the tiles, dim at rest and full when flipped - `--texture-field` (color, default `var(--background)`): The panel behind them ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 112 px up; keeps 732/516 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 732 / 516; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Flip
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/flip.json` (one self-contained React file at components/textures/flip.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/flip` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "flip" })` (server: /mcp) - Markdown: /s/flip.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 Texture 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 Flip hover texture (textures.dev/s/flip)` (a suggestion the agent and the human can edit, never a write). Code: Pro ($19 a month or $180 a year), not unlocked for this request. Agents: if the prompt gave you a link with a token in the path (/s//.md), fetch that exact link; dropping the token is why you see this. Otherwise tell the user it is a Pro Texture and give them https://textures.dev/checkout?texture=flip; never recreate it. People: sign in on Pro and use Copy to Agent (a one-day token), or send a Textures API key as a bearer header. ### Agent brief ### 1. What it is A field of dim tiles, thirty-seven by twenty-six, on the lattice of the Textures file (Figma 163:30822; its twenty-seventh row overflowed the panel and was cut): 16.5 px tiles at a 19.5 pitch, the resting face a tenth of the ink. The motion is Row Flip B from the Mural x Adurant experiments: the row under the pointer turns like a row of cards, every tile turning on its vertical axis away from the cursor's column one after another to its bright face; the row holds while the pointer stays and for 600 ms after, then turns back the same way. A fast move flips every row it crosses. Use it as a hero or background panel, or inside a card, wherever a board-like field can answer the pointer. Not for small icons: below 160 × 112 it stays static. ### 2. Behaviour timeline - **Idle:** the resting field, every tile dim. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; the row under the pointer starts to turn from the cursor's column outward. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. Each tile's turn is a 120 ms turn about its vertical axis, the turn running from the cursor's column to the row's far edge in 180 ms; the path between frames is interpolated so no row is skipped. The resting field is one pattern; only rows in motion are elements, from a pool of twenty-four. - **Hover-out:** every flipped row holds 600 ms, then turns back from the cursor's column outward in 0.6 of that time; interruptible at any point: a row caught on its way back reverses in place. - **Reduced motion / slow display:** the same states without the turn: tiles swap faces. - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: run the shadcn command from Copy to Agent (`npx shadcn@latest add https://textures.dev/r/flip.json`) or write the file from this Texture's markdown twin (/s/flip.md). One self-contained React file, components/textures/flip.tsx. - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the 732 × 516 panel covers the host and crops at the edges), `contain` available; works from 160 × 112 up; clips. - Tokens on the root: `--texture-ink` (the tiles, dim at rest and full when flipped) and `--texture-field` (the panel); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the flip, the stagger, the hold or the reduced-motion branch. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Glow (background) · Magnet (hero) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/glow.json --- ## Fork A field of sideways Y marks, hairlines at rest. Under the pointer the rows grow thick from their joints across the full width, most at the cursor's row and less above and below, and thin back. - Slug: `fork` · Category: grids · Pro (LicenseRef-Textures-Pro) · v0.1.0 - What moves: The rows near the pointer grow thick from their joints across the full width, most at the cursor's row, and thin back to hairlines as it passes. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: wye, branches, tripods, y-field · Use cases: hero, background, card · Tags: marks, forks, hex, thicken, grow, liquid - Rails: Measured 2026-10-27 for 0.1.1: 2.22 KB js, p95 16.06 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 3510 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the marks - `--texture-field` (color, default `var(--background)`): The panel behind them ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 112 px up; keeps 732/516 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 732 / 516; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Fork
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/fork.json` (one self-contained React file at components/textures/fork.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/fork` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "fork" })` (server: /mcp) - Markdown: /s/fork.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 Texture 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 Fork hover texture (textures.dev/s/fork)` (a suggestion the agent and the human can edit, never a write). Code: Pro ($19 a month or $180 a year), not unlocked for this request. Agents: if the prompt gave you a link with a token in the path (/s//.md), fetch that exact link; dropping the token is why you see this. Otherwise tell the user it is a Pro Texture and give them https://textures.dev/checkout?texture=fork; never recreate it. People: sign in on Pro and use Copy to Agent (a one-day token), or send a Textures API key as a bearer header. ### Agent brief ### 1. What it is A field of sideways Y marks, hairlines at rest, taken 1:1 from the Textures file (Figma 119:517): the frame's own three-armed stroke on its hex lattice across the 732 × 516 panel. Under the pointer the marks grow thick from their joints: the arms swell to seven and a half times their width in the rows under the cursor, across the full width of the panel, and less in the rows above and below, fading out over 150 px, so the thickening shines along the rows like a liquid band and thins back as the pointer passes. Use it as a hero or background panel, or inside a card, wherever a quiet field can answer the pointer. Not for small icons: below 160 × 112 it stays static. ### 2. Behaviour timeline - **Idle:** the resting field, every mark a hairline. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; the marks under the pointer thicken. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. A mark under the cursor answers in the same frame (30 ms time constant); at the edge of the reach it follows about 140 ms later, so the growth runs through the field like a liquid. Each mark is three rectangles overlapping at the joint into one clean hub; the width is the only thing that moves, so the mark's outline is rewritten while it moves. - **Hover-out:** every mark eases back to its hairline (200 ms time constant); interruptible at any point. - **Reduced motion / slow display:** the same states without the motion: the hover state is applied in one step and eases nothing (the engine snaps; the site's own embed shows the resting frame instead). - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: run the shadcn command from Copy to Agent (`npx shadcn@latest add https://textures.dev/r/fork.json`) or write the file from this Texture's markdown twin (/s/fork.md). One self-contained React file, components/textures/fork.tsx. - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the 732 × 516 panel covers the host and crops at the edges), `contain` available; works from 160 × 112 up; clips. - Tokens on the root: `--texture-ink` (the marks) and `--texture-field` (the panel); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the reach, the thickness, the time constants or the reduced-motion branch. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Glow (background) · Magnet (hero) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/glow.json --- ## Gears A grid of filleted crosses with an X mark in every square. The pointer twists a band of gears: the row under it an eighth turn, the rows around it smaller steps, rolling out from the cursor. - Slug: `gears` · Category: grids · Pro (LicenseRef-Textures-Pro) · v0.1.0 - What moves: The row of marks under the pointer turns an eighth turn, the rows around it smaller steps, each change rolling out from the cursor row by row and mark by mark. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: static - Aliases: cogs, electrical grid, crosses, gear train · Use cases: hero, background, card · Tags: grid, marks, gears, rotate, band, steps, technical - Rails: Measured 2026-10-27 for 0.1.0: 2.64 KB js, p95 6.13 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 4049 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the grid and the marks - `--texture-field` (color, default `var(--background)`): The panel behind them ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 112 px up; keeps 732/516 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 732 / 516; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Gears
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/gears.json` (one self-contained React file at components/textures/gears.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/gears` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "gears" })` (server: /mcp) - Markdown: /s/gears.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 Texture 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 Gears hover texture (textures.dev/s/gears)` (a suggestion the agent and the human can edit, never a write). Code: Pro ($19 a month or $180 a year), not unlocked for this request. Agents: if the prompt gave you a link with a token in the path (/s//.md), fetch that exact link; dropping the token is why you see this. Otherwise tell the user it is a Pro Texture and give them https://textures.dev/checkout?texture=gears; never recreate it. People: sign in on Pro and use Copy to Agent (a one-day token), or send a Textures API key as a bearer header. ### Agent brief ### 1. What it is A grid of filleted crosses with an X mark in every square, an electrical grid, taken 1:1 from the Textures file (Figma 119:363): the frame's own cross tiled twenty-six by eighteen and its own mark twenty-five by seventeen across the 732 × 516 panel. The pointer twists a band of gears: every mark in the row under it turns an eighth turn in one clear step, the two rows either side two thirds of that and the three beyond a third, and every change rolls out from the cursor row by row and mark by mark, a little later the farther away. Moving down the field carries the band with it; leaving lets it roll home. The field reads like a gear train answering broadly. Use it as a hero or background panel, or inside a card, wherever a technical field can answer the pointer. Not for small icons: below 160 × 112 it stays static. ### 2. Behaviour timeline - **Idle:** the resting grid, every mark an X. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; the band around the pointer's row rolls into its steps, out from the cursor. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. The pointer's row is read live with a little hysteresis at its edges, so the band never flickers between two rows; a change reaches each mark 25 ms later per row and 6 ms later per mark away from the cursor, and the mark eases into its step with a 70 ms time constant, home with 90 ms. The grid is one pattern and never moves. - **Hover-out:** every mark snaps back to its X (90 ms time constant); interruptible at any point. - **Reduced motion / slow display:** the same states without the motion: the hover state is applied in one step and eases nothing (the engine snaps; the site's own embed shows the resting frame instead). - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: run the shadcn command from Copy to Agent (`npx shadcn@latest add https://textures.dev/r/gears.json`) or write the file from this Texture's markdown twin (/s/gears.md). One self-contained React file, components/textures/gears.tsx. - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the 732 × 516 panel covers the host and crops at the edges), `contain` available; works from 160 × 112 up; clips. - Tokens on the root: `--texture-ink` (the grid and the marks) and `--texture-field` (the panel); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the steps, the roll-out, the time constants or the reduced-motion branch. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Glow (background) · Magnet (hero) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/glow.json --- ## Iris A field of cells, each crossed by a hairline quarter arc. Near the pointer the rings focus in: the inner edge moves inward and the arc thickens into a crescent, then thins back as it passes. - Slug: `iris` · Category: circles · Pro (LicenseRef-Textures-Pro) · v0.1.0 - What moves: Rings near the pointer focus in, their arcs thickening into crescents and sliding a little toward it, and thin back to hairlines as it passes. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: focus, arcs, crescents, scales · Use cases: hero, background, card · Tags: rings, arcs, cells, focus, crescent, liquid - Rails: Measured 2026-10-27 for 0.1.1: 2.55 KB js, p95 10.83 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 3626 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the rings - `--texture-field` (color, default `var(--background)`): The panel behind them ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 112 px up; keeps 732/516 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 732 / 516; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Iris
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/iris.json` (one self-contained React file at components/textures/iris.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/iris` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "iris" })` (server: /mcp) - Markdown: /s/iris.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 Texture 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 Iris hover texture (textures.dev/s/iris)` (a suggestion the agent and the human can edit, never a write). Code: Pro ($19 a month or $180 a year), not unlocked for this request. Agents: if the prompt gave you a link with a token in the path (/s//.md), fetch that exact link; dropping the token is why you see this. Otherwise tell the user it is a Pro Texture and give them https://textures.dev/checkout?texture=iris; never recreate it. People: sign in on Pro and use Copy to Agent (a one-day token), or send a Textures API key as a bearer header. ### Agent brief ### 1. What it is A field of cells, seventeen by twelve, taken 1:1 from the Textures file (Figma 119:519): each is crossed by a hairline quarter arc, the edge of a ring centred up and left of the cell. Near the pointer the rings focus in: the outer edge holds and the inner edge moves inward, so the arc thickens into a crescent toward the ring's centre, most under the cursor and fading out over 150 px, and thins back to a hairline as the pointer passes; each ring also slides a few px toward the pointer inside its cell, the way Lift's circles do. Use it as a hero or background panel, or inside a card, wherever a quiet field can answer the pointer. Not for small icons: below 160 × 112 it stays static. ### 2. Behaviour timeline - **Idle:** the resting field, every arc a hairline. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; the rings under the pointer thicken. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. A ring under the cursor answers in the same frame (30 ms time constant); at the edge of the reach it follows about 140 ms later, so the focus runs through the field like a liquid. The band is the stroke of one circle per cell (outer edge held, inner edge moving in) and the slide one translate. - **Hover-out:** every ring eases back to its hairline (200 ms time constant); interruptible at any point. - **Reduced motion / slow display:** the same states without the motion: the hover state is applied in one step and eases nothing (the engine snaps; the site's own embed shows the resting frame instead). - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: run the shadcn command from Copy to Agent (`npx shadcn@latest add https://textures.dev/r/iris.json`) or write the file from this Texture's markdown twin (/s/iris.md). One self-contained React file, components/textures/iris.tsx. - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the 732 × 516 panel covers the host and crops at the edges), `contain` available; works from 160 × 112 up; clips. - Tokens on the root: `--texture-ink` (the rings) and `--texture-field` (the panel); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the reach, the thickness, the time constants or the reduced-motion branch. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Lift (background, pro) · Sphere (hero) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/lift.json --- ## Mountain A field of jog bars lined up into diagonals, white on black. Hovering lifts the run above the pointer on every bar, most under it and less to both sides: a mountain climbs above the cursor. - Slug: `mountain` · Category: lines · Pro (LicenseRef-Textures-Pro) · v0.1.0 - What moves: On every bar the run just above the pointer's row stretches up and carries everything above it: most on the pointer's bar, less left, slowly less right. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: mountain field, jog bars, parting field, madrone stripes · Use cases: hero, background, card · Tags: lines, bars, jogs, stripes, lift, field - Rails: Measured 2026-10-27 for 0.1.0: 2.69 KB js, p95 10.49 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 3999 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the bars - `--texture-field` (color, default `var(--background)`): The panel behind them ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 112 px up; keeps 732/516 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 732 / 516; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Mountain
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/mountain.json` (one self-contained React file at components/textures/mountain.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/mountain` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "mountain" })` (server: /mcp) - Markdown: /s/mountain.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 Texture 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 Mountain hover texture (textures.dev/s/mountain)` (a suggestion the agent and the human can edit, never a write). Code: Pro ($19 a month or $180 a year), not unlocked for this request. Agents: if the prompt gave you a link with a token in the path (/s//.md), fetch that exact link; dropping the token is why you see this. Otherwise tell the user it is a Pro Texture and give them https://textures.dev/checkout?texture=mountain; never recreate it. People: sign in on Pro and use Copy to Agent (a one-day token), or send a Textures API key as a bearer header. ### Agent brief ### 1. What it is A field of jog bars across the 732 × 516 panel, white on black, from the Madrone x Adurant experiments (Mountain field on the Parting field lattice): sixty-four pitches across, each bar a stroke 0.78 of the pitch running down, a straight run of 1.8 pitches then a 45° jog to the right of 2.2, repeating, the jogs of neighbouring bars lined up into diagonals with the same gap as between the runs. Hovering raises one run per bar, the run just above the pointer's row: its top moves up and everything above it on that bar rises with it, so a mountain of the field climbs above the cursor. The rise peaks at twelve pitches on the bar under the pointer, spreads to the left on a bell six bars wide and falls a fifth of a pitch per bar to the right in total over the stacked runs, so no two jogs ever close up. Use it as a hero or background panel, or inside a card, wherever a bold striped field can answer the pointer. Not for small icons: below 160 × 112 it stays static. ### 2. Behaviour timeline - **Idle:** the resting lattice. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; the runs above the pointer's row start to rise. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. The pointer's bar and row are found each frame; every bar's lift on that row eases toward its target with a 50 ms time constant, and lifts on rows the pointer has left ease back to nothing on their own, so moving to another row never snaps. The field is one path, rewritten once a frame while anything moves. - **Hover-out:** every lift eases back; interruptible at any point. - **Reduced motion / slow display:** the same states without the ease: the lifts sit at their targets directly. - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: run the shadcn command from Copy to Agent (`npx shadcn@latest add https://textures.dev/r/mountain.json`) or write the file from this Texture's markdown twin (/s/mountain.md). One self-contained React file, components/textures/mountain.tsx. - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the 732 × 516 panel covers the host and crops at the edges), `contain` available; works from 160 × 112 up; clips. - Tokens on the root: `--texture-ink` (the bars) and `--texture-field` (the panel); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the lattice, the rise, its spread and fall, the ease or the reduced-motion branch. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Settle (hero) · Beam (background) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/settle.json --- ## Pillars > For agents: this file is the source. Write the file under Code exactly as given, at the path it names, and mount the component as Placement says; do not recreate or approximate the animation. A field of white-capped cylinders, row behind row. Under the pointer they rise in a smoothed pyramid, most beneath it and less around, bodies showing between the caps, and settle back as it moves on. - Slug: `pillars` · Category: grids · Free (MIT) · v0.1.0 - What moves: The cylinders under the pointer rise in a smoothed pyramid, most beneath it and less around, bodies showing between the caps, and settle back as it moves. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: cylinders, pegs, relief, pyramid lift · Use cases: hero, background, card · Tags: cylinders, relief, grid, lift, pyramid, rows - Rails: Measured 2026-10-27 for 0.1.1: 2.31 KB js, p95 6.49 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 3928 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): The caps and the bodies' edges - `--texture-field` (color, default `var(--background)`): The panel and the bodies ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 112 px up; keeps 732/516 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 732 / 516; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Pillars
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/pillars.json` (one self-contained React file at components/textures/pillars.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/pillars` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "pillars" })` (server: /mcp) - Markdown: /s/pillars.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 Texture 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 Pillars hover texture (textures.dev/s/pillars)` (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/pillars.tsx` ```tsx // Pillars — a Texture by Textures · https://textures.dev/s/pillars // Install another: npx shadcn@latest add https://textures.dev/r/.json // For agents: https://textures.md // 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 } const AMBIENT_SPEED = 1.5 const AMBIENT_CYCLE = 4.5 type EngineOptions = { ambientSpeed?: number } function engine( root: HTMLElement, draw: (p: Pointer, dt: number) => boolean | void, min: [number, number], options: EngineOptions = {}, ): () => void { const svg = root.querySelector("svg") if (!svg) return () => {} const mq = () => matchMedia("(prefers-reduced-motion: reduce)").matches || matchMedia("(update: slow)").matches const touchMq = matchMedia("(hover: none), (pointer: coarse)") const forced = () => root.closest("[data-motion]")?.getAttribute("data-motion") ?? null const wantsAmbient = () => (forced() ? forced() === "ambient" : touchMq.matches) const speed = AMBIENT_SPEED * (options.ambientSpeed ?? 1) 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; s: number; ox: number; oy: number } | null = null let px = 366 let py = 258 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 = svg.getBoundingClientRect() if (r.width < min[0] || r.height < min[1]) { box = null return } const s = Math.max(r.width / 732, r.height / 516) box = { left: r.left, top: r.top, s, ox: (r.width - 732 * s) / 2, oy: (r.height - 516 * s) / 2 } } 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 && now > hold) { if (reduced) target = 0 else { clock += dt * speed const t = clock px = 366 + 366 * (0.76 * Math.sin(w * t + phase) + 0.2 * Math.sin(1.93 * w * t + 1.2 + phase)) py = 258 + 258 * (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 }, dt) if (moving || more) frame = requestAnimationFrame(tick) else { frame = 0 last = 0 } } const wake = () => { if (!frame) frame = requestAnimationFrame(tick) } const touchIsAmbient = (e: Event) => (e as PointerEvent).pointerType === "touch" && touchMq.matches const onEnter = (e: PointerEvent) => { if (touchIsAmbient(e)) return measure() } const onMove = (e: PointerEvent) => { if (touchIsAmbient(e)) return const t = performance.now() if (!box || t - lastMoveAt > 250) measure() lastMoveAt = t if (!box) return if (!e.isTrusted) hold = performance.now() + 1000 cx = e.clientX cy = e.clientY px = (cx - box.left - box.ox) / box.s py = (cy - box.top - box.oy) / box.s target = 1 wake() } const onLeave = (e?: Event) => { if (e && touchIsAmbient(e)) return if (e && !e.isTrusted) hold = 0 if (ambient && performance.now() > hold) return target = 0 wake() } const onFocus = () => { measure() if (!box) return px = 366 py = 258 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 - box.ox) / box.s py = (cy - box.top - box.oy) / box.s wake() } const settle = () => { const next = onScreen && !document.hidden && wantsAmbient() if (next === ambient) return ambient = next if (ambient) { measure() if (!box) { ambient = false return } wake() } else { target = 0 wake() } } const io = typeof IntersectionObserver === "function" ? new IntersectionObserver( ([entry]) => { onScreen = !!entry?.isIntersecting settle() }, { threshold: 0.2 }, ) : null io?.observe(svg) if (!io) { onScreen = true settle() } const onVisibility = () => settle() const onCapability = () => settle() const reducedMq = matchMedia("(prefers-reduced-motion: reduce)") const onReduced = () => wake() document.addEventListener("visibilitychange", onVisibility) touchMq.addEventListener?.("change", onCapability) 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("pointercancel", onLeave) 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("pointercancel", onLeave) host.removeEventListener("focusin", onFocus) host.removeEventListener("focusout", onLeave) window.removeEventListener("resize", onResize) window.removeEventListener("scroll", onScroll, { capture: true }) document.removeEventListener("visibilitychange", onVisibility) touchMq.removeEventListener?.("change", onCapability) reducedMq.removeEventListener?.("change", onReduced) io?.disconnect() if (frame) cancelAnimationFrame(frame) } } const W = 732 const H = 516 const COLS = 34 const ROWS = 16 const PX = 23.0941 const PY = 33.4779 const X0 = -27 const X0_ODD = -15 const Y0 = 0.74 const BODY_X = 0.5 const BODY_W = 23.094 const BODY_Y = 6.748 const BODY_H = 158.23 const PEAK = 64 const REACH = 5.2 const CURVE = 1.5 const TAU = 0.06 export type PillarsProps = { tokens?: Partial> className?: string static?: boolean } const N = COLS * ROWS const colX = (r: number, c: number) => (r % 2 ? X0_ODD : X0) + c * PX const cap = (x: number, y: number) => `M${(x + 24.0941).toFixed(2)} ${(y + 6.17535).toFixed(2)}c0 3.41056-5.3936 6.17535-12.047 6.17535s-12.047-2.76479-12.047-6.17535 5.3936-6.17535 12.047-6.17535 12.047 2.76479 12.047 6.17535z` const body = (x: number, y: number) => `M${(x + BODY_X).toFixed(2)} ${(y + BODY_Y).toFixed(2)}h${BODY_W.toFixed(3)}v${BODY_H.toFixed(2)}h-${BODY_W.toFixed(3)}z` const rowPaths = (r: number, lift: Float32Array) => { let b = "" let c = "" const y0 = Y0 + r * PY for (let i = 0; i < COLS; i++) { const x = colX(r, i) const y = y0 - lift[r * COLS + i]! b += body(x, y) c += cap(x, y) } return [b, c] as const } const REST = Array.from({ length: ROWS }, (_, r) => rowPaths(r, new Float32Array(N))) export function Pillars({ tokens, className = "", static: isStatic = false }: PillarsProps) { const ref = useRef(null) useEffect(() => selfSize(ref.current), []) const bodies = useRef<(SVGPathElement | null)[]>([]) const caps = useRef<(SVGPathElement | null)[]>([]) useEffect(() => { const root = ref.current if (!root || isStatic) return const lift = new Float32Array(N) const atRest = new Uint8Array(ROWS).fill(1) return engine( root, ({ x, y, active, reduced }, dt) => { const fx = x const fy = y let moving = false const k = reduced ? 1 : 1 - Math.exp(-dt / TAU) for (let r = 0; r < ROWS; r++) { let rowMoving = false let rowLifted = false for (let i = 0; i < COLS; i++) { const j = r * COLS + i const dx = (colX(r, i) + 12.047 - fx) / PX const dy = (Y0 + r * PY + 6.175 - fy) / PY const d = Math.hypot(dx, dy) const want = d >= REACH ? 0 : PEAK * active * Math.pow(1 - d / REACH, CURVE) let next = lift[j]! + (want - lift[j]!) * k if (Math.abs(next - want) < 0.03) next = want else rowMoving = true lift[j] = next if (next !== 0) rowLifted = true } if (rowMoving) moving = true const bEl = bodies.current[r] const cEl = caps.current[r] if (!bEl || !cEl) continue if (!rowLifted) { if (!atRest[r]) { bEl.setAttribute("d", REST[r]![0]) cEl.setAttribute("d", REST[r]![1]) atRest[r] = 1 } continue } if (rowMoving || atRest[r] || reduced) { const [b, c] = rowPaths(r, lift) bEl.setAttribute("d", b) cEl.setAttribute("d", c) atRest[r] = 0 } } return moving }, [160, 112], ) }, [isStatic]) const style = { "--texture-ink": "var(--foreground)", "--texture-field": "var(--background)", ...tokens, color: "var(--texture-ink)", background: "var(--texture-field)", } as CSSProperties return (
) } ```. Live preview: /embed/pillars. ### Agent brief ### 1. What it is A field of white-capped black cylinders across the 732 × 516 panel, 1:1 from the Textures file (Figma 167:31793): 34 to a row on a 23.09 px pitch, rows 33.48 px apart with odd rows shifted 12 px, each cylinder the frame's own group (a 24.09 × 12.35 elliptical cap over a 158 px body with 1 px edges), every row drawn over the one above so only the caps and a sliver of body show. Under the pointer the cylinders rise in a smoothed pyramid, the one beneath it by 64 px and the ring around it less out to about five cells, their bodies showing between the caps in front, and settle back as it moves on. Use it as a hero or background panel, or inside a card, wherever a dense relief can answer the pointer. Not for small icons: below 160 × 112 it stays static. ### 2. Behaviour timeline - **Idle:** the frame at rest, every cap on its row. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; the cylinders under the pointer start to rise. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. The lift's centre is the pointer itself; each cylinder eases toward its lift with a 60 ms time constant, so the field stays in step with the cursor. Rows with a lifted cylinder are rewritten as two paths (bodies, caps); rows at rest keep theirs. - **Hover-out:** every cylinder eases back down; interruptible at any point. - **Reduced motion / slow display:** the same states without the ease: the cylinders sit at their lift directly. - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: run the shadcn command from Copy to Agent (`npx shadcn@latest add https://textures.dev/r/pillars.json`) or write the file from this Texture's markdown twin (/s/pillars.md). One self-contained React file, components/textures/pillars.tsx. - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the 732 × 516 panel covers the host and crops at the edges), `contain` available; works from 160 × 112 up; clips. - Tokens on the root: `--texture-ink` (the caps and edges) and `--texture-field` (the panel and the bodies); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the field, the cylinder, the pyramid, the ease or the reduced-motion branch. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Glow (background) · Magnet (hero) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/glow.json --- ## Pinch > For agents: this file is the source. Write the file under Code exactly as given, at the path it names, and mount the component as Placement says; do not recreate or approximate the animation. A grid of cells, each filled by its square. Near the pointer the squares shrink to the smallest unit in a soft dome, trailing it; they fill their cells again when it leaves. Cells, inverted. - Slug: `pinch` · Category: grids · Free (MIT) · v0.1.0 - What moves: Every cell is filled by its square. Near the pointer the squares shrink to the smallest unit in a soft dome, then fill their cells again when it leaves. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: cells inverted, pixel grid, cell grid, LED board off, halftone squares · Use cases: hero, background, image tile · Tags: grid, cells, squares, shrink, pinch, follow, soft, dense - Rails: Measured 2026-10-27 for 0.1.0: 1.34 KB js, p95 9.98 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 4863 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the marks - `--texture-field` (color, default `var(--background)`): The panel behind them ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 113 px up; keeps 1/0.7049 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 1 / 0.7049; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Pinch
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/pinch.json` (one self-contained React file at components/textures/pinch.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/pinch` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "pinch" })` (server: /mcp) - Markdown: /s/pinch.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 Texture 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 Pinch hover texture (textures.dev/s/pinch)` (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/pinch.tsx` ```tsx // Pinch — a Texture by Textures · https://textures.dev/s/pinch // Install another: npx shadcn@latest add https://textures.dev/r/.json // For agents: https://textures.md // License: free to use "use client" import { type CSSProperties, useEffect, useRef, useState } from "react" function selfSize(root: HTMLElement | null): void { const host = root?.parentElement if (!root || !host || host.getBoundingClientRect().height > 0) return root.style.minHeight = "240px" } const ASPECT = 0.7049 const MIN_BOX: [number, number] = [160, 113] type El = { id: string; role: string; d: string; cx: number; cy: number; ny?: number } const PACKED = "7d1NSsNQFMXx+8SB6MyhA0k3oVtwN7YuwZG4Mx07EQoNTlxBnQhGUoux8as1JmjyUzAn0Ms77/bl3/OSgukmpdiNSFsvv9s0TdM0TdM0TdM0TdM0TdP0/9Un+5WmKIqiKIqiKIqiKIqiqM/VY9zGYYoYRfmTRSzPZkXE3VMsjvF6lhfl68YpvqzJazXlKyY/qGljnL7NZ0h9y2o1+QDH2aSmyfvT1Th9uX62p5OEo9YBjuIojjbh6BhHrQMcxVEc/TWOjr65fmYfXD+jb8aZfTCf9WraGKdv8xlS37JaTT7AcTapafL+dDVOX64fHLUOcBRHcbQpR8/s6+1L7Ovt6+3rG+3rT+VRn6fyqDwqj8qj8qg8Ko/Ko57X46i+4SiO+v4oHuAojuKoPIqjOIqjOIqj8iiO4iiO4qg8iqP6hqM42oM8mtWun3yN6yerjZOvMZ+va9oYp2/zGVLfslpNPsBxNqlp8v50NU5frh951OepPCqPyqPyqDwqj8qj8qg8Ko/qmzwqj8qjcpU8Ko/Ko0O9fg6m5/cPEXG0qDmOiPnieF1E7BaxOEZcFRF7RXk8WnqbL1/9tqZ61WrN8dLbT2p4402vefvr3namk6TXvOGoXvP2Wxwt/76crdZUZ+/nU9Wseqtq3s9n/RreeNNr3v66NxzlDUf1mremHB3b1/NmX6/XvLk/yhuO6jVvOGod8IajvOGoXvOGo7zhqHvRvHnOZB3w5nk9bziq17zhqHXAG47y5ntP7qHw5v4ob54z6TVvOGod8IajvOGoXvOGo9YBb3rNm+89uRfNm+dMvHler9e84ah1wBuO8oajes1bi94u00Uq60qPbR7L/1/SzThj8zEf8zEf8zGfzufzDA==" type Geometry = { elements: El[]; plates: string[] } let pending: Promise | null = null async function unpack(): Promise { const bytes = Uint8Array.from(atob(PACKED), (c) => c.charCodeAt(0)) const v = new DataView( await new Response(new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate-raw"))).arrayBuffer(), ) let o = 0 const u8 = () => v.getUint8(o++) const u16 = () => ((o += 2), v.getUint16(o - 2, true)) const i16 = () => ((o += 2), v.getInt16(o - 2, true)) u8() const ne = u16() const ops = Array.from({ length: ne + u16() }, () => Array.from({ length: u16() }, u8)) let x = 0 let y = 0 const pt = () => `${(x += i16()) / 10000} ${(y += i16()) / 10000}` const paths = ops.map((a) => a .map( (c) => ["M", "L", "Q", "A", "Z"][c] + (c === 2 ? `${pt()} ${pt()}` : c === 3 ? `${i16() / 10000} ${i16() / 10000} ${i16() / 10} ${i16()} ${i16()} ${pt()}` : c === 4 ? "" : pt()), ) .join(""), ) let cx = 0 let cy = 0 const elements = paths .slice(0, ne) .map((d, i): El => ({ id: String(i), role: "motif", d, cx: (cx += i16()) / 10000, cy: (cy += i16()) / 10000 })) return { elements, plates: paths.slice(ne) } } function useGeometry(): Geometry | null { const [geometry, setGeometry] = useState(null) useEffect(() => { let live = true void (pending ??= unpack()).then((g) => live && setGeometry(g)) return () => { live = false } }, []) return geometry } type React_ = { target: string property: "translate" | "scale" | "rotate" | "opacity" along?: "normal" | "tangent" | "toward" | "away" | "x" | "y" max: number map?: "falloff" | "inverse-falloff" | "constant" } type Spec = { ir?: 1 behavior?: "displace" | "clear" | "glow" rest?: "static" pointer: { falloff: { shape?: "radial" | "directional" radius?: number curve?: Curve } } react: React_[] stagger?: { by?: "distance" | "index" | "none"; ms?: number } leave: { settle: "spring" | "ease" spring?: { stiffness: number; damping: number } } reducedMotion: "none" | "static" | "simplified" a11y?: { keyboard?: "center-pulse" | "center-hold" | "none" touch?: "ambient" | "tap-ripple" | "tap-toggle" | "static" } } type Target = { el: SVGGraphicsElement; role: string; index: number; cx: number; cy: number } type Options = { spec: Spec; targets: Target[]; aspect: number; min?: [number, number] } const AMBIENT_SPEED = 1.5 const AMBIENT_CYCLE = 4.5 const LEAVE_K = 900 const LEAVE_MS = 350 function mount(root: HTMLElement, o: Options): () => void { const svg = root.querySelector("svg") if (!svg) return () => {} const { spec } = o const fall = spec.pointer.falloff const rad0 = fall.radius ?? 0.35 const curve = curves[fall.curve ?? "smoothstep"] const short = Math.min(1, o.aspect) const calm = () => matchMedia("(prefers-reduced-motion: reduce)").matches || matchMedia("(update: slow)").matches const reduced = () => spec.reducedMotion !== "none" && calm() const n = o.targets.length const C: number = 1 const val = new Float32Array(n * C) const vel = new Float32Array(n * C) const amp = Math.max(1, ...spec.react.map((r) => Math.abs(r.max))) const rules = spec.react.map((r) => ({ ...r, idx: o.targets.map((t, i) => (r.target === "all" || t.role === r.target ? i : -1)).filter((i) => i >= 0), })) const mask = new Uint32Array(n) rules.forEach((r, ri) => { for (const i of r.idx) mask[i]! |= 1 << ri }) const wrote: string[] = new Array(n).fill("") for (const t of o.targets) { t.el.style.transformBox = "fill-box" t.el.style.transformOrigin = "center" } let box: DOMRect | null = null let px = 0.5 let py = short / 2 let act = 0 let w = 0 let wv = 0 let t0 = 0 let raf = 0 let last = 0 let seen = true let gone = 0 const touchQ = matchMedia("(hover: none), (pointer: coarse)") const wantsAmb = () => { const forced = root.closest("[data-motion]")?.getAttribute("data-motion") return (spec.a11y?.touch ?? "ambient") === "ambient" && (forced ? forced === "ambient" : touchQ.matches) } const wAmb = (2 * Math.PI) / AMBIENT_CYCLE const ph = Math.random() * 2 * Math.PI let amb = false let clock = 0 let hold = 0 const fit = () => { const r = svg.getBoundingClientRect() box = r.width < (o.min?.[0] ?? 0) || r.height < (o.min?.[1] ?? 0) ? null : r } const toUnit = (x: number, y: number) => { if (!box) return px = (x - box.left) / box.width py = ((y - box.top) / box.height) * o.aspect } const tick = (now: number) => { const dt = Math.min(last ? (now - last) / 1000 : 1 / 60, 1 / 30) last = now const snap = reduced() if (amb && now > hold) { if (snap || calm()) act = 0 else { clock += dt * AMBIENT_SPEED const t = clock px = 0.5 + 0.5 * (0.76 * Math.sin(wAmb * t + ph) + 0.2 * Math.sin(1.93 * wAmb * t + 1.2 + ph)) py = (o.aspect / 2) * (1 + 0.72 * Math.sin(0.75 * wAmb * t + 0.8 + ph) + 0.2 * Math.sin(1.37 * wAmb * t + 2.1 + ph)) if (!act) t0 = now gone = 0 act = 1 } } const st = spec.leave.spring ?? { stiffness: 260, damping: 28 } let busy = false if (snap) w = act else if (!act) [w, wv] = spring(w, wv, 0, LEAVE_K, 2 * Math.sqrt(LEAVE_K), dt) else [w, wv] = spring(w, wv, act, LEAVE_K, 2 * Math.sqrt(LEAVE_K), dt) const z = (v: number) => (Math.abs(v) < 1e-5 ? 0 : v) const rad = rad0 * short for (let i = 0; i < n; i++) { const t = o.targets[i]! let dx = px - t.cx let dy = py - t.cy const dist = Math.sqrt(dx * dx + dy * dy) / rad const outside = dist >= 1 if (outside) { if (val[i] === 0 && vel[i] === 0) continue } const on = act && now - t0 >= 0 ? w : 0 const f0 = dist >= 1 ? 0 : curve(1 - dist) for (let c = 0; c < C; c++) { const k = i * C + c const goal = act ? f0 * on : 0 if (snap) val[k] = goal else if (!act) { const kk = Math.max(st.stiffness, LEAVE_K) ;[val[k], vel[k]] = spring(val[k]!, vel[k]!, goal, kk, 2 * Math.sqrt(kk), dt) as [number, number] } else [val[k], vel[k]] = spring(val[k]!, vel[k]!, goal, st.stiffness, st.damping, dt) as [number, number] if (goal * amp < 0.005 && Math.abs(val[k]!) * amp < 0.005) { val[k] = 0 vel[k] = 0 continue } if ((Math.abs(val[k]! - goal) + Math.abs(vel[k]!) * dt) * amp > 1e-3) busy = true else if (Math.abs(val[k]!) * amp <= 1e-3 && !act) { val[k] = 0 vel[k] = 0 } } if (val[i] === 0 && wrote[i] === "") continue let sx = 1 let sy = 1 const m = mask[i]! for (let ri = 0; ri < rules.length; ri++) { if (!(m & (1 << ri))) continue const r = rules[ri]! const ch = 0 const amt = val[i * C + ch]! * r.max if (r.property === "scale") { sx += amt sy += amt } } const r3 = (v: number) => Math.round(v * 100) / 100 const ssx = r3(1 + z(sx - 1)) const ssy = r3(1 + z(sy - 1)) const next = (ssx !== 1 || ssy !== 1 ? `scale(${ssx},${ssy})` : "") || "translate(0px,0px)" if (next !== wrote[i]) { t.el.style.transform = next wrote[i] = next } } if (Math.abs(w - act) * amp > 1e-3 || Math.abs(wv) * dt * amp > 1e-3) busy = true else if (act) { w = act wv = 0 } if (!act && gone && now - gone > LEAVE_MS) busy = false raf = busy || (amb && !snap && !calm()) ? requestAnimationFrame(tick) : 0 if (!raf) { if (act) last = 0 else rest() } } function rest() { last = 0 w = 0 wv = 0 val.fill(0) vel.fill(0) for (let i = 0; i < n; i++) { const t = o.targets[i]! if (wrote[i] !== "") t.el.style.transform = "" wrote[i] = "" } } const go = () => { if (!raf && (seen || hold > performance.now()) && box) raf = requestAnimationFrame(tick) } const enterAt = (x: number, y: number) => { if (timer) { clearTimeout(timer) timer = 0 } fit() if (!box) return toUnit(x, y) if (!act) t0 = performance.now() gone = 0 act = 1 go() } let timer: ReturnType | 0 = 0 const letGo = () => { if (act) gone = performance.now() act = 0 go() } const leave = (e?: Event) => { if (e && isTouch(e as PointerEvent)) return if (e && !e.isTrusted) hold = 0 if (amb && performance.now() > hold) return return letGo() } const isTouch = (e: PointerEvent) => e.pointerType === "touch" && touchQ.matches const onMove = (e: PointerEvent) => { if (isTouch(e)) return if (!e.isTrusted) hold = performance.now() + 1000 lastX = e.clientX lastY = e.clientY if (!box) fit() toUnit(e.clientX, e.clientY) if (!act) enterAt(e.clientX, e.clientY) else go() } const onIn = (e: PointerEvent) => !isTouch(e) && enterAt(e.clientX, e.clientY) const onDown = (e: PointerEvent) => { if (e.pointerType !== "touch" || touchQ.matches) return enterAt(e.clientX, e.clientY) setTimeout(leave, 600) } const onFoc = () => { fit() if (!box) return px = 0.5 py = o.aspect / 2 t0 = performance.now() gone = 0 act = 1 go() setTimeout(leave, 900) } root.addEventListener("pointerenter", onIn, { passive: true }) root.addEventListener("pointermove", onMove, { passive: true }) root.addEventListener("pointerleave", leave) root.addEventListener("pointerdown", onDown) root.addEventListener("focusin", onFoc) root.addEventListener("focusout", leave) const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => { fit() if (amb) go() }) : null let lastX = 0 let lastY = 0 const onScr = () => { if (!act || amb) return fit() if (box) toUnit(lastX, lastY) go() } window.addEventListener("scroll", onScr, { passive: true, capture: true }) ro?.observe(svg) const io = typeof IntersectionObserver !== "undefined" ? new IntersectionObserver( ([e]) => { seen = !!e?.isIntersecting sync() if (!seen && raf && hold < performance.now()) { cancelAnimationFrame(raf) raf = 0 last = 0 rest() } else go() }, { threshold: 0.2 }, ) : null const sync = () => { const next = seen && !document.hidden && wantsAmb() if (next === amb) return amb = next if (amb) { fit() go() } else { if (act) gone = performance.now() act = 0 go() } } const onVis = () => sync() const reduceQ = matchMedia("(prefers-reduced-motion: reduce)") const onReduce = () => go() document.addEventListener("visibilitychange", onVis) touchQ.addEventListener?.("change", sync) reduceQ.addEventListener?.("change", onReduce) io?.observe(svg) fit() if (!io) sync() return () => { root.removeEventListener("pointerenter", onIn) root.removeEventListener("pointermove", onMove) root.removeEventListener("pointerleave", leave) root.removeEventListener("pointerdown", onDown) root.removeEventListener("focusin", onFoc) root.removeEventListener("focusout", leave) ro?.disconnect() io?.disconnect() window.removeEventListener("scroll", onScr, { capture: true }) document.removeEventListener("visibilitychange", onVis) touchQ.removeEventListener?.("change", sync) reduceQ.removeEventListener?.("change", onReduce) if (timer) clearTimeout(timer) if (raf) cancelAnimationFrame(raf) for (const t of o.targets) { t.el.style.transform = "" } } } const curves = { smoothstep: (t: number) => t * t * (3 - 2 * t), } as const type Curve = keyof typeof curves function spring(value: number, vel0: number, goal: number, stiff: number, damp: number, dt: number): [number, number] { const steps = Math.max(1, Math.ceil(dt * 240)) const h = dt / steps let x = value let v = vel0 for (let n = 0; n < steps; n++) { v += (-stiff * (x - goal) - damp * v) * h x += v * h } return [x, v] } const MOTION: Spec = { ir: 1, behavior: "displace", pointer: { falloff: { shape: "radial", radius: 0.42, curve: "smoothstep", }, }, react: [ { target: "motif", property: "scale", max: -0.8462, map: "falloff", }, ], stagger: { by: "none", ms: 0, }, leave: { settle: "spring", spring: { stiffness: 900, damping: 60, }, }, rest: "static", reducedMotion: "simplified", a11y: { keyboard: "center-pulse", touch: "ambient", }, } export type PinchProps = { tokens?: Partial> className?: string static?: boolean } export function Pinch({ tokens, className = "", static: isStatic = false }: PinchProps) { const ref = useRef(null) useEffect(() => selfSize(ref.current), []) const geometry = useGeometry() useEffect(() => { const root = ref.current if (!root || isStatic || !geometry) return const nodes = root.querySelectorAll("svg [data-role]") const targets: Target[] = geometry.elements.map((e, index) => ({ el: nodes[index]!, role: e.role, index, cx: e.cx, cy: e.cy, })) return mount(root, { spec: MOTION, targets, aspect: ASPECT, min: MIN_BOX }) }, [isStatic, geometry]) return ( ) } ```. Live preview: /embed/pinch. ### Agent brief ### 1. What it is Cells, inverted: the same 26 × 18 grid of cells from the Textures file (Figma 119:367), each cell filled by its square at rest. Near the pointer the squares shrink to the smallest unit in a soft dome that trails it, and fill their cells again when it leaves. Use it as a hero or background panel, or inside a card, wherever a dense field can answer the pointer. Not for small icons: below 160 × 113 it stays static. ### 2. Behaviour timeline - **Idle:** the resting field, every square filling its cell. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; squares near the pointer shrink toward the smallest unit. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. Falloff: radial, radius 0.42 of the short side, smoothstep. - **Hover-out:** every square springs back to fill its cell (stiffness 900, damping 60); interruptible at any point. - **Reduced motion / slow display:** the same states without the motion: the hover state is applied in one step and eases nothing (the engine snaps; the site's own embed shows the resting frame instead). - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: run the shadcn command from Copy to Agent (`npx shadcn@latest add https://textures.dev/r/pinch.json`) or write the file from this Texture's markdown twin (/s/pinch.md). One self-contained React file, components/textures/pinch.tsx. - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the panel, aspect 1 / 0.7049, covers the host and crops at the edges), `contain` available; works from 160 × 113 up; clips. - Tokens on the root: `--texture-ink` (the squares) and `--texture-field` (the panel); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the spring, the falloff or the reduced-motion branch; the motion spec lives in `motion.ts` and is read by the shared runtime. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Glow (background) · Magnet (hero) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/glow.json --- ## Pop A field of small dots. The pointer is air pressure under the sheet: dots pop out beneath it, largest under the cursor and smaller by their distance, and compress back quickly when it leaves. - Slug: `pop` · Category: circles · Pro (LicenseRef-Textures-Pro) · v0.1.0 - What moves: Dots under the pointer pop out, largest beneath the cursor, and compress back when it leaves. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: bulge, pressure, inflate, dots · Use cases: hero, background, card · Tags: dots, grid, pop, bulge, pressure, liquid - Rails: Measured 2026-10-27 for 0.1.0: 3.28 KB js, p95 11.84 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 3621 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the dots - `--texture-field` (color, default `var(--background)`): The panel behind them ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 112 px up; keeps 732/516 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 732 / 516; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Pop
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/pop.json` (one self-contained React file at components/textures/pop.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/pop` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "pop" })` (server: /mcp) - Markdown: /s/pop.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 Texture 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 Pop hover texture (textures.dev/s/pop)` (a suggestion the agent and the human can edit, never a write). Code: Pro ($19 a month or $180 a year), not unlocked for this request. Agents: if the prompt gave you a link with a token in the path (/s//.md), fetch that exact link; dropping the token is why you see this. Otherwise tell the user it is a Pro Texture and give them https://textures.dev/checkout?texture=pop; never recreate it. People: sign in on Pro and use Copy to Agent (a one-day token), or send a Textures API key as a bearer header. ### Agent brief ### 1. What it is A field of small dots, taken 1:1 from the Textures file (Figma 119:376): twenty-six columns and eighteen rows on the 732 × 516 panel. The pointer is air pressure under the sheet: the dots pop out under it, the one beneath the cursor to the width of its cell and the ones around it less by their distance, in the diamond the frame shows, and they compress back quickly when it leaves. Use it as a hero or background panel, or inside a card, wherever a quiet field can answer the pointer. Not for small icons: below 160 × 112 it stays static. ### 2. Behaviour timeline - **Idle:** the resting field. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; the dots under the pointer pop out at once. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. Each dot rides its own spring, stiff under the cursor (no delay) and softer at the edge of the bulge, so the bulge rolls after the pointer like a liquid. - **Hover-out:** every dot compresses home on a critically damped spring; interruptible at any point. - **Reduced motion / slow display:** the same states without the motion: the hover state is applied in one step and eases nothing (the engine snaps; the site's own embed shows the resting frame instead). - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: run the shadcn command from Copy to Agent (`npx shadcn@latest add https://textures.dev/r/pop.json`) or write the file from this Texture's markdown twin (/s/pop.md). One self-contained React file, components/textures/pop.tsx. - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the 732 × 516 panel covers the host and crops at the edges), `contain` available; works from 160 × 112 up; clips. - Tokens on the root: `--texture-ink` (the dots) and `--texture-field` (the panel); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the bulge, the springs or the reduced-motion branch. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Lift (background, pro) · Sphere (hero) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/lift.json --- ## Rise A field of stepped three-bar tiles, white on black. Every band rests low in its tile; near the pointer the bands rise, most under it and less around it, and ease back down as it moves away. - Slug: `rise` · Category: grids · Pro (LicenseRef-Textures-Pro) · v0.1.0 - What moves: The jogs of the three bars in each tile rise toward the pointer, most under it and less around it on a soft bell, and ease back down as it moves away. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: tile lift, stepped bars, madrone tile, jogs · Use cases: hero, background, card · Tags: tiles, bars, steps, lift, grid, bold - Rails: Measured 2026-10-27 for 0.1.0: 2.13 KB js, p95 11.54 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 3588 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the bars - `--texture-field` (color, default `var(--background)`): The panel behind them ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 112 px up; keeps 732/516 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 732 / 516; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Rise
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/rise.json` (one self-contained React file at components/textures/rise.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/rise` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "rise" })` (server: /mcp) - Markdown: /s/rise.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 Texture 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 Rise hover texture (textures.dev/s/rise)` (a suggestion the agent and the human can edit, never a write). Code: Pro ($19 a month or $180 a year), not unlocked for this request. Agents: if the prompt gave you a link with a token in the path (/s//.md), fetch that exact link; dropping the token is why you see this. Otherwise tell the user it is a Pro Texture and give them https://textures.dev/checkout?texture=rise; never recreate it. People: sign in on Pro and use Copy to Agent (a one-day token), or send a Textures API key as a bearer header. ### Agent brief ### 1. What it is A field of tiles multiplied over the 732 × 516 panel, sixteen by twelve, each the Madrone tile (Figma 108:8820): three bars 23.85 wide on a 40.06 pitch, running down, jogging 45° to the right by 97.83 and running on, the jogs stepped 16.6 per bar (pitch × (√2 − 1), so the gaps are the same width along the diagonal as between the runs) so they read as one band; white on black. The motion is Tile lift from the Madrone x Adurant experiments: every band rests low in its tile, and the row under the pointer rises as one layer, most under it and less and less out to both edges, the rows above and below following less, easing back down as it moves away. The bars thin a little as they rise (23.85 to 20 at full lift), so the gaps between them open; their spacing never changes. Use it as a hero or background panel, or inside a card, wherever a bold stepped field can answer the pointer. Not for small icons: below 160 × 112 it stays static. ### 2. Behaviour timeline - **Idle:** every band low. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; the row under the pointer starts to rise. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. Each tile's lift is a bell over its distance from the pointer, six tiles wide across the row and 1.3 up and down it (gain 1.6, capped at one), eased with a 110 ms time constant; a band travels 62 tile units from low to high and its bars thin from 23.85 to 20 as it rises. One path per tile, rewritten only while its lift moves. - **Hover-out:** every band eases back down; interruptible at any point. - **Reduced motion / slow display:** the same states without the ease: the bands sit at their lift directly. - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: run the shadcn command from Copy to Agent (`npx shadcn@latest add https://textures.dev/r/rise.json`) or write the file from this Texture's markdown twin (/s/rise.md). One self-contained React file, components/textures/rise.tsx. - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the 732 × 516 panel covers the host and crops at the edges), `contain` available; works from 160 × 112 up; clips. - Tokens on the root: `--texture-ink` (the bars) and `--texture-field` (the panel); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the tile, the lift, the bell, the ease or the reduced-motion branch. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Glow (background) · Magnet (hero) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/glow.json --- ## Shear A field of cells, each with a thin upright bar. Under the pointer every bar turns to point at it and the near ones thicken to fill their cells, so the field shears toward the cursor like a liquid. - Slug: `shear` · Category: grids · Pro (LicenseRef-Textures-Pro) · v0.1.0 - What moves: Every bar turns to point at the pointer; the near ones thicken to fill their cells and all relax when it leaves. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: compass, needles, bars, point · Use cases: hero, background, card · Tags: grid, bars, cells, turn, shear, liquid, follow - Rails: Measured 2026-10-27 for 0.1.1: 2.61 KB js, p95 14.72 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 3741 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the bars - `--texture-field` (color, default `var(--background)`): The panel behind them ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 112 px up; keeps 732/516 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 732 / 516; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Shear
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/shear.json` (one self-contained React file at components/textures/shear.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/shear` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "shear" })` (server: /mcp) - Markdown: /s/shear.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 Texture 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 Shear hover texture (textures.dev/s/shear)` (a suggestion the agent and the human can edit, never a write). Code: Pro ($19 a month or $180 a year), not unlocked for this request. Agents: if the prompt gave you a link with a token in the path (/s//.md), fetch that exact link; dropping the token is why you see this. Otherwise tell the user it is a Pro Texture and give them https://textures.dev/checkout?texture=shear; never recreate it. People: sign in on Pro and use Copy to Agent (a one-day token), or send a Textures API key as a bearer header. ### Agent brief ### 1. What it is A field of cells, each holding a thin upright bar, taken 1:1 from the Textures file (Figma 119:524): twenty-five columns and eighteen rows on the 732 × 516 panel. Under the pointer every bar turns to point at it, and the bars within reach thicken, to a full cell under the cursor, so the field shears toward the pointer like a liquid and relaxes to thin and upright when it leaves. Use it as a hero or background panel, or inside a card, wherever a dense field can answer the pointer. Not for small icons: below 160 × 112 it stays static. ### 2. Behaviour timeline - **Idle:** the resting field, every bar thin and upright. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; the bars turn toward the pointer and the near ones thicken. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. A bar under the cursor answers in the same frame (35 ms time constant); at 260 px it follows about 160 ms later, so the turn runs through the field like a liquid. Each bar is a band clipped by its own cell. - **Hover-out:** every bar eases back to thin and upright (220 ms time constant); interruptible at any point. - **Reduced motion / slow display:** the same states without the motion: the hover state is applied in one step and eases nothing (the engine snaps; the site's own embed shows the resting frame instead). - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: run the shadcn command from Copy to Agent (`npx shadcn@latest add https://textures.dev/r/shear.json`) or write the file from this Texture's markdown twin (/s/shear.md). One self-contained React file, components/textures/shear.tsx. - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the 732 × 516 panel covers the host and crops at the edges), `contain` available; works from 160 × 112 up; clips. - Tokens on the root: `--texture-ink` (the bars) and `--texture-field` (the panel); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the reach, the time constants or the reduced-motion branch. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Glow (background) · Magnet (hero) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/glow.json --- ## Shutter A field of white cells, each with a black blade hinged on one edge. The blades turn to face the pointer and widen as it nears, cut in perspective, closing the field to a dark focus around the cursor. - Slug: `shutter` · Category: grids · Pro (LicenseRef-Textures-Pro) · v0.1.0 - What moves: Every blade turns to face the pointer and widens as it nears, cut in perspective, closing the field into a dark focus around the cursor. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: louvres, blades, window, focus · Use cases: hero, background, card · Tags: grid, cells, shutter, blades, perspective, focus, liquid - Rails: Measured 2026-10-27 for 0.1.1: 2.95 KB js, p95 12.64 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 3972 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the cells - `--texture-field` (color, default `var(--background)`): The panel behind them, and the blades ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 112 px up; keeps 732/516 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 732 / 516; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Shutter
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/shutter.json` (one self-contained React file at components/textures/shutter.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/shutter` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "shutter" })` (server: /mcp) - Markdown: /s/shutter.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 Texture 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 Shutter hover texture (textures.dev/s/shutter)` (a suggestion the agent and the human can edit, never a write). Code: Pro ($19 a month or $180 a year), not unlocked for this request. Agents: if the prompt gave you a link with a token in the path (/s//.md), fetch that exact link; dropping the token is why you see this. Otherwise tell the user it is a Pro Texture and give them https://textures.dev/checkout?texture=shutter; never recreate it. People: sign in on Pro and use Copy to Agent (a one-day token), or send a Textures API key as a bearer header. ### Agent brief ### 1. What it is A field of white cells, twenty-six by eighteen, taken 1:1 from the Textures file (Figma 119:411): each cell holds a black blade hinged on one edge, like a window shutter seen in perspective. The blades turn to face the pointer: each sits on the cell edge nearest it, widens from a sliver to the whole cell as the pointer comes near, and its free edge is cut on the corner away from the pointer, deeper the further the cell is above or below it, so the field closes into a dark focus around the cursor with every blade aimed at it. Use it as a hero or background panel, or inside a card, wherever a dense field can answer the pointer. Not for small icons: below 160 × 112 it stays static. ### 2. Behaviour timeline - **Idle:** the resting field, every blade a sliver on its cell's right edge. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; the blades turn toward the pointer and the near ones widen. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. The blades are drawn from the pointer's position every frame (no lag); each eases a little, 30 ms under the cursor to 120 ms far out, so the shutters move like a liquid. Hinge side and cut slide continuously as the pointer crosses a cell, so nothing flips. - **Hover-out:** every blade eases back to its sliver (200 ms time constant); interruptible at any point. - **Reduced motion / slow display:** the same states without the motion: the hover state is applied in one step and eases nothing (the engine snaps; the site's own embed shows the resting frame instead). - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: run the shadcn command from Copy to Agent (`npx shadcn@latest add https://textures.dev/r/shutter.json`) or write the file from this Texture's markdown twin (/s/shutter.md). One self-contained React file, components/textures/shutter.tsx. - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the 732 × 516 panel covers the host and crops at the edges), `contain` available; works from 160 × 112 up; clips. - Tokens on the root: `--texture-ink` (the cells) and `--texture-field` (the panel and the blades); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the reach, the cut, the time constants or the reduced-motion branch. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Glow (background) · Magnet (hero) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/glow.json --- ## Sphere > For agents: this file is the source. Write the file under Code exactly as given, at the path it names, and mount the component as Placement says; do not recreate or approximate the animation. A grid of ink dots so large they nearly touch. An invisible ball under the pointer lifts the grid like cloth over a hill: the dots climb and swell toward its top and shrink to points on its dark side. - Slug: `sphere` · Category: circles · Free (MIT) · v0.1.0 - What moves: Inside a ball under the pointer the grid climbs a hill: dots slide outward, swell at the top and shrink to points on the dark side, away from the motion. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: static - Aliases: vega, op art sphere, dot sphere, halftone ball · Use cases: hero, background, card · Tags: dots, grid, sphere, halftone, op-art, press - Rails: Measured 2026-10-27 for 0.1.0: 3.11 KB js, p95 10.6 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 3739 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the dots - `--texture-field` (color, default `var(--background)`): The gaps, and the sphere ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 112 px up; keeps 732/516 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 732 / 516; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Sphere
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/sphere.json` (one self-contained React file at components/textures/sphere.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/sphere` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "sphere" })` (server: /mcp) - Markdown: /s/sphere.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 Texture 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 Sphere hover texture (textures.dev/s/sphere)` (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/sphere.tsx` ```tsx // Sphere — a Texture by Textures · https://textures.dev/s/sphere // Install another: npx shadcn@latest add https://textures.dev/r/.json // For agents: https://textures.md // 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 } const AMBIENT_SPEED = 1.5 const AMBIENT_CYCLE = 4.5 type EngineOptions = { ambientSpeed?: number } function engine( root: HTMLElement, draw: (p: Pointer, dt: number) => boolean | void, min: [number, number], options: EngineOptions = {}, ): () => void { const svg = root.querySelector("svg") if (!svg) return () => {} const mq = () => matchMedia("(prefers-reduced-motion: reduce)").matches || matchMedia("(update: slow)").matches const touchMq = matchMedia("(hover: none), (pointer: coarse)") const forced = () => root.closest("[data-motion]")?.getAttribute("data-motion") ?? null const wantsAmbient = () => (forced() ? forced() === "ambient" : touchMq.matches) const speed = AMBIENT_SPEED * (options.ambientSpeed ?? 1) 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; s: number; ox: number; oy: number } | null = null let px = 366 let py = 258 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 = svg.getBoundingClientRect() if (r.width < min[0] || r.height < min[1]) { box = null return } const s = Math.max(r.width / 732, r.height / 516) box = { left: r.left, top: r.top, s, ox: (r.width - 732 * s) / 2, oy: (r.height - 516 * s) / 2 } } 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 && now > hold) { if (reduced) target = 0 else { clock += dt * speed const t = clock px = 366 + 366 * (0.76 * Math.sin(w * t + phase) + 0.2 * Math.sin(1.93 * w * t + 1.2 + phase)) py = 258 + 258 * (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 }, dt) if (moving || more) frame = requestAnimationFrame(tick) else { frame = 0 last = 0 } } const wake = () => { if (!frame) frame = requestAnimationFrame(tick) } const touchIsAmbient = (e: Event) => (e as PointerEvent).pointerType === "touch" && touchMq.matches const onEnter = (e: PointerEvent) => { if (touchIsAmbient(e)) return measure() } const onMove = (e: PointerEvent) => { if (touchIsAmbient(e)) return const t = performance.now() if (!box || t - lastMoveAt > 250) measure() lastMoveAt = t if (!box) return if (!e.isTrusted) hold = performance.now() + 1000 cx = e.clientX cy = e.clientY px = (cx - box.left - box.ox) / box.s py = (cy - box.top - box.oy) / box.s target = 1 wake() } const onLeave = (e?: Event) => { if (e && touchIsAmbient(e)) return if (e && !e.isTrusted) hold = 0 if (ambient && performance.now() > hold) return target = 0 wake() } const onFocus = () => { measure() if (!box) return px = 366 py = 258 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 - box.ox) / box.s py = (cy - box.top - box.oy) / box.s wake() } const settle = () => { const next = onScreen && !document.hidden && wantsAmbient() if (next === ambient) return ambient = next if (ambient) { measure() if (!box) { ambient = false return } wake() } else { target = 0 wake() } } const io = typeof IntersectionObserver === "function" ? new IntersectionObserver( ([entry]) => { onScreen = !!entry?.isIntersecting settle() }, { threshold: 0.2 }, ) : null io?.observe(svg) if (!io) { onScreen = true settle() } const onVisibility = () => settle() const onCapability = () => settle() const reducedMq = matchMedia("(prefers-reduced-motion: reduce)") const onReduced = () => wake() document.addEventListener("visibilitychange", onVisibility) touchMq.addEventListener?.("change", onCapability) 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("pointercancel", onLeave) 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("pointercancel", onLeave) host.removeEventListener("focusin", onFocus) host.removeEventListener("focusout", onLeave) window.removeEventListener("resize", onResize) window.removeEventListener("scroll", onScroll, { capture: true }) document.removeEventListener("visibilitychange", onVisibility) touchMq.removeEventListener?.("change", onCapability) reducedMq.removeEventListener?.("change", onReduced) io?.disconnect() if (frame) cancelAnimationFrame(frame) } } const W = 732 const H = 516 const COLS = 34 const ROWS = 24 const PITCH = W / (COLS - 1) const Y0 = (H - (ROWS - 1) * PITCH) / 2 const RADIUS = 10.6 const BALL = 135 const SHADE_REACH = 0.45 const HILL = 0.47 const MAX_SCALE = 1.3 const SHADE_MIN = 0.08 const LIGHT_Z = 0.55 const LIGHT_REST_X = -0.5 const LIGHT_REST_Y = -0.4 const LAG = 0.9 const LAG_SPEED = 900 const LAG_OMEGA = 28 const OMEGA = 30 const ZETA = 1 export type SphereProps = { tokens?: Partial> className?: string static?: boolean } const N = COLS * ROWS const smooth = (t: number) => (t <= 0 ? 0 : t >= 1 ? 1 : t * t * (3 - 2 * t)) export function Sphere({ tokens, className = "", static: isStatic = false }: SphereProps) { const ref = useRef(null) useEffect(() => selfSize(ref.current), []) const dots = useRef<(SVGCircleElement | null)[]>([]) useEffect(() => { const root = ref.current if (!root || isStatic) return const writtenR = new Float32Array(N).fill(1) const writtenT = new Float32Array(N).fill(1) let bx = W / 2 let by = H / 2 let vx = 0 let vy = 0 let press = 0 let vPress = 0 const spring = (v: number, from: number, to: number, dt: number, w = OMEGA, z = ZETA) => v + (w * w * (to - from) - 2 * z * w * v) * dt let lx = LIGHT_REST_X let ly = LIGHT_REST_Y let vlx = 0 let vly = 0 const writtenX = new Float32Array(N) const writtenY = new Float32Array(N) return engine( root, ({ x, y, active, reduced }, dt) => { if (reduced) return false vx = spring(vx, bx, x, dt) bx += vx * dt vy = spring(vy, by, y, dt) by += vy * dt vPress = spring(vPress, press, active, dt) press += vPress * dt const speed = Math.hypot(vx, vy) const swing = Math.min(1, speed / LAG_SPEED) const tx = LIGHT_REST_X + ((speed > 1 ? (vx / speed) * LAG : 0) - LIGHT_REST_X) * swing const ty = LIGHT_REST_Y + ((speed > 1 ? (vy / speed) * LAG : 0) - LIGHT_REST_Y) * swing vlx = spring(vlx, lx, tx, dt, LAG_OMEGA, 1) lx += vlx * dt vly = spring(vly, ly, ty, dt, LAG_OMEGA, 1) ly += vly * dt const settled = Math.abs(bx - x) < 0.05 && Math.abs(by - y) < 0.05 && Math.abs(vx) + Math.abs(vy) < 0.5 && Math.abs(press - active) < 0.001 && Math.abs(vPress) < 0.01 && Math.abs(lx - LIGHT_REST_X) + Math.abs(ly - LIGHT_REST_Y) < 0.002 && Math.abs(vlx) + Math.abs(vly) < 0.02 if (settled) { bx = x by = y press = active lx = LIGHT_REST_X ly = LIGHT_REST_Y vx = vy = vPress = vlx = vly = 0 } const ln = Math.hypot(lx, ly, LIGHT_Z) const Lx = lx / ln const Ly = ly / ln const Lz = LIGHT_Z / ln for (let i = 0; i < N; i++) { const cx = (i % COLS) * PITCH const cy = Y0 + Math.floor(i / COLS) * PITCH const dx = cx - bx const dy = cy - by let sr = 1 let st = 1 let mx = 0 let my = 0 if (press > 0.001) { const d = Math.hypot(dx, dy) if (d > 0.001 && d < BALL * (1 + SHADE_REACH)) { const rho = Math.min(1, d / BALL) const ux = dx / d const uy = dy / d const q = 1 - rho * rho const lift = HILL * q * q const radial = Math.max(0.3, 1 + lift - 4 * HILL * rho * rho * q) const tangential = 1 + lift const nz = Math.sqrt(q) const lit = rho * (ux * Lx + uy * Ly) + nz * Lz const beyond = d > BALL ? smooth((d - BALL) / (BALL * SHADE_REACH)) : 0 const shade = 1 - (1 - (SHADE_MIN + (1 - SHADE_MIN) * smooth((lit + 0.25) / 1.05))) * (1 - beyond) const w = press mx = lift * d * ux * w my = lift * d * uy * w sr = 1 + (Math.min(MAX_SCALE, radial) * shade - 1) * w st = 1 + (Math.min(MAX_SCALE, tangential) * shade - 1) * w } } const atRest = press <= 0.001 if ( settled || (atRest && (writtenR[i] !== 1 || writtenT[i] !== 1 || writtenX[i] !== 0 || writtenY[i] !== 0)) || Math.abs(sr - writtenR[i]!) > 0.004 || Math.abs(st - writtenT[i]!) > 0.004 || Math.abs(mx - writtenX[i]!) > 0.15 || Math.abs(my - writtenY[i]!) > 0.15 ) { writtenR[i] = sr writtenT[i] = st writtenX[i] = mx writtenY[i] = my const el = dots.current[i] if (el) { if (sr === 1 && st === 1 && mx === 0 && my === 0) el.style.transform = "" else { const angle = (Math.atan2(dy, dx) * 180) / Math.PI el.style.transform = `translate(${mx.toFixed(1)}px, ${my.toFixed(1)}px) rotate(${angle.toFixed(1)}deg) scale(${sr.toFixed(3)}, ${st.toFixed(3)})` } } } } return !settled }, [160, 112], ) }, [isStatic]) const style = { "--texture-ink": "var(--foreground)", "--texture-field": "var(--background)", ...tokens, color: "var(--texture-ink)", background: "var(--texture-field)", } as CSSProperties return (
) } ```. Live preview: /embed/sphere. ### Agent brief ### 1. What it is A regular grid of ink dots on the 732 × 516 panel, 34 by 24 on a 22.18 px pitch with a radius of 10.6, so large they nearly touch: an almost solid field with small diamond gaps. An invisible ball with a 135 px footprint sits under the pointer and lifts the grid like cloth over a hill: inside the footprint every dot climbs outward, magnified at the top and gently packed on the slope, joining the untouched field at the rim without a seam, and is shaded (the shade reaching a little past the rim and fading out, so the dark side dissolves into the field) by the sphere's surface against a light from the upper left that swings toward the way the ball moves, so the far side shrinks to the smallest dots and the near side stays bright. Use it as a hero or background panel, or inside a card, wherever a dense field can hold a sphere under the pointer. Not for small icons: below 160 × 112 it stays static. ### 2. Behaviour timeline - **Idle:** the full grid, every dot whole. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; the ball presses in at the pointer. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. The ball's centre follows the pointer on a critically damped spring (30 rad/s), its press on the same, and the light's swing on one of 28 rad/s; each dot gets one transform, a slide along its radius, a rotation to the radial direction and a scale (radial, tangential), written only when it changes. - **Hover-out:** the press eases out and the dots return to full size; interruptible at any point. - **Reduced motion / slow display:** the resting frame; nothing moves. - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: run the shadcn command from Copy to Agent (`npx shadcn@latest add https://textures.dev/r/sphere.json`) or write the file from this Texture's markdown twin (/s/sphere.md). One self-contained React file, components/textures/sphere.tsx. - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the 732 × 516 panel covers the host and crops at the edges), `contain` available; works from 160 × 112 up; clips. - Tokens on the root: `--texture-ink` (the dots) and `--texture-field` (the gaps and the sphere); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the grid, the ball, its map, the shade, the springs or the reduced-motion branch. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Lift (background, pro) · Pop (hero, pro) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/lift.json --- ## Twist > For agents: this file is the source. Write the file under Code exactly as given, at the path it names, and mount the component as Placement says; do not recreate or approximate the animation. A field of cells, each with a thin upright bar. Every bar turns to point at the pointer; fat when the cursor lies along its row or column, thin on the diagonals: a cross with a twist in each quadrant. - Slug: `twist` · Category: grids · Free (MIT) · v0.1.0 - What moves: Every bar turns to point at the pointer and is as thick as its line to it is straight: a cross through the cursor with a twist in each quadrant. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: cross, compass, needles, shear · Use cases: hero, background, card · Tags: grid, bars, cells, turn, twist, cross, follow - Rails: Measured 2026-10-27 for 0.1.1: 2.74 KB js, p95 13.7 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 3741 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the bars - `--texture-field` (color, default `var(--background)`): The panel behind them ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 112 px up; keeps 732/516 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 732 / 516; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Twist
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/twist.json` (one self-contained React file at components/textures/twist.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/twist` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "twist" })` (server: /mcp) - Markdown: /s/twist.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 Texture 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 Twist hover texture (textures.dev/s/twist)` (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/twist.tsx` ```tsx // Twist — a Texture by Textures · https://textures.dev/s/twist // Install another: npx shadcn@latest add https://textures.dev/r/.json // For agents: https://textures.md // 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 } const AMBIENT_SPEED = 1.5 const AMBIENT_CYCLE = 4.5 type EngineOptions = { ambientSpeed?: number } function engine( root: HTMLElement, draw: (p: Pointer, dt: number) => boolean | void, min: [number, number], options: EngineOptions = {}, ): () => void { const svg = root.querySelector("svg") if (!svg) return () => {} const mq = () => matchMedia("(prefers-reduced-motion: reduce)").matches || matchMedia("(update: slow)").matches const touchMq = matchMedia("(hover: none), (pointer: coarse)") const forced = () => root.closest("[data-motion]")?.getAttribute("data-motion") ?? null const wantsAmbient = () => (forced() ? forced() === "ambient" : touchMq.matches) const speed = AMBIENT_SPEED * (options.ambientSpeed ?? 1) 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; s: number; ox: number; oy: number } | null = null let px = 366 let py = 258 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 = svg.getBoundingClientRect() if (r.width < min[0] || r.height < min[1]) { box = null return } const s = Math.max(r.width / 732, r.height / 516) box = { left: r.left, top: r.top, s, ox: (r.width - 732 * s) / 2, oy: (r.height - 516 * s) / 2 } } 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 && now > hold) { if (reduced) target = 0 else { clock += dt * speed const t = clock px = 366 + 366 * (0.76 * Math.sin(w * t + phase) + 0.2 * Math.sin(1.93 * w * t + 1.2 + phase)) py = 258 + 258 * (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 }, dt) if (moving || more) frame = requestAnimationFrame(tick) else { frame = 0 last = 0 } } const wake = () => { if (!frame) frame = requestAnimationFrame(tick) } const touchIsAmbient = (e: Event) => (e as PointerEvent).pointerType === "touch" && touchMq.matches const onEnter = (e: PointerEvent) => { if (touchIsAmbient(e)) return measure() } const onMove = (e: PointerEvent) => { if (touchIsAmbient(e)) return const t = performance.now() if (!box || t - lastMoveAt > 250) measure() lastMoveAt = t if (!box) return if (!e.isTrusted) hold = performance.now() + 1000 cx = e.clientX cy = e.clientY px = (cx - box.left - box.ox) / box.s py = (cy - box.top - box.oy) / box.s target = 1 wake() } const onLeave = (e?: Event) => { if (e && touchIsAmbient(e)) return if (e && !e.isTrusted) hold = 0 if (ambient && performance.now() > hold) return target = 0 wake() } const onFocus = () => { measure() if (!box) return px = 366 py = 258 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 - box.ox) / box.s py = (cy - box.top - box.oy) / box.s wake() } const settle = () => { const next = onScreen && !document.hidden && wantsAmbient() if (next === ambient) return ambient = next if (ambient) { measure() if (!box) { ambient = false return } wake() } else { target = 0 wake() } } const io = typeof IntersectionObserver === "function" ? new IntersectionObserver( ([entry]) => { onScreen = !!entry?.isIntersecting settle() }, { threshold: 0.2 }, ) : null io?.observe(svg) if (!io) { onScreen = true settle() } const onVisibility = () => settle() const onCapability = () => settle() const reducedMq = matchMedia("(prefers-reduced-motion: reduce)") const onReduced = () => wake() document.addEventListener("visibilitychange", onVisibility) touchMq.addEventListener?.("change", onCapability) 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("pointercancel", onLeave) 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("pointercancel", onLeave) host.removeEventListener("focusin", onFocus) host.removeEventListener("focusout", onLeave) window.removeEventListener("resize", onResize) window.removeEventListener("scroll", onScroll, { capture: true }) document.removeEventListener("visibilitychange", onVisibility) touchMq.removeEventListener?.("change", onCapability) reducedMq.removeEventListener?.("change", onReduced) io?.disconnect() if (frame) cancelAnimationFrame(frame) } } const W = 732 const H = 516 const COLS = 25 const ROWS = 18 const CELL_W = 27 const CELL_H = 26 const X0 = 17 const Y0 = 17 const PITCH_X = (714 - 17 - CELL_W) / (COLS - 1) const PITCH_Y = (498 - 17 - CELL_H) / (ROWS - 1) const T_MIN = 3.75 const T_MAX = 26.5 const BAR_LEN = 44 const CELLS: [number, number, number][] = [] for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) CELLS.push([Math.round((X0 + c * PITCH_X) * 100) / 100, Math.round((Y0 + r * PITCH_Y) * 100) / 100, CELL_W]) export type TwistProps = { tokens?: Partial> className?: string static?: boolean } const ALIGN = 3 const REACH = 260 const TAU_NEAR = 0.035 const TAU_FAR = 0.06 const TAU_HOME = 0.16 const DEAD_A = 0.6 const DEAD_S = 0.04 const WRITE_A = 0.2 const WRITE_S = 0.01 const arc = (deg: number) => (((deg % 180) + 270) % 180) - 90 export function Twist({ tokens, className = "", static: isStatic = false }: TwistProps) { const ref = useRef(null) useEffect(() => selfSize(ref.current), []) const bars = useRef<(SVGRectElement | null)[]>([]) useEffect(() => { const root = ref.current if (!root || isStatic) return const n = CELLS.length const cx = new Float32Array(n) const cy = new Float32Array(n) for (let i = 0; i < n; i++) { const [x, y, w] = CELLS[i]! cx[i] = x + w / 2 cy[i] = y + CELL_H / 2 } const angle = new Float32Array(n) const thick = new Float32Array(n).fill(1) const wa = new Float32Array(n) const ws = new Float32Array(n).fill(1) const smax = T_MAX / T_MIN return engine( root, ({ x, y, active, reduced }, dt) => { let moving = false for (let i = 0; i < n; i++) { const dx = x - cx[i]! const dy = y - cy[i]! const d = Math.sqrt(dx * dx + dy * dy) const bearing = (Math.atan2(dy, dx) * 180) / Math.PI - 90 const ta = active > 0 ? arc(bearing) * active : 0 const f = d < 1 ? 1 : Math.abs(Math.cos(2 * Math.atan2(dy, dx))) ** ALIGN const ts = 1 + (smax - 1) * f * active const a = angle[i]! const s = thick[i]! if (a === ta && s === ts) continue let na: number let ns: number if (reduced) { na = ta ns = ts } else { const tau = active > 0 ? TAU_NEAR + (TAU_FAR - TAU_NEAR) * Math.min(1, d / REACH) : TAU_HOME const k = 1 - Math.exp(-dt / tau) na = a + arc(ta - a) * k ns = s + (ts - s) * k if (Math.abs(arc(ta - na)) < DEAD_A) na = ta if (Math.abs(ts - ns) < DEAD_S) ns = ts } angle[i] = na thick[i] = ns if (na !== ta || ns !== ts) moving = true if ( Math.abs(na - wa[i]!) > WRITE_A || Math.abs(ns - ws[i]!) > WRITE_S || (na === 0 && ns === 1 && (wa[i] !== 0 || ws[i] !== 1)) ) { wa[i] = na ws[i] = ns const el = bars.current[i] if (el) el.style.transform = na === 0 && ns === 1 ? "" : `rotate(${na.toFixed(2)}deg) scaleX(${ns.toFixed(3)})` } } return moving }, [160, 112], ) }, [isStatic]) const style = { "--texture-ink": "var(--foreground)", "--texture-field": "var(--background)", ...tokens, color: "var(--texture-ink)", background: "var(--texture-field)", } as CSSProperties return (
) } ```. Live preview: /embed/twist. ### Agent brief ### 1. What it is A field of cells, each holding a thin upright bar, taken 1:1 from the Textures file (Figma 119:524): twenty-five columns and eighteen rows on the 732 × 516 panel. Under the pointer every bar turns to point at it, and a bar is as thick as its line to the pointer is straight: a full cell when the cursor lies along its row or column, thin when it sits diagonal to it, so the field forms a cross through the cursor with a twist in every quadrant, as the frame shows, and relaxes to thin and upright when the pointer leaves. Use it as a hero or background panel, or inside a card, wherever a dense field can answer the pointer. Not for small icons: below 160 × 112 it stays static. ### 2. Behaviour timeline - **Idle:** the resting field, every bar thin and upright. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; the bars turn toward the pointer; the ones along its row and column go fat. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. A bar under the cursor answers in the same frame (35 ms time constant); at 260 px it follows about 160 ms later, so the turn runs through the field like a liquid. Each bar is a band clipped by its own cell. - **Hover-out:** every bar eases back to thin and upright (220 ms time constant); interruptible at any point. - **Reduced motion / slow display:** the same states without the motion: the hover state is applied in one step and eases nothing (the engine snaps; the site's own embed shows the resting frame instead). - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: run the shadcn command from Copy to Agent (`npx shadcn@latest add https://textures.dev/r/twist.json`) or write the file from this Texture's markdown twin (/s/twist.md). One self-contained React file, components/textures/twist.tsx. - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the 732 × 516 panel covers the host and crops at the edges), `contain` available; works from 160 × 112 up; clips. - Tokens on the root: `--texture-ink` (the bars) and `--texture-field` (the panel); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the alignment curve, the time constants or the reduced-motion branch. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Glow (background) · Magnet (hero) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/glow.json --- ## Watch > For agents: this file is the source. Write the file under Code exactly as given, at the path it names, and mount the component as Placement says; do not recreate or approximate the animation. A grid of dim round cells, each holding a small dot. Under the pointer the dots fill their cells, most at the cursor and less with distance, and every dot turns toward it: a field of eyes. - Slug: `watch` · Category: grids · Free (MIT) · v0.1.0 - What moves: The dots fill their cells under the pointer, most at the cursor and less with distance, each seated toward it in its cell; the field ripples out in step. - Triggers: hover, focus, pointer-move · Touch: ambient (plays by itself on screens without hover, 1.5× pace, rests off screen) · Reduced motion: simplified - Aliases: eyes, watching grid, cell fill, dot grid · Use cases: hero, background, card · Tags: grid, cells, dots, fill, follow, eyes - Rails: Measured 2026-10-27 for 0.1.1: 2.67 KB js, p95 7.71 ms on a 4× throttled CPU; not yet within every budget line, so no Light badge - Estimated tokens: 3900 ### Tokens - `--texture-ink` (color, default `var(--foreground)`): Ink of the squares, and the cells at a tenth - `--texture-field` (color, default `var(--background)`): The panel behind them ### Placement **Never set width or height on the Texture; size the host. The Texture fills whatever box it is given.** Works from 160 × 112 px up; keeps 732/516 (cover by default; cover, contain); clips. The host needs position: relative. #### contained A card, a tile, a list row, a hero panel: the host has a size and the Texture fills it. ```css .host { position: relative; aspect-ratio: 732 / 516; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; } ``` ```tsx
Watch
``` #### background A full-bleed layer behind content: the Texture covers the section and ignores the pointer unless you want it to. ```css .section { position: relative; } .section > .host { position: absolute; inset: 0; z-index: 0; pointer-events: none; } .section > .content { position: relative; z-index: 1; } ``` ```tsx
…
``` ### Install - Command: `npx shadcn@latest add https://textures.dev/r/watch.json` (one self-contained React file at components/textures/watch.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/watch` with the @textures registry and its header in components.json, see below) - MCP: `get_texture({ ref: "watch" })` (server: /mcp) - Markdown: /s/watch.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 Texture 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 Watch hover texture (textures.dev/s/watch)` (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/watch.tsx` ```tsx // Watch — a Texture by Textures · https://textures.dev/s/watch // Install another: npx shadcn@latest add https://textures.dev/r/.json // For agents: https://textures.md // License: free to use "use client" import { type CSSProperties, useEffect, useId, 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 } const AMBIENT_SPEED = 1.5 const AMBIENT_CYCLE = 4.5 type EngineOptions = { ambientSpeed?: number } function engine( root: HTMLElement, draw: (p: Pointer, dt: number) => boolean | void, min: [number, number], options: EngineOptions = {}, ): () => void { const svg = root.querySelector("svg") if (!svg) return () => {} const mq = () => matchMedia("(prefers-reduced-motion: reduce)").matches || matchMedia("(update: slow)").matches const touchMq = matchMedia("(hover: none), (pointer: coarse)") const forced = () => root.closest("[data-motion]")?.getAttribute("data-motion") ?? null const wantsAmbient = () => (forced() ? forced() === "ambient" : touchMq.matches) const speed = AMBIENT_SPEED * (options.ambientSpeed ?? 1) 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; s: number; ox: number; oy: number } | null = null let px = 366 let py = 258 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 = svg.getBoundingClientRect() if (r.width < min[0] || r.height < min[1]) { box = null return } const s = Math.max(r.width / 732, r.height / 516) box = { left: r.left, top: r.top, s, ox: (r.width - 732 * s) / 2, oy: (r.height - 516 * s) / 2 } } 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 && now > hold) { if (reduced) target = 0 else { clock += dt * speed const t = clock px = 366 + 366 * (0.76 * Math.sin(w * t + phase) + 0.2 * Math.sin(1.93 * w * t + 1.2 + phase)) py = 258 + 258 * (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 }, dt) if (moving || more) frame = requestAnimationFrame(tick) else { frame = 0 last = 0 } } const wake = () => { if (!frame) frame = requestAnimationFrame(tick) } const touchIsAmbient = (e: Event) => (e as PointerEvent).pointerType === "touch" && touchMq.matches const onEnter = (e: PointerEvent) => { if (touchIsAmbient(e)) return measure() } const onMove = (e: PointerEvent) => { if (touchIsAmbient(e)) return const t = performance.now() if (!box || t - lastMoveAt > 250) measure() lastMoveAt = t if (!box) return if (!e.isTrusted) hold = performance.now() + 1000 cx = e.clientX cy = e.clientY px = (cx - box.left - box.ox) / box.s py = (cy - box.top - box.oy) / box.s target = 1 wake() } const onLeave = (e?: Event) => { if (e && touchIsAmbient(e)) return if (e && !e.isTrusted) hold = 0 if (ambient && performance.now() > hold) return target = 0 wake() } const onFocus = () => { measure() if (!box) return px = 366 py = 258 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 - box.ox) / box.s py = (cy - box.top - box.oy) / box.s wake() } const settle = () => { const next = onScreen && !document.hidden && wantsAmbient() if (next === ambient) return ambient = next if (ambient) { measure() if (!box) { ambient = false return } wake() } else { target = 0 wake() } } const io = typeof IntersectionObserver === "function" ? new IntersectionObserver( ([entry]) => { onScreen = !!entry?.isIntersecting settle() }, { threshold: 0.2 }, ) : null io?.observe(svg) if (!io) { onScreen = true settle() } const onVisibility = () => settle() const onCapability = () => settle() const reducedMq = matchMedia("(prefers-reduced-motion: reduce)") const onReduced = () => wake() document.addEventListener("visibilitychange", onVisibility) touchMq.addEventListener?.("change", onCapability) 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("pointercancel", onLeave) 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("pointercancel", onLeave) host.removeEventListener("focusin", onFocus) host.removeEventListener("focusout", onLeave) window.removeEventListener("resize", onResize) window.removeEventListener("scroll", onScroll, { capture: true }) document.removeEventListener("visibilitychange", onVisibility) touchMq.removeEventListener?.("change", onCapability) reducedMq.removeEventListener?.("change", onReduced) io?.disconnect() if (frame) cancelAnimationFrame(frame) } } const W = 732 const H = 516 const COLS = 27 const ROWS = 19 const CELL = 23.58 const PITCH = 26.08 const X0 = 15 const Y0 = 11.49 const DIM = 0.1 const SIZES: number[] = [23.6, 22.8, 21.65, 19.77, 16.95, 11.3, 9.4, 7.5, 5.6, 3.77] const S_MIN = 3.77 const EYE = 0.85 const EYE_REACH = 14 const TAU_NEAR = 0.045 const TAU_FAR = 0.14 export type WatchProps = { tokens?: Partial> className?: string static?: boolean } const N = COLS * ROWS const REST = S_MIN / CELL const sizeAt = (d: number) => { const i = Math.min(SIZES.length - 1, d) const k = Math.floor(i) const f = i - k return k >= SIZES.length - 1 ? SIZES[SIZES.length - 1]! : SIZES[k]! * (1 - f) + SIZES[k + 1]! * f } export function Watch({ tokens, className = "", static: isStatic = false }: WatchProps) { const ref = useRef(null) useEffect(() => selfSize(ref.current), []) const squares = useRef<(SVGRectElement | null)[]>([]) const uid = useId().replace(/[^a-zA-Z0-9]/g, "") const patternId = `watch-cells-${uid}` useEffect(() => { const root = ref.current if (!root || isStatic) return const size = new Float32Array(N).fill(S_MIN) const ox = new Float32Array(N) const oy = new Float32Array(N) const written = new Float32Array(N * 3).fill(Infinity) return engine( root, ({ x, y, active, reduced }, dt) => { let moving = false const cx = (x - X0 - CELL / 2) / PITCH const cy = (y - Y0 - CELL / 2) / PITCH for (let i = 0; i < N; i++) { const c = i % COLS const r = Math.floor(i / COLS) const dx = cx - c const dy = cy - r const d = Math.hypot(dx, dy) const wantSize = S_MIN + (sizeAt(d) - S_MIN) * active const room = ((CELL - wantSize) / 2) * EYE * active * (d < EYE_REACH ? 1 : Math.max(0, 1 - (d - EYE_REACH) / 3)) const wantX = d > 0.001 ? (dx / d) * room : 0 const wantY = d > 0.001 ? (dy / d) * room : 0 let s = size[i]! let px = ox[i]! let py = oy[i]! if (reduced) { s = wantSize px = wantX py = wantY } else { const tau = TAU_NEAR + (TAU_FAR - TAU_NEAR) * Math.min(1, d / EYE_REACH) const k = 1 - Math.exp(-dt / tau) s += (wantSize - s) * k px += (wantX - px) * k py += (wantY - py) * k if (Math.abs(s - wantSize) < 0.02 && Math.abs(px - wantX) < 0.02 && Math.abs(py - wantY) < 0.02) { s = wantSize px = wantX py = wantY } else moving = true } size[i] = s ox[i] = px oy[i] = py const j = i * 3 const restNow = s === S_MIN && px === 0 && py === 0 if ( (restNow && (written[j] !== S_MIN || written[j + 1] !== 0 || written[j + 2] !== 0)) || Math.abs(s - written[j]!) > 0.03 || Math.abs(px - written[j + 1]!) > 0.1 || Math.abs(py - written[j + 2]!) > 0.1 ) { written[j] = s written[j + 1] = px written[j + 2] = py const el = squares.current[i] if (el) { const sc = s / CELL el.style.transform = s === S_MIN && px === 0 && py === 0 ? `scale(${REST.toFixed(4)})` : `translate(${px.toFixed(2)}px, ${py.toFixed(2)}px) scale(${sc.toFixed(4)})` } } } return moving }, [160, 112], ) }, [isStatic]) const style = { "--texture-ink": "var(--foreground)", "--texture-field": "var(--background)", ...tokens, color: "var(--texture-ink)", background: "var(--texture-field)", } as CSSProperties return (
) } ```. Live preview: /embed/watch. ### Agent brief ### 1. What it is A grid of dim round cells, twenty-seven by nineteen, on the lattice of the Textures file (Figma 119:550): 23.58 px cells on a 26.08 pitch, the ink at a tenth, each holding a dot of ink that rests at 3.77 px (the frame's squares, fully rounded). Under the pointer the dots fill their cells, whole at the cursor and less with distance exactly as the frame's cells show, and every dot sits inside its cell toward the cursor, so the whole field watches it like a sheet of eyes. The response is immediate under the cursor and ripples outward with a growing ease, so a move reads as a layer expanding in step with the cursor rather than an orb dragged across it. Use it as a hero or background panel, or inside a card, wherever a dense field can follow the pointer. Not for small icons: below 160 × 112 it stays static. ### 2. Behaviour timeline - **Idle:** every dot small and centred. Nothing runs: no loops, no listeners doing work. - **Hover-in:** the on/off weight springs to 1 in about 80 ms; the dots under the pointer fill and the field turns toward it. - **Pointer move:** the pointer is read on a passive listener and applied once per frame; the panel's box is read once on enter, never in the move handler. Each dot's size is the frame's size for its distance from the cursor's cell, and its seat is 0.85 of the room it has toward the cursor; both ease with a time constant from 45 ms under the cursor to 140 ms fourteen cells out. One transform per dot, a slide and a scale, written only when it changes. - **Hover-out:** every dot eases back to small and centred; interruptible at any point. - **Reduced motion / slow display:** the same states without the ease: the dots sit at their targets directly. - **Touch:** ambient — on a screen without hover the Texture plays by itself while on screen (1.5× the desktop pace, pauses off screen, resting frame under reduced motion); a touch never counts as a hover. ### 3. Integration - Install: run the shadcn command from Copy to Agent (`npx shadcn@latest add https://textures.dev/r/watch.json`) or write the file from this Texture's markdown twin (/s/watch.md). One self-contained React file, components/textures/watch.tsx. - The host provides `position: relative` and a size. **Never set width or height on the Texture; size the host.** - Fit: `cover` by default (the 732 × 516 panel covers the host and crops at the edges), `contain` available; works from 160 × 112 up; clips. - Tokens on the root: `--texture-ink` (the dots, and the cells at a tenth) and `--texture-field` (the panel); both default to the shadcn `--foreground` and `--background`. ### 4. Customisation - Change the two tokens. Do not change the grid, the sizes, the eye, the eases or the reduced-motion branch. ### 5. Accessibility - Decorative; `aria-hidden` on the panel; the host keeps its own accessible name. - Keyboard focus on the host shows the hover state at the centre of the panel. No text sits on the Texture. ### Related Pairs with: Glow (background) · Magnet (hero) — same family, same tokens. npx shadcn@latest add https://textures.dev/r/glow.json ## Using Textures from an agent Textures are curated hover animations, each delivered as one self-contained React file: `components/textures/.tsx`, no Tailwind, no dependency beyond react. Three ways in, in this order: 1. **Copy to Agent** on a Texture page (https://textures.dev/s/) hands the user a prompt: the shadcn command, the markdown twin as a fallback, the mount, never recreate. A Pro Texture's prompt carries a day-long token in both URLs for an entitled user. Do what the prompt says. 2. **Connected to the Textures MCP server** (https://textures.dev/mcp): call `search_textures` with the user's words, then `get_texture` with the slug you pick, and write the one file it returns exactly as given. 3. **Neither:** `npx shadcn@latest add https://textures.dev/r/.json` installs a free Texture with no setup; or fetch `https://textures.dev/s/.md` (or the page with `Accept: text/markdown`) and write the file under Code exactly. For Pro, send a Textures API key from https://textures.dev/settings/agents as `Authorization: Bearer ` on either URL, or map the registry with the header in `components.json`: `"registries": { "@textures": { "url": "https://textures.dev/r/{name}.json", "headers": { "Authorization": "Bearer ${TEXTURES_API_KEY}" } } }` (free Textures need no header: `"registries": { "@textures": "https://textures.dev/r/{name}.json" }`), then `npx shadcn@latest add @textures/`. **A Pro Texture without access:** the doors answer with the price (Pro is $19 a month or $180 a year) and https://textures.dev/checkout?texture=. Tell the user and stop. Never retry, guess or reconstruct the code. Every published Texture is listed at https://textures.dev/llms.txt and https://textures.dev/r/registry.json. Browse them at https://textures.dev/textures.