# Roll

> 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 pill button in your project's font. On hover the label rolls up letter by letter, outward from where the pointer came in, and a twin label rolls in from below.

- Slug: `roll` · Category: buttons · Free (MIT) · v1.0.0
- What moves: Each letter of the label rolls up out of its line while a twin rolls in from below, staggered from the pointer.
- Triggers: hover, focus, pointer-move · Touch: press · Reduced motion: simplified
- Aliases: rolling button, text roll, slot button · Use cases: CTA · Tags: button, label, roll, letters, cta, pill
- Rails: Light · 2.01 KB js · p95 1.53 ms on a 4× throttled CPU · 0 layers (measured 2026-11-08 for 0.1.0)
- Estimated tokens: 3867

## Tokens

- `--texture-ink` (color, default `var(--foreground)`): The button's surface
- `--texture-field` (color, default `var(--background)`): The label

## Placement

**Never set width or height on the Component; size the host. The Component fills whatever box it is given.** Works from 96 × 32 px up; free aspect (fill by default; fill); clips. The host needs position: relative.

### inline

A button, a link, a badge: the project's own element is the host; give it a size and the Component fills it (its text comes in by props).

```css
.host { position: relative; display: inline-block; width: 11rem; height: 3rem; border-radius: 999px; }
```

```tsx
<button type="button" className="host">
  <Roll />
</button>
```

### contained

A card, a tile, a stat, a panel: the host has a size and the Component fills it.

```css
.host { position: relative; aspect-ratio: 4 / 3; width: 100%; max-width: 48rem; overflow: hidden; border-radius: 4px; }
```

```tsx
<article className="host">
  <Roll />
</article>
```

## Install

