# 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
<div className="host">
  <Sphere />
  <span className="sr-only">Sphere</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"><Sphere /></div>
  <div className="content">…</div>
</section>
```

## 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/<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 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<Record<"--texture-ink" | "--texture-field", string>>
	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<HTMLDivElement>(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 (
		<div
			ref={ref}
			style={{
				position: "relative",
				isolation: "isolate",
				height: "100%",
				width: "100%",
				overflow: "clip",
				contain: "layout paint style",
				containerType: "size",
				...style,
			}}
			data-texture="sphere"
			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) => (
					<circle
						key={i}
						ref={(el) => {
							dots.current[i] = el
						}}
						cx={((i % COLS) * PITCH).toFixed(2)}
						cy={(Y0 + Math.floor(i / COLS) * PITCH).toFixed(2)}
						r={RADIUS}
						style={{ transformBox: "fill-box", transformOrigin: "center" }}
					/>
				))}
			</svg>
		</div>
	)
}
```. 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

## 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.
