# 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
<div className="host">
  <Curtain />
  <span className="sr-only">Curtain</span>
</div>
```

### 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
<section className="section">
  <div className="host" aria-hidden="true"><Curtain /></div>
  <div className="content">…</div>
</section>
```

## 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/<slug>.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<Record<"--texture-ink" | "--texture-field", string>>
	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<HTMLDivElement>(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 (
		<div
			ref={ref}
			style={{
				position: "relative",
				isolation: "isolate",
				height: "100%",
				width: "100%",
				overflow: "clip",
				contain: "layout paint style",
				containerType: "size",
				...style,
			}}
			data-texture="curtain"
			className={className}
		>
			<svg
				viewBox={`0 0 ${W} ${H}`}
				preserveAspectRatio="xMidYMid slice"
				aria-hidden="true"
				focusable="false"
				fill="currentColor"
				style={{ position: "absolute", inset: 0, display: "block", height: "100%", width: "100%" }}
			>
				{Array.from({ length: N }, (_, i) => (
					<path
						key={i}
						ref={(el) => {
							paths.current[i] = el
						}}
						d={rest(i)}
					/>
				))}
			</svg>
		</div>
	)
}
```. 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

## Using Textures from an agent

Textures are curated hover animations, 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 Texture page (https://textures.dev/s/<slug>) 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/<slug>.json` installs a free Texture with no setup; or fetch `https://textures.dev/s/<slug>.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 <key>` 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/<slug>`.

**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=<slug>. 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.
