# Spotlight

> 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 card in your project's font: title and body at the top left on a hairline frame. A soft light follows the pointer across the surface and fades when it leaves.

- Slug: `spotlight` · Category: cards · Free (MIT) · v1.0.0
- What moves: A soft radial light trails the pointer across the card, its strength following the hover; the copy and frame stay still.
- Triggers: hover, focus, pointer-move · Touch: press · Reduced motion: simplified
- Aliases: spotlight card, glow card, cursor light card · Use cases: card, pricing card · Tags: card, spotlight, glow, pointer, feature, surface
- Rails: Light · 2.19 KB js · p95 1.48 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 copy, the hairline frame and the light's tint
- `--texture-field` (color, default `var(--background)`): The card's surface

## Placement

**Never set width or height on the Component; size the host. The Component fills whatever box it is given.** Works from 200 × 140 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">
  <Spotlight />
</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">
  <Spotlight />
</article>
```

## Install

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

## Code

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

### React

### `components/textures/spotlight.tsx`

```tsx
// Spotlight — a Component by Site Supply · https://sitesupply.dev/s/spotlight
// Install another: npx shadcn@latest add https://sitesupply.dev/r/<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 SpotlightProps = {
	title?: string
	body?: string
	tokens?: Partial<Record<"--texture-ink" | "--texture-field", string>>
	className?: string
	static?: boolean
}
const EASE = 0.25
const DISC = 1.2
const HIDDEN: CSSProperties = { position: "absolute", clipPath: "inset(50%)", whiteSpace: "nowrap" }
const RTL = /[֐-ࣿיִ-﷿ﹰ-﻿]/
export function Spotlight({
	title = "Ship faster",
	body = "Everything your team needs to launch, in one place.",
	tokens,
	className = "",
	static: isStatic = false,
}: SpotlightProps) {
	const ref = useRef<HTMLDivElement>(null)
	useEffect(() => selfSize(ref.current), [])
	const disc = useRef<HTMLDivElement>(null)
	const dir = RTL.test(title + body) ? "rtl" : "ltr"
	useEffect(() => {
		const root = ref.current
		const el = disc.current
		if (!root || !el || isStatic) return
		let lit = false
		let sx = 0
		let sy = 0
		return engine(
			root,
			({ x, y, active, reduced, w, h }, dt) => {
				if (active === 0) {
					if (lit) {
						lit = false
						el.style.opacity = ""
						el.style.transform = ""
					}
					return false
				}
				if (!lit) {
					lit = true
					sx = x
					sy = y
				}
				const k = reduced ? 1 : 1 - (1 - EASE) ** (dt * 60)
				sx += (x - sx) * k
				sy += (y - sy) * k
				if (Math.abs(x - sx) < 0.1) sx = x
				if (Math.abs(y - sy) < 0.1) sy = y
				const r = (DISC * Math.min(w, h)) / 2
				el.style.transform = `translate(${(sx + r).toFixed(1)}px, ${(sy + r).toFixed(1)}px)`
				el.style.opacity = active.toFixed(3)
				return sx !== x || sy !== y
			},
			[200, 140],
		)
	}, [isStatic])
	const style = {
		"--texture-ink": "var(--foreground)",
		"--texture-field": "var(--background)",
		...tokens,
		color: "var(--texture-ink)",
		background: "var(--texture-field)",
		borderRadius: "min(16px, 6cqmin)",
	} as CSSProperties
	const titleSize = "min(9cqb, 7cqi)"
	return (
		<div
			ref={ref}
			style={{
				position: "relative",
				isolation: "isolate",
				height: "100%",
				width: "100%",
				overflow: "clip",
				contain: "layout paint style",
				containerType: "size",
				...style,
			}}
			data-texture="spotlight"
			className={className}
		>
			<span style={HIDDEN}>
				{title}. {body}
			</span>
			<div
				ref={disc}
				data-part
				aria-hidden="true"
				style={{
					position: "absolute",
					left: `-${DISC * 100}cqmin`,
					top: `-${DISC * 100}cqmin`,
					width: `${DISC * 100}cqmin`,
					height: `${DISC * 100}cqmin`,
					borderRadius: "50%",
					background:
						"radial-gradient(closest-side, color-mix(in oklab, var(--texture-ink) 10%, transparent), transparent)",
					pointerEvents: "none",
				}}
			/>
			<div
				aria-hidden="true"
				dir={dir}
				style={{
					position: "absolute",
					inset: 0,
					padding: "7cqmin",
					display: "flex",
					flexDirection: "column",
					alignItems: "flex-start",
					overflow: "hidden",
					pointerEvents: "none",
				}}
			>
				<span
					style={{
						maxWidth: "100%",
						fontSize: titleSize,
						fontWeight: 600,
						lineHeight: 1.2,
						letterSpacing: "-0.01em",
						whiteSpace: "nowrap",
						overflow: "hidden",
						textOverflow: "ellipsis",
					}}
				>
					{title}
				</span>
				<span
					style={{
						maxWidth: "min(100%, 34em)",
						marginTop: "0.6em",
						fontSize: `calc(${titleSize} * 0.56)`,
						fontWeight: 400,
						lineHeight: 1.45,
						color: "color-mix(in oklab, var(--texture-ink) 65%, transparent)",
						display: "-webkit-box",
						WebkitBoxOrient: "vertical",
						WebkitLineClamp: 3,
						overflow: "hidden",
					}}
				>
					{body}
				</span>
			</div>
			<div
				aria-hidden="true"
				style={{
					position: "absolute",
					inset: 0,
					borderRadius: "inherit",
					border: "1px solid color-mix(in oklab, var(--texture-ink) 12%, transparent)",
					pointerEvents: "none",
				}}
			/>
		</div>
	)
}
```. Live preview: /embed/spotlight.

## Agent brief

## 1. What it is

A card surface with a title and a line of body copy at the top left, set in the project's own font, framed by a hairline. A soft light follows the pointer across the card and fades away when the pointer leaves. Use it for feature grids, pricing tiers and link cards on a calm page. Below 200 × 140 it stays static.

## 2. Behaviour timeline

- **Idle:** the resting card; the light sits outside the surface. Nothing runs: no loops, no listeners doing work.
- **Hover-in:** the light appears under the pointer and its strength rises on the engine's spring (about 240 ms to full).
- **Pointer move:** the light trails the pointer on an ease (a quarter of the gap closes each 60 Hz frame); the pointer is read on a passive listener once per frame, the box once on enter.
- **Hover-out:** the light fades out where it is on the same spring and is back at exact rest within 300 ms; interruptible at any point.
- **Reduced motion / slow display:** the light snaps to the pointer and on or off in one step; nothing eases.
- **Touch:** press — a finger down lights the card where it lands, lifting it releases; nothing plays by itself.

## 3. Integration

- Install: `npx shadcn@latest add https://sitesupply.dev/r/spotlight.json` (one self-contained React file at components/textures/spotlight.tsx), or write the file `get_texture` returns exactly as returned, or use the code in this Component's markdown twin (/s/spotlight.md).
- Mount it inside the project's own `<article>` or `<a>` (the host keeps 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.** Round the host's corners to match (the surface rounds to min(16px, 6cqmin)).
- Props: `title` (one line, ellipsised) and `body` (up to three lines, ellipsised).
- Fit: `fill` (the surface fills the host, the copy is sized from it and laid out top left); works from 200 × 140 up; clips.
- Tokens on the root: `--texture-ink` (the copy, the frame and the light's tint) and `--texture-field` (the surface); they default to the shadcn `--foreground` and `--background`.

## 4. Customisation

- Change the title, the body and the two tokens. Do not change the timing, the light's size or the reduced-motion branch.

## 5. Accessibility

- The title and body are real text in a visually hidden span, so the host link or article takes them as its content; the drawn copy and the light are `aria-hidden`.
- Keyboard focus on the host lights the card from the centre.


## Related
Pairs with: Raise (card, pro) · Reveal (card, pro) — same family, same tokens.
npx shadcn@latest add https://site-supply.vercel.app/r/raise.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.