- Command: `npx shadcn@latest add https://site-supply.vercel.app/r/roll.json` (one self-contained React file at components/textures/roll.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/roll` with the @textures registry and its header in components.json, see below)
- MCP: `get_texture({ ref: "roll" })` (server: /mcp)
- Markdown: /s/roll.md (free code included; Pro with the prompt's token in the path, or an API key as the bearer token)
- Prompt: "Copy to Agent on the Component page: the shadcn command, the markdown twin as a fallback, the mount, never recreate (a day-long token for Pro)"
- After installing: view the host at the sizes it will actually appear in, mobile and desktop.
- Suggested commit: `feat: add Roll component (site-supply.vercel.app/s/roll)` (a suggestion the agent and the human can edit, never a write).

## Code

Write these files into the project, then mount the component on a host with position: relative and a size.

### React

### `components/textures/roll.tsx`

```tsx
// Roll — a Component by Site Supply · https://sitesupply.dev/s/roll
// Install another: npx shadcn@latest add https://sitesupply.dev/r/<slug>.json
// For agents: https://sitesupply.dev/llms.txt
// License: free to use
"use client"
import { type CSSProperties, useEffect, useRef } from "react"
function selfSize(root: HTMLElement | null): void {
	const host = root?.parentElement
	if (!root || !host || host.getBoundingClientRect().height > 0) return
	root.style.minHeight = "240px"
}
type Pointer = {
	x: number
	y: number
	active: number
	reduced: boolean
	w: number
	h: number
}
const AMBIENT_SPEED = 1.5
const AMBIENT_CYCLE = 4.5
function engine(
	root: HTMLElement,
	draw: (p: Pointer, dt: number) => boolean | void,
	min: [number, number],
): () => void {
	const mq = () => matchMedia("(prefers-reduced-motion: reduce)").matches || matchMedia("(update: slow)").matches
	const touchMq = matchMedia("(hover: none), (pointer: coarse)")
	const wantsAmbient = () => root.closest("[data-motion]")?.getAttribute("data-motion") === "ambient"
	const w = (2 * Math.PI) / AMBIENT_CYCLE
	const phase = Math.random() * 2 * Math.PI
	let onScreen = false
	let ambient = false
	let clock = 0
	let hold = 0
	let box: { left: number; top: number; w: number; h: number } | null = null
	let px = 0
	let py = 0
	let cx = 0
	let cy = 0
	let active = 0
	let target = 0
	let vel = 0
	let frame = 0
	let last = 0
	let lastMoveAt = 0
	const measure = () => {
		const r = root.getBoundingClientRect()
		box = r.width < min[0] || r.height < min[1] ? null : { left: r.left, top: r.top, w: r.width, h: r.height }
	}
	const tick = (now: number) => {
		const dt = Math.min(last ? (now - last) / 1000 : 1 / 60, 1 / 30)
		last = now
		let moving = false
		const reduced = mq()
		if (ambient && box && now > hold) {
			if (reduced) target = 0
			else {
				clock += dt * AMBIENT_SPEED
				const t = clock
				px = box.w / 2 + (box.w / 2) * (0.76 * Math.sin(w * t + phase) + 0.2 * Math.sin(1.93 * w * t + 1.2 + phase))
				py =
					box.h / 2 +
					(box.h / 2) * (0.72 * Math.sin(0.75 * w * t + 0.8 + phase) + 0.2 * Math.sin(1.37 * w * t + 2.1 + phase))
				target = 1
				moving = true
			}
		}
		if (reduced) {
			active = target
			vel = 0
		} else {
			const decay = Math.exp(-30 * dt)
			const off = active - target
			const lead = vel + 30 * off
			active = target + (off + lead * dt) * decay
			vel = (vel - 30 * lead * dt) * decay
			if (Math.abs(vel) < 0.02 && Math.abs(active - target) < 0.02) {
				active = target
				vel = 0
			} else moving = true
		}
		const more = draw({ x: px, y: py, active, reduced, w: box?.w ?? 0, h: box?.h ?? 0 }, dt)
		if (moving || more) frame = requestAnimationFrame(tick)
		else {
			frame = 0
			last = 0
		}
	}
	const wake = () => {
		if (!frame) frame = requestAnimationFrame(tick)
	}
	const isTouch = (e: Event) => (e as PointerEvent).pointerType === "touch" && touchMq.matches
	const at = (e: PointerEvent) => {
		if (!box) return false
		cx = e.clientX
		cy = e.clientY
		px = cx - box.left
		py = cy - box.top
		return true
	}
	const onEnter = (e: PointerEvent) => {
		if (isTouch(e)) return
		measure()
	}
	const onMove = (e: PointerEvent) => {
		if (isTouch(e)) return
		const t = performance.now()
		if (!box || t - lastMoveAt > 250) measure()
		lastMoveAt = t
		if (!at(e)) return
		if (!e.isTrusted) hold = t + 1000
		target = 1
		wake()
	}
	const onLeave = (e?: Event) => {
		if (e && isTouch(e)) return
		if (e && !e.isTrusted) hold = 0
		if (ambient && performance.now() > hold) return
		target = 0
		wake()
	}
	const onDown = (e: PointerEvent) => {
		if (!isTouch(e)) return
		measure()
		if (!at(e)) return
		target = 1
		wake()
	}
	const onUp = (e: PointerEvent) => {
		if (!isTouch(e) || ambient) return
		target = 0
		wake()
	}
	const onFocus = () => {
		measure()
		if (!box) return
		px = box.w / 2
		py = box.h / 2
		target = 1
		wake()
	}
	const onResize = () => {
		if (box || ambient) measure()
		if (ambient && !box) ambient = false
		settle()
	}
	const onScroll = () => {
		if (!box || target !== 1 || (ambient && performance.now() > hold)) return
		measure()
		if (!box) return
		px = cx - box.left
		py = cy - box.top
		wake()
	}
	const settle = () => {
		const next = onScreen && !document.hidden && wantsAmbient()
		if (next === ambient) return
		ambient = next
		if (ambient) measure()
		if (ambient && !box) ambient = false
		if (!ambient) target = 0
		wake()
	}
	const io =
		typeof IntersectionObserver === "function"
			? new IntersectionObserver(
					([entry]) => {
						onScreen = !!entry?.isIntersecting
						settle()
					},
					{ threshold: 0.2 },
				)
			: null
	io?.observe(root)
	const onVisibility = () => settle()
	const reducedMq = matchMedia("(prefers-reduced-motion: reduce)")
	const onReduced = () => wake()
	document.addEventListener("visibilitychange", onVisibility)
	reducedMq.addEventListener?.("change", onReduced)
	const host: Element = root.closest("a, button, [tabindex]") ?? root.parentElement ?? root
	root.addEventListener("pointerenter", onEnter)
	root.addEventListener("pointermove", onMove, { passive: true })
	root.addEventListener("pointerleave", onLeave)
	root.addEventListener("pointerdown", onDown, { passive: true })
	root.addEventListener("pointerup", onUp)
	root.addEventListener("pointercancel", onUp)
	host.addEventListener("focusin", onFocus)
	host.addEventListener("focusout", onLeave)
	window.addEventListener("resize", onResize, { passive: true })
	window.addEventListener("scroll", onScroll, { passive: true, capture: true })
	return () => {
		root.removeEventListener("pointerenter", onEnter)
		root.removeEventListener("pointermove", onMove)
		root.removeEventListener("pointerleave", onLeave)
		root.removeEventListener("pointerdown", onDown)
		root.removeEventListener("pointerup", onUp)
		root.removeEventListener("pointercancel", onUp)
		host.removeEventListener("focusin", onFocus)
		host.removeEventListener("focusout", onLeave)
		window.removeEventListener("resize", onResize)
		window.removeEventListener("scroll", onScroll, { capture: true })
		document.removeEventListener("visibilitychange", onVisibility)
		reducedMq.removeEventListener?.("change", onReduced)
		io?.disconnect()
		if (frame) cancelAnimationFrame(frame)
	}
}
export type RollProps = {
	label?: string
	tokens?: Partial<Record<"--texture-ink" | "--texture-field", string>>
	className?: string
	static?: boolean
}
const DURATION = 0.32
const STAGGER = 0.022
const RELEASE = 0.24
const outQuart = (p: number) => 1 - (1 - p) ** 4
const HIDDEN: CSSProperties = { position: "absolute", clipPath: "inset(50%)", whiteSpace: "nowrap" }
export function Roll({ label = "Get started", tokens, className = "", static: isStatic = false }: RollProps) {
	const ref = useRef<HTMLDivElement>(null)
	useEffect(() => selfSize(ref.current), [])
	const letters = useRef<(HTMLSpanElement | null)[]>([])
	const chars = Array.from(label)
	const dir = /[֐-ࣿיִ-﷿ﹰ-﻿]/.test(label) ? "rtl" : "ltr"
	useEffect(() => {
		const root = ref.current
		if (!root || isStatic) return
		const n = chars.length
		const q = new Array<number>(n).fill(0)
		const delay = new Array<number>(n).fill(0)
		let on = false
		let t = 0
		const write = (i: number) => {
			const el = letters.current[i]
			if (el) el.style.transform = q[i] === 0 ? "" : `translateY(${(-100 * outQuart(q[i]!)).toFixed(2)}%)`
		}
		return engine(
			root,
			({ x, active, reduced, w }, dt) => {
				if (!on && active > 0.5) {
					on = true
					t = 0
					const near = w > 0 ? Math.round((x / w) * (n - 1)) : 0
					for (let i = 0; i < n; i++) delay[i] = Math.abs(i - (dir === "rtl" ? n - 1 - near : near)) * STAGGER
				} else if (on && active < 0.5) on = false
				let moving = false
				t += dt
				for (let i = 0; i < n; i++) {
					const before = q[i]!
					if (reduced) q[i] = on ? 1 : 0
					else if (on) q[i] = Math.min(1, Math.max(before, (t - delay[i]!) / DURATION))
					else q[i] = Math.max(0, before - dt / RELEASE)
					if (q[i] !== before) write(i)
					if (q[i] !== (on ? 1 : 0)) moving = true
				}
				return moving
			},
			[96, 32],
		)
	}, [isStatic, label])
	const style = {
		"--texture-ink": "var(--foreground)",
		"--texture-field": "var(--background)",
		...tokens,
		color: "var(--texture-field)",
		background: "var(--texture-ink)",
		borderRadius: "999px",
	} as CSSProperties
	return (
		<div
			ref={ref}
			style={{
				position: "relative",
				isolation: "isolate",
				height: "100%",
				width: "100%",
				overflow: "clip",
				contain: "layout paint style",
				containerType: "size",
				...style,
			}}
			data-texture="roll"
			className={className}
		>
			<span style={HIDDEN}>{label}</span>
			<span
				aria-hidden="true"
				dir={dir}
				style={{
					position: "absolute",
					inset: 0,
					display: "flex",
					alignItems: "center",
					justifyContent: "center",
					whiteSpace: "pre",
					lineHeight: 1.2,
					fontSize: "min(34cqb, 11cqi)",
					fontWeight: 500,
					pointerEvents: "none",
				}}
			>
				<span style={{ display: "inline-flex", overflow: "hidden" }}>
					{chars.map((c, i) => (
						<span
							key={i}
							data-part
							ref={(el) => {
								letters.current[i] = el
							}}
							style={{ position: "relative", display: "inline-block" }}
						>
							{c === " " ? " " : c}
							<span style={{ position: "absolute", left: 0, top: "100%" }}>{c === " " ? " " : c}</span>
						</span>
					))}
				</span>
			</span>
		</div>
	)
}
```. Live preview: /embed/roll.

## Agent brief

## 1. What it is

A pill button surface with its label set in the project's own font. On hover the label rolls up out of its line letter by letter, starting at the letter nearest the pointer and running outward, while an identical label rolls in from below. Use it for a primary call to action. Not for icon buttons: below 96 × 32 it stays static.

## 2. Behaviour timeline

- **Idle:** the resting label. Nothing runs: no loops, no listeners doing work.
- **Hover-in:** each letter rolls up over 320 ms on an ease-out, 22 ms after its neighbour, outward from the pointer's arrival.
- **Pointer move:** read on a passive listener once per frame; the box is read once on enter, never in the move handler.
- **Hover-out:** every letter rolls back together in 240 ms from wherever it is; interruptible at any point.
- **Reduced motion / slow display:** the hover state is applied in one step; nothing eases.
- **Touch:** press — a finger down plays the hover state, lifting it releases; nothing plays by itself.

## 3. Integration

- Install: `npx shadcn@latest add https://sitesupply.dev/r/roll.json` (one self-contained React file at components/textures/roll.tsx), or write the file `get_texture` returns exactly as returned, or use the code in this Component's markdown twin (/s/roll.md).
- Mount it inside the project's own `<button>` or `<a>` (the host keeps the click, the link and the focus ring). The host provides `position: relative` and a size. **Never set width or height on the Component; size the host.**
- Props: `label` (the text, one line).
- Fit: `fill` (the surface fills the host, the label is sized from it and centred); works from 96 × 32 up; clips.
- Tokens on the root: `--texture-ink` (the surface) and `--texture-field` (the label); both default to the shadcn `--foreground` and `--background`.

## 4. Customisation

- Change the label and the two tokens; round the host's corners to match (the surface is a pill). Do not change the timing or the reduced-motion branch.

## 5. Accessibility

- The label is real text in a visually hidden span, so the host button or link takes it as its accessible name; the rolling letters are `aria-hidden`.
- Keyboard focus on the host plays the hover state from the centre.


## Related
Pairs with: Flood (CTA) · Lean (CTA, pro) — same family, same tokens.
npx shadcn@latest add https://site-supply.vercel.app/r/flood.json

## Using Site Supply from an agent

Components are curated, animated site components (buttons, links, cards, badges), each delivered as one self-contained React file: `components/textures/<slug>.tsx`, no Tailwind, no dependency beyond react. Three ways in, in this order:

1. **Copy to Agent** on a Component page (https://site-supply.vercel.app/s/<slug>) hands the user a prompt: the shadcn command, the markdown twin as a fallback, the mount, never recreate. A Pro Component's prompt carries a day-long token in both URLs for an entitled user. Do what the prompt says.
2. **Connected to the Site Supply MCP server** (https://site-supply.vercel.app/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://site-supply.vercel.app/r/<slug>.json` installs a free Component with no setup; or fetch `https://site-supply.vercel.app/s/<slug>.md` (or the page with `Accept: text/markdown`) and write the file under Code exactly. For Pro, send a Site Supply API key from https://site-supply.vercel.app/settings/agents as `Authorization: Bearer <key>` on either URL, or map the registry with the header in `components.json`: `"registries": { "@textures": { "url": "https://site-supply.vercel.app/r/{name}.json", "headers": { "Authorization": "Bearer ${TEXTURES_API_KEY}" } } }` (free Components need no header: `"registries": { "@textures": "https://site-supply.vercel.app/r/{name}.json" }`), then `npx shadcn@latest add @textures/<slug>`.

**A Pro Component without access:** the doors answer with the price (Pro is from $9 a month) and https://site-supply.vercel.app/checkout?texture=<slug>. Tell the user and stop. Never retry, guess or reconstruct the code.

Every published Component is listed at https://site-supply.vercel.app/llms.txt and https://site-supply.vercel.app/r/registry.json. Browse them at https://site-supply.vercel.app/textures.
