# Flood

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

An outline pill button in your project's font. On hover the ink floods the surface from where the pointer came in, inverting the label, and drains toward where it left.

- Slug: `flood` · Category: buttons · Free (MIT) · v1.0.0
- What moves: A filled circle grows from the pointer's entry point to cover the surface, the label inverting inside it; leaving drains it to the exit point.
- Triggers: hover, focus, pointer-move · Touch: press · Reduced motion: simplified
- Aliases: fill button, ink flood, outline fill button · Use cases: CTA · Tags: button, outline, fill, flood, cta, pill
- Rails: Light · 2.09 KB js · p95 2.33 ms on a 4× throttled CPU · 0 layers (measured 2026-11-08 for 0.1.0)
- Estimated tokens: 3547

## Tokens

- `--texture-ink` (color, default `var(--foreground)`): The outline, the flood and the resting label
- `--texture-field` (color, default `var(--background)`): The surface at rest and the label inside the flood

## 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">
  <Flood />
</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">
  <Flood />
</article>
```

## Install

- Command: `npx shadcn@latest add https://site-supply.vercel.app/r/flood.json` (one self-contained React file at components/textures/flood.tsx; Pro through the Copy to Agent prompt's token URL, or `npx shadcn@latest add @textures/flood` with the @textures registry and its header in components.json, see below)
- MCP: `get_texture({ ref: "flood" })` (server: /mcp)
- Markdown: /s/flood.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 Flood component (site-supply.vercel.app/s/flood)` (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/flood.tsx`

```tsx
// Flood — a Component by Site Supply · https://sitesupply.dev/s/flood
// 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 FloodProps = {
	label?: string
	tokens?: Partial<Record<"--texture-ink" | "--texture-field", string>>
	className?: string
	static?: boolean
}
const FILL = 0.32
const DRAIN = 0.24
const outCubic = (p: number) => 1 - (1 - p) ** 3
const REST = "circle(0% at 50% 50%)"
const HIDDEN: CSSProperties = { position: "absolute", clipPath: "inset(50%)", whiteSpace: "nowrap" }
const LAYER: CSSProperties = { position: "absolute", inset: 0, borderRadius: "inherit", pointerEvents: "none" }
const ROW: CSSProperties = {
	...LAYER,
	display: "flex",
	alignItems: "center",
	justifyContent: "center",
	lineHeight: 1.2,
	fontSize: "min(34cqb, 11cqi)",
	fontWeight: 500,
}
const TEXT: CSSProperties = {
	maxWidth: "100%",
	boxSizing: "border-box",
	padding: "0 0.9em",
	overflow: "hidden",
	textOverflow: "ellipsis",
	whiteSpace: "nowrap",
}
export function Flood({ label = "Get started", tokens, className = "", static: isStatic = false }: FloodProps) {
	const ref = useRef<HTMLDivElement>(null)
	useEffect(() => selfSize(ref.current), [])
	const flood = useRef<HTMLSpanElement>(null)
	const dir = /[֐-ࣿיִ-﷿ﹰ-﻿]/.test(label) ? "rtl" : "ltr"
	useEffect(() => {
		const root = ref.current
		if (!root || isStatic) return
		let prev = 0
		let on = false
		let f = 0
		let from = 0
		let t = 0
		let cx = 0
		let cy = 0
		let R = 0
		const reach = (x: number, y: number, w: number, h: number) => Math.hypot(Math.max(x, w - x), Math.max(y, h - y))
		return engine(
			root,
			({ x, y, active, reduced, w, h }, dt) => {
				const el = flood.current
				if (!el) return false
				const want = active > prev ? true : active < prev ? false : active > 0.5
				prev = active
				const px = Math.min(Math.max(x, 0), w)
				const py = Math.min(Math.max(y, 0), h)
				if (want !== on) {
					on = want
					if ((on && f === 0) || (!on && f > 0.999)) {
						cx = px
						cy = py
					}
					R = reach(cx, cy, w, h)
					from = f
					t = 0
				}
				const to = on ? 1 : 0
				if (reduced) f = to
				else {
					t += dt
					const p = Math.min(1, t / (on ? FILL : DRAIN))
					f = from + (to - from) * outCubic(p)
					if (p >= 1) f = to
				}
				el.style.clipPath =
					f === 0
						? REST
						: `circle(${(f * R + (f === 1 ? 1 : 0)).toFixed(2)}px at ${cx.toFixed(1)}px ${cy.toFixed(1)}px)`
				return f !== to
			},
			[96, 32],
		)
	}, [isStatic, label])
	const style = {
		"--texture-ink": "var(--foreground)",
		"--texture-field": "var(--background)",
		...tokens,
		color: "var(--texture-ink)",
		background: "var(--texture-field)",
		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="flood"
			className={className}
		>
			<span style={HIDDEN}>{label}</span>
			<span aria-hidden="true" dir={dir} style={ROW}>
				<span style={TEXT}>{label}</span>
			</span>
			<span
				ref={flood}
				data-part
				aria-hidden="true"
				dir={dir}
				style={{ ...ROW, background: "var(--texture-ink)", color: "var(--texture-field)", clipPath: REST }}
			>
				<span style={TEXT}>{label}</span>
			</span>
			<span aria-hidden="true" style={{ ...LAYER, boxShadow: "inset 0 0 0 1.5px var(--texture-ink)" }} />
		</div>
	)
}
```. Live preview: /embed/flood.

## Agent brief

## 1. What it is

An outline pill button with its label set in the project's own font. On hover the ink floods the surface from the exact point where the pointer came in, a filled circle growing until it covers the farthest corner, and the label turns to the field colour inside the flood. Leaving drains the flood back toward the point where the pointer left. Use it for a secondary or primary call to action that should answer the hand. Not for icon buttons: below 96 × 32 it stays static.

## 2. Behaviour timeline

- **Idle:** the outline and the resting label. Nothing runs: no loops, no listeners doing work.
- **Hover-in:** a circle centred on the entry point grows to the farthest corner over 320 ms on an ease-out; inside it the surface is ink and the label is field.
- **Pointer move:** read on a passive listener once per frame; the flood keeps its entry centre; the box is read once on enter, never in the move handler.
- **Hover-out:** a full flood re-centres on the leaving point and drains to nothing there in 240 ms on an ease-out; a flood caught half-way reverses about its own centre. Interruptible at any point.
- **Reduced motion / slow display:** the flood is applied and removed in one step; nothing eases.
- **Touch:** press — a finger down floods from where it lands, lifting it drains; nothing plays by itself.

## 3. Integration

- Install: `npx shadcn@latest add https://sitesupply.dev/r/flood.json` (one self-contained React file at components/textures/flood.tsx), or write the file `get_texture` returns exactly as returned, or use the code in this Component's markdown twin (/s/flood.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; a long label ends in an ellipsis).
- 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 outline, the flood and the resting label) and `--texture-field` (the surface at rest and the label inside the flood); both default to the shadcn `--foreground` and `--background`.

## 4. Customisation

- Change the label and the two tokens (an accent flood: set `--texture-ink` to `var(--primary)` and `--texture-field` to `var(--primary-foreground)`); round the host's corners to match (the surface is a pill). Do not change the timing, the outline width 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 two drawn labels are `aria-hidden`.
- Both states keep the ink/field contrast of the two tokens; keyboard focus on the host floods from the centre.


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