# ONONC > ONONC is an original, motion-first React component library for Next.js 16 (App Router, React 19, TypeScript, Tailwind CSS v4, Framer Motion, lucide-react). It is a copy-paste library: every component ships its real source, which you paste directly into your project — there is no package to install. Notes for coding agents: - Components are grouped into four categories: Backgrounds (ambient animated canvases), Text (typographic animations), Components (interactive UI primitives), and Blocks (composed page sections). - Copy a component's source from its page on the site, or grab every source at once from https://dev.ononc.com/llms-full.txt. - Or install any component with the shadcn CLI — `npx shadcn@latest add https://dev.ononc.com/r/.json` — which bundles the component plus every internal file it imports (@/lib, sibling components) at your project's aliases. Each item is served at /r/.json. - Stack: React 19 + Next.js 16, TypeScript, Tailwind CSS v4 (CSS-first @theme tokens in src/app/globals.css — there is no tailwind.config), Framer Motion for interactive motion, lucide-react for icons. - Shared helpers live in src/lib/utils.ts (cn, clamp, mapRange, seededRandom, prefersReducedMotion); canvas components use the lifecycle hook in src/lib/use-canvas.ts. The shadcn install bundles these automatically; when copying by hand, copy them too. - Styling: components use ONONC's Tailwind v4 design tokens and keyframes from src/app/globals.css (@theme — e.g. surface/brand/muted colors, aurora/shimmer/marquee animations). The registry brings the .tsx files only, so copy those tokens into your globals.css for exact styling. - All motion degrades under prefers-reduced-motion; canvas backgrounds pause when off-screen and when the tab is hidden. > This file inlines the full source of all 308 components. Each fenced block is the exact contents of the file at the given path. ## Backgrounds Ambient, animated canvases to sit behind your content. GPU-friendly and they pause when off-screen. ### Flow Field Particles drift along an invisible flow field, leaving glowing trails. URL: https://dev.ononc.com/backgrounds/flow-field Path: src/components/backgrounds/flow-field.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface FlowFieldProps extends HTMLAttributes { /** Approx. particles per 100k px² (capped). */ density?: number; /** Trail colors as "r,g,b" (cycled per particle). */ colors?: string[]; children?: ReactNode; } interface Particle { x: number; y: number; vx: number; vy: number; life: number; color: string; } interface State { ps: Particle[]; pointer: { x: number; y: number; inside: boolean }; } export function FlowField({ className, density = 6, colors = ["139,124,255", "94,234,255", "244,114,182"], children, ...props }: FlowFieldProps) { const ref = useCanvas({ init: ({ width, height }) => { const count = Math.max( 80, Math.min(460, Math.round(((width * height) / 100000) * density * 10)), ); const spawn = (): Particle => ({ x: Math.random() * width, y: Math.random() * height, vx: 0, vy: 0, life: 60 + Math.random() * 240, color: colors[Math.floor(Math.random() * colors.length)], }); return { ps: Array.from({ length: count }, spawn), pointer: { x: 0, y: 0, inside: false }, }; }, draw: ({ ctx, width, height }, state, t) => { // Fade prior frame for silky trails (instead of clearing). ctx.fillStyle = "rgba(6,7,13,0.07)"; ctx.fillRect(0, 0, width, height); ctx.globalCompositeOperation = "lighter"; ctx.lineWidth = 1.1; const { ps, pointer } = state; for (const p of ps) { // Flow angle from layered sine "noise". const angle = (Math.sin(p.x * 0.0022 + t * 0.25) + Math.sin(p.y * 0.0027 - t * 0.2) + Math.sin((p.x + p.y) * 0.0016 + t * 0.12)) * Math.PI; p.vx += Math.cos(angle) * 0.08; p.vy += Math.sin(angle) * 0.08; if (pointer.inside) { const dx = p.x - pointer.x; const dy = p.y - pointer.y; const d2 = dx * dx + dy * dy; if (d2 < 160 * 160 && d2 > 1) { const f = 22 / d2; p.vx += dx * f; p.vy += dy * f; } } p.vx *= 0.93; p.vy *= 0.93; const nx = p.x + p.vx; const ny = p.y + p.vy; ctx.strokeStyle = `rgba(${p.color},0.5)`; ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(nx, ny); ctx.stroke(); p.x = nx; p.y = ny; p.life -= 1; if ( p.life <= 0 || p.x < -20 || p.x > width + 20 || p.y < -20 || p.y > height + 20 ) { p.x = Math.random() * width; p.y = Math.random() * height; p.vx = 0; p.vy = 0; p.life = 60 + Math.random() * 240; } } ctx.globalCompositeOperation = "source-over"; }, onPointer: (state, x, y, inside) => { state.pointer.x = x; state.pointer.y = y; state.pointer.inside = inside; }, }); return (
{children}
); } ``` ### Aurora Ribbons Luminous ribbons of light weave and drift like the aurora. URL: https://dev.ononc.com/backgrounds/aurora-ribbons Path: src/components/backgrounds/aurora-ribbons.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface AuroraRibbonsProps extends HTMLAttributes { /** Ribbon colors as "r,g,b" (back to front). */ colors?: string[]; children?: ReactNode; } interface Ribbon { color: string; yBase: number; amp: number; amp2: number; len: number; len2: number; speed: number; phase: number; thickness: number; } interface State { ribbons: Ribbon[]; } export function AuroraRibbons({ className, colors = ["139,92,246", "34,211,238", "244,114,182", "79,70,229"], children, ...props }: AuroraRibbonsProps) { const ref = useCanvas({ init: ({ width, height }) => { const ribbons: Ribbon[] = colors.map((color, i) => ({ color, yBase: height * (0.32 + i * 0.13), amp: 28 + i * 10, amp2: 12 + i * 6, len: width * (0.5 + i * 0.12), len2: width * (0.18 + i * 0.05), speed: 0.12 + i * 0.05, phase: i * 1.7, thickness: 60 + i * 18, })); return { ribbons }; }, draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); ctx.globalCompositeOperation = "lighter"; for (const r of state.ribbons) { const top: [number, number][] = []; const bottom: [number, number][] = []; for (let x = 0; x <= width; x += 12) { const wobble = Math.sin(x / r.len + t * r.speed + r.phase) * r.amp + Math.sin(x / r.len2 - t * r.speed * 1.7 + r.phase) * r.amp2; const y = r.yBase + wobble; const thick = r.thickness * (0.6 + 0.4 * Math.sin(x / (r.len * 0.7) + t * 0.3)); top.push([x, y - thick / 2]); bottom.push([x, y + thick / 2]); } ctx.beginPath(); ctx.moveTo(top[0][0], top[0][1]); for (const [x, y] of top) ctx.lineTo(x, y); for (let i = bottom.length - 1; i >= 0; i--) ctx.lineTo(bottom[i][0], bottom[i][1]); ctx.closePath(); const grad = ctx.createLinearGradient(0, 0, width, 0); grad.addColorStop(0, `rgba(${r.color},0)`); grad.addColorStop(0.5, `rgba(${r.color},0.22)`); grad.addColorStop(1, `rgba(${r.color},0)`); ctx.fillStyle = grad; ctx.fill(); } ctx.globalCompositeOperation = "source-over"; // Vignette to seat the ribbons. const vg = ctx.createRadialGradient( width / 2, height / 2, 0, width / 2, height / 2, Math.max(width, height) * 0.7, ); vg.addColorStop(0, "rgba(6,7,13,0)"); vg.addColorStop(1, "rgba(6,7,13,0.55)"); ctx.fillStyle = vg; ctx.fillRect(0, 0, width, height); }, }); return (
{children}
); } ``` ### Vortex Particles swirl around a drifting center that follows your cursor. URL: https://dev.ononc.com/backgrounds/vortex Path: src/components/backgrounds/vortex.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface VortexProps extends HTMLAttributes { /** Number of orbiting particles. */ count?: number; colors?: string[]; children?: ReactNode; } interface Particle { x: number; y: number; vx: number; vy: number; color: string; } interface State { ps: Particle[]; pointer: { x: number; y: number; inside: boolean }; } export function Vortex({ className, count = 240, colors = ["139,124,255", "94,234,255", "244,114,182"], children, ...props }: VortexProps) { const ref = useCanvas({ init: ({ width, height }) => { const ps: Particle[] = Array.from({ length: count }, () => ({ x: Math.random() * width, y: Math.random() * height, vx: 0, vy: 0, color: colors[Math.floor(Math.random() * colors.length)], })); return { ps, pointer: { x: 0, y: 0, inside: false } }; }, draw: ({ ctx, width, height }, state, t) => { ctx.fillStyle = "rgba(6,7,13,0.09)"; ctx.fillRect(0, 0, width, height); ctx.globalCompositeOperation = "lighter"; ctx.lineWidth = 1.1; const cx = state.pointer.inside ? state.pointer.x : width / 2 + Math.sin(t * 0.2) * width * 0.12; const cy = state.pointer.inside ? state.pointer.y : height / 2 + Math.cos(t * 0.24) * height * 0.12; const maxR = Math.hypot(width, height) * 0.6; for (const p of state.ps) { const dx = p.x - cx; const dy = p.y - cy; const r = Math.hypot(dx, dy) || 0.001; // Tangential (swirl) + gentle radial correction toward a mid band. const tx = -dy / r; const ty = dx / r; const swirl = 1.7; p.vx += tx * swirl * 0.12; p.vy += ty * swirl * 0.12; const pull = (r - height * 0.32) * 0.0006; p.vx -= (dx / r) * pull * 40; p.vy -= (dy / r) * pull * 40; p.vx *= 0.95; p.vy *= 0.95; const nx = p.x + p.vx; const ny = p.y + p.vy; ctx.strokeStyle = `rgba(${p.color},0.55)`; ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(nx, ny); ctx.stroke(); p.x = nx; p.y = ny; if (r > maxR) { const a = Math.random() * Math.PI * 2; const rr = height * (0.2 + Math.random() * 0.25); p.x = cx + Math.cos(a) * rr; p.y = cy + Math.sin(a) * rr; p.vx = 0; p.vy = 0; } } ctx.globalCompositeOperation = "source-over"; }, onPointer: (state, x, y, inside) => { state.pointer.x = x; state.pointer.y = y; state.pointer.inside = inside; }, }); return (
{children}
); } ``` ### Grid Beams Pulses of light race along the lines of a faint grid. URL: https://dev.ononc.com/backgrounds/grid-beams Path: src/components/backgrounds/grid-beams.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface GridBeamsProps extends HTMLAttributes { /** Grid cell size in pixels. */ size?: number; /** Beam colors as "r,g,b". */ colors?: string[]; children?: ReactNode; } interface Beam { axis: "h" | "v"; coord: number; pos: number; len: number; speed: number; color: string; } interface State { beams: Beam[]; size: number; } export function GridBeams({ className, size = 46, colors = ["139,124,255", "94,234,255", "244,114,182"], children, ...props }: GridBeamsProps) { const ref = useCanvas({ init: ({ width, height }) => { const cols = Math.max(1, Math.floor(width / size)); const rows = Math.max(1, Math.floor(height / size)); const beamCount = Math.min(26, Math.max(8, Math.round((cols + rows) / 3))); const make = (): Beam => { const axis = Math.random() > 0.5 ? "h" : "v"; const coord = axis === "h" ? Math.floor(Math.random() * (rows + 1)) * size : Math.floor(Math.random() * (cols + 1)) * size; const length = axis === "h" ? width : height; return { axis, coord, pos: Math.random() * length, len: 70 + Math.random() * 150, speed: 90 + Math.random() * 160, color: colors[Math.floor(Math.random() * colors.length)], }; }; return { beams: Array.from({ length: beamCount }, make), size }; }, draw: ({ ctx, width, height }, state, _t, dt) => { ctx.clearRect(0, 0, width, height); // Faint grid. ctx.strokeStyle = "rgba(255,255,255,0.045)"; ctx.lineWidth = 1; ctx.beginPath(); for (let x = 0; x <= width; x += state.size) { ctx.moveTo(x, 0); ctx.lineTo(x, height); } for (let y = 0; y <= height; y += state.size) { ctx.moveTo(0, y); ctx.lineTo(width, y); } ctx.stroke(); // Travelling beams. ctx.globalCompositeOperation = "lighter"; ctx.lineWidth = 2; for (const b of state.beams) { const length = b.axis === "h" ? width : height; b.pos += b.speed * dt; if (b.pos - b.len > length) b.pos = -b.len + Math.random() * 40; const headX = b.axis === "h" ? b.pos : b.coord; const headY = b.axis === "h" ? b.coord : b.pos; const tailX = b.axis === "h" ? b.pos - b.len : b.coord; const tailY = b.axis === "h" ? b.coord : b.pos - b.len; const grad = ctx.createLinearGradient(tailX, tailY, headX, headY); grad.addColorStop(0, `rgba(${b.color},0)`); grad.addColorStop(1, `rgba(${b.color},0.85)`); ctx.strokeStyle = grad; ctx.beginPath(); ctx.moveTo(tailX, tailY); ctx.lineTo(headX, headY); ctx.stroke(); ctx.fillStyle = `rgba(${b.color},0.95)`; ctx.beginPath(); ctx.arc(headX, headY, 2.2, 0, Math.PI * 2); ctx.fill(); } ctx.globalCompositeOperation = "source-over"; }, }); return (
{children}
); } ``` ### Topographic Lines Contour lines that morph like a living topographic map. URL: https://dev.ononc.com/backgrounds/topographic-lines Path: src/components/backgrounds/topographic-lines.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface TopographicLinesProps extends HTMLAttributes { /** Vertical gap between contour lines in pixels. */ gap?: number; /** Line color as "r,g,b". */ color?: string; /** Accent color (every 5th line) as "r,g,b". */ accent?: string; children?: ReactNode; } interface State { gap: number; } export function TopographicLines({ className, gap = 26, color = "150,160,255", accent = "94,234,255", children, ...props }: TopographicLinesProps) { const ref = useCanvas({ init: () => ({ gap }), draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); ctx.lineWidth = 1.2; const lines = Math.ceil(height / state.gap) + 2; for (let i = 0; i < lines; i++) { const baseY = i * state.gap; ctx.beginPath(); for (let x = 0; x <= width; x += 8) { const f = Math.sin(x * 0.006 + baseY * 0.01 + t * 0.3) * 18 + Math.sin(x * 0.003 - baseY * 0.014 - t * 0.22) * 14 + Math.sin(x * 0.011 + baseY * 0.006 + t * 0.15) * 6; const y = baseY + f; if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } const isAccent = i % 5 === 0; ctx.strokeStyle = isAccent ? `rgba(${accent},0.32)` : `rgba(${color},0.16)`; ctx.stroke(); } // Soft vignette for depth. const vg = ctx.createRadialGradient( width / 2, height / 2, 0, width / 2, height / 2, Math.max(width, height) * 0.7, ); vg.addColorStop(0, "rgba(6,7,13,0)"); vg.addColorStop(1, "rgba(6,7,13,0.6)"); ctx.fillStyle = vg; ctx.fillRect(0, 0, width, height); }, }); return (
{children}
); } ``` ### Embers Warm embers rise and flicker out into the dark. URL: https://dev.ononc.com/backgrounds/embers Path: src/components/backgrounds/embers.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface EmbersProps extends HTMLAttributes { /** Number of embers. */ count?: number; /** Ember colors as "r,g,b" (warm by default). */ colors?: string[]; children?: ReactNode; } interface Ember { x: number; y: number; vy: number; drift: number; r: number; seed: number; life: number; maxLife: number; color: string; } interface State { embers: Ember[]; } export function Embers({ className, count = 90, colors = ["255,176,84", "255,138,76", "255,214,140"], children, ...props }: EmbersProps) { const ref = useCanvas({ init: ({ width, height }) => { const make = (seeded: boolean): Ember => ({ x: Math.random() * width, y: seeded ? Math.random() * height : height + Math.random() * 40, vy: 0.3 + Math.random() * 0.9, drift: (Math.random() - 0.5) * 0.4, r: 1 + Math.random() * 2.4, seed: Math.random() * 1000, life: seeded ? Math.random() * 200 : 0, maxLife: 200 + Math.random() * 220, color: colors[Math.floor(Math.random() * colors.length)], }); return { embers: Array.from({ length: count }, () => make(true)) }; }, draw: ({ ctx, width, height }, state, t) => { ctx.fillStyle = "rgba(6,7,13,0.18)"; ctx.fillRect(0, 0, width, height); ctx.globalCompositeOperation = "lighter"; for (const e of state.embers) { e.y -= e.vy; e.x += e.drift + Math.sin(t * 1.5 + e.seed) * 0.4; e.life += 1; const lifeT = e.life / e.maxLife; const flicker = 0.65 + 0.35 * Math.sin(t * 9 + e.seed); const alpha = Math.sin(Math.min(1, lifeT) * Math.PI) * flicker; if (alpha > 0.01) { const glow = ctx.createRadialGradient(e.x, e.y, 0, e.x, e.y, e.r * 4); glow.addColorStop(0, `rgba(${e.color},${alpha})`); glow.addColorStop(1, `rgba(${e.color},0)`); ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(e.x, e.y, e.r * 4, 0, Math.PI * 2); ctx.fill(); } if (e.life >= e.maxLife || e.y < -10) { e.x = Math.random() * width; e.y = height + Math.random() * 30; e.life = 0; e.vy = 0.3 + Math.random() * 0.9; e.r = 1 + Math.random() * 2.4; e.color = colors[Math.floor(Math.random() * colors.length)]; } } ctx.globalCompositeOperation = "source-over"; }, }); return (
{children}
); } ``` ### Warp Stars Stars streaking outward from the center like hyperspace. URL: https://dev.ononc.com/backgrounds/warp-stars Path: src/components/backgrounds/warp-stars.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface WarpStarsProps extends HTMLAttributes { count?: number; color?: string; children?: ReactNode; } interface Star { angle: number; r: number; speed: number; } interface State { stars: Star[]; } export function WarpStars({ className, count = 150, color = "200,214,255", children, ...props }: WarpStarsProps) { const ref = useCanvas({ init: ({ width, height }) => { const maxR = Math.hypot(width, height) / 2; const stars: Star[] = Array.from({ length: count }, () => ({ angle: Math.random() * Math.PI * 2, r: Math.random() * maxR, speed: 0.6 + Math.random() * 1.6, })); return { stars }; }, draw: ({ ctx, width, height }, state) => { ctx.clearRect(0, 0, width, height); const cx = width / 2; const cy = height / 2; const maxR = Math.hypot(width, height) / 2; ctx.globalCompositeOperation = "lighter"; for (const s of state.stars) { const oldR = s.r; s.r += s.speed * (s.r * 0.02 + 0.6); const cos = Math.cos(s.angle); const sin = Math.sin(s.angle); const alpha = Math.min(1, s.r / (maxR * 0.55)); ctx.strokeStyle = `rgba(${color},${alpha * 0.9})`; ctx.lineWidth = Math.min(2.6, 0.4 + alpha * 2.4); ctx.beginPath(); ctx.moveTo(cx + cos * oldR, cy + sin * oldR); ctx.lineTo(cx + cos * s.r, cy + sin * s.r); ctx.stroke(); if (s.r > maxR) { s.r = Math.random() * 24; s.angle = Math.random() * Math.PI * 2; s.speed = 0.6 + Math.random() * 1.6; } } ctx.globalCompositeOperation = "source-over"; }, }); return (
{children}
); } ``` ### Wave Interference A dot field rippling from orbiting wave sources. URL: https://dev.ononc.com/backgrounds/wave-interference Path: src/components/backgrounds/wave-interference.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface WaveInterferenceProps extends HTMLAttributes { /** Dot grid spacing in pixels. */ gap?: number; /** Dot color as "r,g,b". */ color?: string; children?: ReactNode; } interface State { gap: number; } export function WaveInterference({ className, gap = 18, color = "120,180,255", children, ...props }: WaveInterferenceProps) { const ref = useCanvas({ init: () => ({ gap }), draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); const g = state.gap; // Three slowly orbiting wave sources. const sources = [ [width * (0.5 + 0.34 * Math.sin(t * 0.3)), height * (0.5 + 0.34 * Math.cos(t * 0.26))], [width * (0.5 + 0.32 * Math.sin(t * 0.22 + 2)), height * (0.5 + 0.3 * Math.cos(t * 0.34 + 1))], [width * (0.5 + 0.3 * Math.cos(t * 0.28 + 4)), height * (0.5 + 0.33 * Math.sin(t * 0.24 + 3))], ] as const; for (let y = g / 2; y < height; y += g) { for (let x = g / 2; x < width; x += g) { let v = 0; for (const [sx, sy] of sources) { v += Math.sin(Math.hypot(x - sx, y - sy) * 0.045 - t * 2.2); } const b = (v / sources.length + 1) / 2; // 0..1 const r = 0.6 + b * b * 2.6; ctx.fillStyle = `rgba(${color},${0.08 + b * b * 0.6})`; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); } } }, }); return (
{children}
); } ``` ### Bokeh Soft, out-of-focus orbs of colored light drifting by. URL: https://dev.ononc.com/backgrounds/bokeh Path: src/components/backgrounds/bokeh.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface BokehProps extends HTMLAttributes { count?: number; colors?: string[]; children?: ReactNode; } interface Orb { x: number; y: number; r: number; vx: number; vy: number; alpha: number; color: string; } interface State { orbs: Orb[]; } export function Bokeh({ className, count = 22, colors = ["139,124,255", "94,234,255", "244,114,182"], children, ...props }: BokehProps) { const ref = useCanvas({ init: ({ width, height }) => { const orbs: Orb[] = Array.from({ length: count }, () => ({ x: Math.random() * width, y: Math.random() * height, r: 16 + Math.random() * 64, vx: (Math.random() - 0.5) * 0.25, vy: (Math.random() - 0.5) * 0.25, alpha: 0.06 + Math.random() * 0.12, color: colors[Math.floor(Math.random() * colors.length)], })); return { orbs }; }, draw: ({ ctx, width, height }, state) => { ctx.clearRect(0, 0, width, height); ctx.globalCompositeOperation = "lighter"; for (const o of state.orbs) { o.x += o.vx; o.y += o.vy; if (o.x < -o.r) o.x = width + o.r; if (o.x > width + o.r) o.x = -o.r; if (o.y < -o.r) o.y = height + o.r; if (o.y > height + o.r) o.y = -o.r; const glow = ctx.createRadialGradient(o.x, o.y, 0, o.x, o.y, o.r); glow.addColorStop(0, `rgba(${o.color},${o.alpha})`); glow.addColorStop(0.7, `rgba(${o.color},${o.alpha * 0.5})`); glow.addColorStop(1, `rgba(${o.color},0)`); ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(o.x, o.y, o.r, 0, Math.PI * 2); ctx.fill(); } ctx.globalCompositeOperation = "source-over"; }, }); return (
{children}
); } ``` ### Fireflies Glowing dots that wander and blink in the dark. URL: https://dev.ononc.com/backgrounds/fireflies Path: src/components/backgrounds/fireflies.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface FirefliesProps extends HTMLAttributes { count?: number; colors?: string[]; children?: ReactNode; } interface Fly { x: number; y: number; vx: number; vy: number; r: number; phase: number; color: string; } interface State { flies: Fly[]; } export function Fireflies({ className, count = 48, colors = ["190,255,150", "255,224,130", "150,234,255"], children, ...props }: FirefliesProps) { const ref = useCanvas({ init: ({ width, height }) => { const flies: Fly[] = Array.from({ length: count }, () => ({ x: Math.random() * width, y: Math.random() * height, vx: (Math.random() - 0.5) * 0.4, vy: (Math.random() - 0.5) * 0.4, r: 1 + Math.random() * 1.8, phase: Math.random() * Math.PI * 2, color: colors[Math.floor(Math.random() * colors.length)], })); return { flies }; }, draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); ctx.globalCompositeOperation = "lighter"; for (const f of state.flies) { f.vx += (Math.random() - 0.5) * 0.06; f.vy += (Math.random() - 0.5) * 0.06; f.vx = Math.max(-0.7, Math.min(0.7, f.vx)); f.vy = Math.max(-0.7, Math.min(0.7, f.vy)); f.x += f.vx; f.y += f.vy; if (f.x < 0) f.x = width; if (f.x > width) f.x = 0; if (f.y < 0) f.y = height; if (f.y > height) f.y = 0; const blink = 0.25 + 0.75 * (0.5 + 0.5 * Math.sin(t * 2.2 + f.phase)); const glow = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, f.r * 6); glow.addColorStop(0, `rgba(${f.color},${blink})`); glow.addColorStop(1, `rgba(${f.color},0)`); ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(f.x, f.y, f.r * 6, 0, Math.PI * 2); ctx.fill(); } ctx.globalCompositeOperation = "source-over"; }, }); return (
{children}
); } ``` ### Hex Grid A honeycomb grid that lights up near the cursor. URL: https://dev.ononc.com/backgrounds/hex-grid Path: src/components/backgrounds/hex-grid.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface HexGridProps extends HTMLAttributes { /** Hexagon radius in pixels. */ size?: number; /** Cell color as "r,g,b". */ color?: string; children?: ReactNode; } interface Cell { x: number; y: number; } interface State { cells: Cell[]; size: number; pointer: { x: number; y: number; inside: boolean }; } export function HexGrid({ className, size = 26, color = "139,124,255", children, ...props }: HexGridProps) { const ref = useCanvas({ init: ({ width, height }) => { const cells: Cell[] = []; const hw = Math.sqrt(3) * size; const vs = 1.5 * size; const rows = Math.ceil(height / vs) + 1; const cols = Math.ceil(width / hw) + 1; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { cells.push({ x: c * hw + (r % 2) * (hw / 2), y: r * vs }); } } return { cells, size, pointer: { x: 0, y: 0, inside: false } }; }, draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); ctx.lineWidth = 1; const s = state.size; for (const cell of state.cells) { // Ambient ring wave from the center + optional cursor highlight. const dwave = Math.hypot(cell.x - width / 2, cell.y - height / 2); let alpha = 0.05 + 0.06 * (0.5 + 0.5 * Math.sin(dwave * 0.02 - t * 1.6)); if (state.pointer.inside) { const d = Math.hypot(cell.x - state.pointer.x, cell.y - state.pointer.y); if (d < 160) alpha += (1 - d / 160) * 0.6; } ctx.strokeStyle = `rgba(${color},${alpha})`; ctx.beginPath(); for (let i = 0; i < 6; i++) { const a = (Math.PI / 180) * (60 * i - 90); const px = cell.x + s * Math.cos(a); const py = cell.y + s * Math.sin(a); if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py); } ctx.closePath(); ctx.stroke(); } }, onPointer: (state, x, y, inside) => { state.pointer.x = x; state.pointer.y = y; state.pointer.inside = inside; }, }); return (
{children}
); } ``` ### Snowfall Depth-layered snow drifting gently downward. URL: https://dev.ononc.com/backgrounds/snowfall Path: src/components/backgrounds/snowfall.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface SnowfallProps extends HTMLAttributes { /** Approx. flakes per 100k px² (capped). */ density?: number; children?: ReactNode; } interface Flake { x: number; y: number; r: number; vy: number; sway: number; phase: number; } interface State { flakes: Flake[]; } export function Snowfall({ className, density = 9, children, ...props }: SnowfallProps) { const ref = useCanvas({ init: ({ width, height }) => { const count = Math.max( 30, Math.min(220, Math.round(((width * height) / 100000) * density)), ); const flakes: Flake[] = Array.from({ length: count }, () => { const r = 0.8 + Math.random() * 2.8; return { x: Math.random() * width, y: Math.random() * height, r, vy: 0.3 + r * 0.35, sway: 0.3 + Math.random() * 0.9, phase: Math.random() * Math.PI * 2, }; }); return { flakes }; }, draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); for (const f of state.flakes) { f.y += f.vy; f.x += Math.sin(t * 0.8 + f.phase) * f.sway; if (f.y > height + 4) { f.y = -4; f.x = Math.random() * width; } ctx.fillStyle = `rgba(235,240,255,${0.35 + f.r * 0.14})`; ctx.beginPath(); ctx.arc(f.x, f.y, f.r, 0, Math.PI * 2); ctx.fill(); } }, }); return (
{children}
); } ``` ### Metaballs Soft blobs of color drift and merge like a lava lamp. URL: https://dev.ononc.com/backgrounds/metaballs Path: src/components/backgrounds/metaballs.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface MetaballsProps extends HTMLAttributes { count?: number; colors?: string[]; children?: ReactNode; } interface Ball { x: number; y: number; vx: number; vy: number; r: number; color: string; } interface State { balls: Ball[]; } export function Metaballs({ className, count = 8, colors = ["139,92,246", "34,211,238", "244,114,182"], children, ...props }: MetaballsProps) { const ref = useCanvas({ init: ({ width, height }) => { const balls: Ball[] = Array.from({ length: count }, () => ({ x: Math.random() * width, y: Math.random() * height, vx: (Math.random() - 0.5) * 0.9, vy: (Math.random() - 0.5) * 0.9, r: 60 + Math.random() * 90, color: colors[Math.floor(Math.random() * colors.length)], })); return { balls }; }, draw: ({ ctx, width, height }, state) => { ctx.clearRect(0, 0, width, height); ctx.globalCompositeOperation = "lighter"; for (const b of state.balls) { b.x += b.vx; b.y += b.vy; if (b.x < -b.r * 0.5 || b.x > width + b.r * 0.5) b.vx *= -1; if (b.y < -b.r * 0.5 || b.y > height + b.r * 0.5) b.vy *= -1; const glow = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, b.r); glow.addColorStop(0, `rgba(${b.color},0.5)`); glow.addColorStop(0.5, `rgba(${b.color},0.18)`); glow.addColorStop(1, `rgba(${b.color},0)`); ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2); ctx.fill(); } ctx.globalCompositeOperation = "source-over"; }, }); return (
{children}
); } ``` ### Orbiting Dots Concentric rings of dots orbiting a glowing core. URL: https://dev.ononc.com/backgrounds/orbiting-dots Path: src/components/backgrounds/orbiting-dots.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface OrbitingDotsProps extends HTMLAttributes { colors?: string[]; children?: ReactNode; } interface Ring { radius: number; count: number; speed: number; phase: number; color: string; dotR: number; } interface State { rings: Ring[]; } export function OrbitingDots({ className, colors = ["139,124,255", "94,234,255", "244,114,182"], children, ...props }: OrbitingDotsProps) { const ref = useCanvas({ init: ({ width, height }) => { const maxR = Math.min(width, height) * 0.44; const ringCount = 6; const rings: Ring[] = Array.from({ length: ringCount }, (_, i) => { const radius = maxR * ((i + 1) / ringCount); return { radius, count: 4 + i * 3, speed: (0.15 + i * 0.05) * (i % 2 === 0 ? 1 : -1), phase: i * 0.7, color: colors[i % colors.length], dotR: 2.6 - i * 0.18, }; }); return { rings }; }, draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); const cx = width / 2; const cy = height / 2; // Center glow. const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, 26); core.addColorStop(0, "rgba(196,181,253,0.9)"); core.addColorStop(1, "rgba(196,181,253,0)"); ctx.fillStyle = core; ctx.beginPath(); ctx.arc(cx, cy, 26, 0, Math.PI * 2); ctx.fill(); for (const ring of state.rings) { ctx.strokeStyle = `rgba(${ring.color},0.1)`; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(cx, cy, ring.radius, 0, Math.PI * 2); ctx.stroke(); for (let i = 0; i < ring.count; i++) { const a = ring.phase + t * ring.speed + (i * Math.PI * 2) / ring.count; const x = cx + Math.cos(a) * ring.radius; const y = cy + Math.sin(a) * ring.radius; ctx.fillStyle = `rgba(${ring.color},0.85)`; ctx.beginPath(); ctx.arc(x, y, Math.max(1, ring.dotR), 0, Math.PI * 2); ctx.fill(); } } }, }); return (
{children}
); } ``` ### DNA Helix A rotating double helix of paired strands and rungs. URL: https://dev.ononc.com/backgrounds/dna-helix Path: src/components/backgrounds/dna-helix.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface DnaHelixProps extends HTMLAttributes { /** The two strand colors as "r,g,b". */ colors?: [string, string]; children?: ReactNode; } interface State { empty: true; } export function DnaHelix({ className, colors = ["139,124,255", "94,234,255"], children, ...props }: DnaHelixProps) { const ref = useCanvas({ init: () => ({ empty: true }), draw: ({ ctx, width, height }, _state, t) => { ctx.clearRect(0, 0, width, height); const cy = height / 2; const amp = height * 0.3; const freq = 0.016; const speed = 1.3; const [c1, c2] = colors; const step = 22; for (let x = 0; x <= width; x += step) { const ph = x * freq + t * speed; const y1 = cy + Math.sin(ph) * amp; const y2 = cy + Math.sin(ph + Math.PI) * amp; const depth1 = (Math.cos(ph) + 1) / 2; // 0 (back) .. 1 (front) const depth2 = 1 - depth1; // Rung connecting the strands. ctx.strokeStyle = `rgba(180,190,220,${0.06 + Math.min(depth1, depth2) * 0.18})`; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, y1); ctx.lineTo(x, y2); ctx.stroke(); // Strand nodes (front drawn larger / brighter). ctx.fillStyle = `rgba(${c1},${0.35 + depth1 * 0.6})`; ctx.beginPath(); ctx.arc(x, y1, 1.5 + depth1 * 3.5, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = `rgba(${c2},${0.35 + depth2 * 0.6})`; ctx.beginPath(); ctx.arc(x, y2, 1.5 + depth2 * 3.5, 0, Math.PI * 2); ctx.fill(); } }, }); return (
{children}
); } ``` ### Radar Sweep A rotating sweep lighting up blips over concentric rings. URL: https://dev.ononc.com/backgrounds/radar-sweep Path: src/components/backgrounds/radar-sweep.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface RadarSweepProps extends HTMLAttributes { /** Sweep color as "r,g,b". */ color?: string; children?: ReactNode; } interface Blip { angle: number; dist: number; } interface State { blips: Blip[]; } const TAU = Math.PI * 2; export function RadarSweep({ className, color = "94,234,255", children, ...props }: RadarSweepProps) { const ref = useCanvas({ init: () => ({ blips: Array.from({ length: 7 }, () => ({ angle: Math.random() * TAU, dist: 0.25 + Math.random() * 0.7, })), }), draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); const cx = width / 2; const cy = height / 2; const maxR = Math.min(width, height) * 0.48; // Concentric rings + crosshair. ctx.strokeStyle = `rgba(${color},0.16)`; ctx.lineWidth = 1; for (let i = 1; i <= 4; i++) { ctx.beginPath(); ctx.arc(cx, cy, (maxR * i) / 4, 0, TAU); ctx.stroke(); } ctx.beginPath(); ctx.moveTo(cx - maxR, cy); ctx.lineTo(cx + maxR, cy); ctx.moveTo(cx, cy - maxR); ctx.lineTo(cx, cy + maxR); ctx.stroke(); const sweep = (t * 0.9) % TAU; // Fading trailing wedge. const steps = 16; for (let k = 0; k < steps; k++) { const a = sweep - k * 0.055; ctx.fillStyle = `rgba(${color},${(1 - k / steps) * 0.12})`; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, maxR, a - 0.06, a); ctx.closePath(); ctx.fill(); } // Leading edge. ctx.strokeStyle = `rgba(${color},0.7)`; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(sweep) * maxR, cy + Math.sin(sweep) * maxR); ctx.stroke(); // Blips light up as the sweep passes. for (const b of state.blips) { const diff = (sweep - b.angle + TAU) % TAU; const bright = Math.max(0, 1 - diff / 0.9); if (bright <= 0.02) continue; const x = cx + Math.cos(b.angle) * maxR * b.dist; const y = cy + Math.sin(b.angle) * maxR * b.dist; ctx.fillStyle = `rgba(${color},${bright})`; ctx.beginPath(); ctx.arc(x, y, 2 + bright * 2.5, 0, TAU); ctx.fill(); } }, }); return (
{children}
); } ``` ### Halftone A halftone dot grid swelling with a moving wave. URL: https://dev.ononc.com/backgrounds/halftone Path: src/components/backgrounds/halftone.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface HalftoneProps extends HTMLAttributes { /** Dot grid spacing in pixels. */ gap?: number; /** Dot color as "r,g,b". */ color?: string; children?: ReactNode; } interface State { gap: number; } export function Halftone({ className, gap = 16, color = "150,160,255", children, ...props }: HalftoneProps) { const ref = useCanvas({ init: () => ({ gap }), draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); const g = state.gap; const maxR = g * 0.5; for (let y = g / 2; y < height; y += g) { for (let x = g / 2; x < width; x += g) { const wave = 0.5 + 0.5 * Math.sin(x * 0.02 + y * 0.014 - t * 1.8) * Math.cos(y * 0.02 - t * 0.6); const r = wave * maxR; if (r < 0.3) continue; ctx.fillStyle = `rgba(${color},${0.18 + wave * 0.5})`; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); } } }, }); return (
{children}
); } ``` ### Rain Angled rain streaks falling with a sense of depth. URL: https://dev.ononc.com/backgrounds/rain Path: src/components/backgrounds/rain.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface RainProps extends HTMLAttributes { /** Approx. drops per 100k px² (capped). */ density?: number; /** Streak color as "r,g,b". */ color?: string; children?: ReactNode; } interface Drop { x: number; y: number; len: number; vy: number; alpha: number; } interface State { drops: Drop[]; } const SLANT = 0.18; export function Rain({ className, density = 14, color = "150,180,255", children, ...props }: RainProps) { const ref = useCanvas({ init: ({ width, height }) => { const count = Math.max( 40, Math.min(320, Math.round(((width * height) / 100000) * density)), ); const make = (): Drop => { const depth = Math.random(); return { x: Math.random() * (width + 80) - 40, y: Math.random() * height, len: 8 + depth * 18, vy: 7 + depth * 11, alpha: 0.1 + depth * 0.35, }; }; return { drops: Array.from({ length: count }, make) }; }, draw: ({ ctx, width, height }, state) => { ctx.clearRect(0, 0, width, height); ctx.lineCap = "round"; for (const d of state.drops) { d.y += d.vy; d.x += d.vy * SLANT; if (d.y > height + d.len) { d.y = -d.len; d.x = Math.random() * (width + 80) - 40; } ctx.strokeStyle = `rgba(${color},${d.alpha})`; ctx.lineWidth = d.len > 18 ? 1.6 : 1; ctx.beginPath(); ctx.moveTo(d.x, d.y); ctx.lineTo(d.x - d.len * SLANT, d.y - d.len); ctx.stroke(); } }, }); return (
{children}
); } ``` ### Spiral Galaxy Spiral arms of stars rotating around a glowing core. URL: https://dev.ononc.com/backgrounds/spiral-galaxy Path: src/components/backgrounds/spiral-galaxy.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface SpiralGalaxyProps extends HTMLAttributes { /** Number of star dots. */ count?: number; /** Number of spiral arms. */ arms?: number; colors?: string[]; children?: ReactNode; } interface Star { r: number; baseAngle: number; size: number; color: string; } interface State { stars: Star[]; } export function SpiralGalaxy({ className, count = 420, arms = 3, colors = ["196,181,253", "94,234,255", "244,114,182"], children, ...props }: SpiralGalaxyProps) { const ref = useCanvas({ init: ({ width, height }) => { const maxR = Math.min(width, height) * 0.48; const turns = 2.4; const stars: Star[] = Array.from({ length: count }, (_, j) => { const f = j / count; const arm = j % arms; return { r: f * maxR + (Math.random() - 0.5) * 10, baseAngle: f * turns * Math.PI * 2 + arm * ((Math.PI * 2) / arms) + (Math.random() - 0.5) * 0.35, size: 0.5 + (1 - f) * 1.9, color: colors[arm % colors.length], }; }); return { stars }; }, draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); const cx = width / 2; const cy = height / 2; const maxR = Math.min(width, height) * 0.48; ctx.globalCompositeOperation = "lighter"; // Glowing core. const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, maxR * 0.4); core.addColorStop(0, "rgba(220,210,255,0.5)"); core.addColorStop(1, "rgba(220,210,255,0)"); ctx.fillStyle = core; ctx.beginPath(); ctx.arc(cx, cy, maxR * 0.4, 0, Math.PI * 2); ctx.fill(); for (const s of state.stars) { // Inner stars rotate faster (differential rotation). const angle = s.baseAngle + t * (0.32 - (s.r / maxR) * 0.2); const x = cx + Math.cos(angle) * s.r; const y = cy + Math.sin(angle) * s.r; const alpha = 0.25 + (1 - s.r / maxR) * 0.7; ctx.fillStyle = `rgba(${s.color},${alpha})`; ctx.beginPath(); ctx.arc(x, y, s.size, 0, Math.PI * 2); ctx.fill(); } ctx.globalCompositeOperation = "source-over"; }, }); return (
{children}
); } ``` ### Neon Tunnel Concentric neon frames zooming outward like a tunnel. URL: https://dev.ononc.com/backgrounds/neon-tunnel Path: src/components/backgrounds/neon-tunnel.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface NeonTunnelProps extends HTMLAttributes { /** Number of concentric layers. */ layers?: number; /** Inner and outer colors as [r,g,b] triples. */ from?: [number, number, number]; to?: [number, number, number]; children?: ReactNode; } interface State { layers: number; } export function NeonTunnel({ className, layers = 16, from = [139, 124, 255], to = [94, 234, 255], children, ...props }: NeonTunnelProps) { const ref = useCanvas({ init: () => ({ layers }), draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); const cx = width / 2; const cy = height / 2; const maxR = Math.hypot(width, height) / 2; ctx.globalCompositeOperation = "lighter"; for (let k = 0; k < state.layers; k++) { const progress = (t * 0.22 + k / state.layers) % 1; const r = progress * maxR; const fadeIn = Math.min(1, progress * 5); const fadeOut = 1 - progress; const alpha = fadeIn * fadeOut * 0.7; if (alpha <= 0.01) continue; const cr = Math.round(from[0] + (to[0] - from[0]) * progress); const cg = Math.round(from[1] + (to[1] - from[1]) * progress); const cb = Math.round(from[2] + (to[2] - from[2]) * progress); ctx.save(); ctx.translate(cx, cy); ctx.rotate(progress * 0.8 + t * 0.05); ctx.strokeStyle = `rgba(${cr},${cg},${cb},${alpha})`; ctx.lineWidth = 1 + progress * 2; ctx.strokeRect(-r, -r, r * 2, r * 2); ctx.restore(); } ctx.globalCompositeOperation = "source-over"; }, }); return (
{children}
); } ``` ### Lightning Branching bolts strike and fade with an electric glow. URL: https://dev.ononc.com/backgrounds/lightning Path: src/components/backgrounds/lightning.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface LightningProps extends HTMLAttributes { /** Bolt glow color as "r,g,b". */ color?: string; children?: ReactNode; } type Point = [number, number]; interface Bolt { segs: Point[][]; life: number; max: number; } interface State { bolts: Bolt[]; timer: number; } function jaggedLine(x1: number, y1: number, x2: number, y2: number): Point[] { let pts: Point[] = [ [x1, y1], [x2, y2], ]; let disp = Math.hypot(x2 - x1, y2 - y1) * 0.16; for (let i = 0; i < 6; i++) { const next: Point[] = []; for (let j = 0; j < pts.length - 1; j++) { const [ax, ay] = pts[j]; const [bx, by] = pts[j + 1]; next.push([ax, ay]); const dx = bx - ax; const dy = by - ay; const len = Math.hypot(dx, dy) || 1; const off = (Math.random() - 0.5) * disp; next.push([(ax + bx) / 2 + (-dy / len) * off, (ay + by) / 2 + (dx / len) * off]); } next.push(pts[pts.length - 1]); pts = next; disp *= 0.52; } return pts; } function makeBolt(w: number, h: number): Point[][] { const x1 = Math.random() * w; const main = jaggedLine(x1, -10, x1 + (Math.random() - 0.5) * w * 0.4, h + 10); const segs = [main]; const branches = 1 + Math.floor(Math.random() * 2); for (let i = 0; i < branches; i++) { const p = main[Math.floor(main.length * (0.25 + Math.random() * 0.5))]; if (!p) continue; segs.push( jaggedLine( p[0], p[1], p[0] + (Math.random() - 0.5) * w * 0.3, Math.min(h, p[1] + Math.random() * h * 0.35), ), ); } return segs; } export function Lightning({ className, color = "150,170,255", children, ...props }: LightningProps) { const ref = useCanvas({ init: () => ({ bolts: [], timer: 0.4 }), draw: ({ ctx, width, height }, state, _t, dt) => { ctx.clearRect(0, 0, width, height); state.timer -= dt; if (state.timer <= 0) { state.bolts.push({ segs: makeBolt(width, height), life: 0.45, max: 0.45 }); state.timer = 0.5 + Math.random() * 1.7; } ctx.globalCompositeOperation = "lighter"; ctx.lineCap = "round"; ctx.lineJoin = "round"; for (const bolt of state.bolts) { bolt.life -= dt; const a = Math.max(0, bolt.life / bolt.max) * (0.55 + Math.random() * 0.45); for (const seg of bolt.segs) { ctx.beginPath(); ctx.moveTo(seg[0][0], seg[0][1]); for (let i = 1; i < seg.length; i++) ctx.lineTo(seg[i][0], seg[i][1]); ctx.strokeStyle = `rgba(${color},${a * 0.18})`; ctx.lineWidth = 7; ctx.stroke(); ctx.strokeStyle = `rgba(235,240,255,${a})`; ctx.lineWidth = 1.6; ctx.stroke(); } } ctx.globalCompositeOperation = "source-over"; state.bolts = state.bolts.filter((b) => b.life > 0); }, }); return (
{children}
); } ``` ### Cells Glowing Voronoi cell edges shifting as their seeds drift. URL: https://dev.ononc.com/backgrounds/cells Path: src/components/backgrounds/cells.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface CellsProps extends HTMLAttributes { /** Number of Voronoi seeds. */ seeds?: number; colors?: string[]; children?: ReactNode; } interface Seed { x: number; y: number; vx: number; vy: number; color: string; } interface State { seeds: Seed[]; } export function Cells({ className, seeds = 11, colors = ["139,124,255", "94,234,255", "244,114,182"], children, ...props }: CellsProps) { const ref = useCanvas({ init: ({ width, height }) => ({ seeds: Array.from({ length: seeds }, () => ({ x: Math.random() * width, y: Math.random() * height, vx: (Math.random() - 0.5) * 0.5, vy: (Math.random() - 0.5) * 0.5, color: colors[Math.floor(Math.random() * colors.length)], })), }), draw: ({ ctx, width, height }, state) => { ctx.clearRect(0, 0, width, height); for (const s of state.seeds) { s.x += s.vx; s.y += s.vy; if (s.x < 0 || s.x > width) s.vx *= -1; if (s.y < 0 || s.y > height) s.vy *= -1; } const step = 8; const edge = 9; for (let y = 0; y < height; y += step) { for (let x = 0; x < width; x += step) { let m1 = Infinity; let m2 = Infinity; let nearest = state.seeds[0]; for (const s of state.seeds) { const dx = x - s.x; const dy = y - s.y; const d = dx * dx + dy * dy; if (d < m1) { m2 = m1; m1 = d; nearest = s; } else if (d < m2) { m2 = d; } } const border = Math.sqrt(m2) - Math.sqrt(m1); if (border < edge) { const a = (1 - border / edge) * 0.7; ctx.fillStyle = `rgba(${nearest.color},${a})`; ctx.fillRect(x, y, step - 1, step - 1); } } } }, }); return (
{children}
); } ``` ### Equalizer Sine-driven bars rising and falling like a visualizer. URL: https://dev.ononc.com/backgrounds/equalizer Path: src/components/backgrounds/equalizer.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface EqualizerProps extends HTMLAttributes { /** Approximate bar width (incl. gap) in pixels. */ barWidth?: number; /** Top and bottom gradient colors as "r,g,b". */ colors?: [string, string]; children?: ReactNode; } interface Bar { phase: number; freq: number; } interface State { bars: Bar[]; } export function Equalizer({ className, barWidth = 16, colors = ["94,234,255", "139,92,246"], children, ...props }: EqualizerProps) { const ref = useCanvas({ init: ({ width }) => { const count = Math.max(6, Math.floor(width / barWidth)); const bars: Bar[] = Array.from({ length: count }, () => ({ phase: Math.random() * Math.PI * 2, freq: 0.8 + Math.random() * 1.8, })); return { bars }; }, draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); const count = state.bars.length; const bw = width / count; const grad = ctx.createLinearGradient(0, 0, 0, height); grad.addColorStop(0, `rgba(${colors[0]},0.9)`); grad.addColorStop(1, `rgba(${colors[1]},0.25)`); ctx.fillStyle = grad; for (let i = 0; i < count; i++) { const b = state.bars[i]; const v = (0.5 + 0.5 * Math.sin(t * b.freq + b.phase)) * (0.55 + 0.45 * Math.sin(t * 0.5 + i * 0.3)); const bh = (0.12 + 0.8 * v) * height; const x = i * bw + bw * 0.18; const w = bw * 0.64; const y = height - bh; const r = Math.min(w / 2, 4); ctx.beginPath(); ctx.moveTo(x, height); ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y); ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r); ctx.lineTo(x + w, height); ctx.closePath(); ctx.fill(); } }, }); return (
{children}
); } ``` ### Smoke Soft fog drifting upward and slowly dissipating. URL: https://dev.ononc.com/backgrounds/smoke Path: src/components/backgrounds/smoke.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface SmokeProps extends HTMLAttributes { count?: number; /** Fog color as "r,g,b". */ color?: string; children?: ReactNode; } interface Puff { x: number; y: number; r: number; vx: number; vy: number; phase: number; alpha: number; } interface State { puffs: Puff[]; } export function Smoke({ className, count = 16, color = "150,160,190", children, ...props }: SmokeProps) { const ref = useCanvas({ init: ({ width, height }) => { const puffs: Puff[] = Array.from({ length: count }, () => ({ x: Math.random() * width, y: Math.random() * height, r: 70 + Math.random() * 120, vx: (Math.random() - 0.5) * 0.25, vy: -(0.15 + Math.random() * 0.4), phase: Math.random() * Math.PI * 2, alpha: 0.03 + Math.random() * 0.06, })); return { puffs }; }, draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); ctx.globalCompositeOperation = "lighter"; for (const p of state.puffs) { p.x += p.vx + Math.sin(t * 0.3 + p.phase) * 0.3; p.y += p.vy; if (p.y < -p.r) { p.y = height + p.r; p.x = Math.random() * width; } const r = p.r * (0.9 + 0.1 * Math.sin(t * 0.5 + p.phase)); const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r); glow.addColorStop(0, `rgba(${color},${p.alpha})`); glow.addColorStop(1, `rgba(${color},0)`); ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2); ctx.fill(); } ctx.globalCompositeOperation = "source-over"; }, }); return (
{children}
); } ``` ### Mesh Wave A tilted wireframe surface undulating in 3D. URL: https://dev.ononc.com/backgrounds/mesh-wave Path: src/components/backgrounds/mesh-wave.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface MeshWaveProps extends HTMLAttributes { /** Grid columns / rows. */ cols?: number; rows?: number; /** Line color as "r,g,b". */ color?: string; children?: ReactNode; } interface State { cols: number; rows: number; } export function MeshWave({ className, cols = 26, rows = 18, color = "139,124,255", children, ...props }: MeshWaveProps) { const ref = useCanvas({ init: () => ({ cols, rows }), draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); const { cols: C, rows: R } = state; const tilt = 1.05; const cosT = Math.cos(tilt); const sinT = Math.sin(tilt); const cx = width / 2; const cy = height * 0.46; const spread = Math.min(width, height) * 1.5; const fov = 3; // Project a grid point (gx,gy in [-1,1]) to screen. const project = (gx: number, gy: number) => { const z = Math.sin(gx * 3 + t) * 0.18 + Math.sin(gy * 3.4 - t * 0.8) * 0.18; const ry = gy * cosT - z * sinT; const rz = gy * sinT + z * cosT; const scale = fov / (fov + rz); return { x: cx + gx * scale * spread, y: cy + ry * scale * spread, z: rz, }; }; for (let r = 0; r < R; r++) { for (let c = 0; c < C; c++) { const gx = (c / (C - 1)) * 2 - 1; const gy = (r / (R - 1)) * 2 - 1; const p = project(gx, gy); const alpha = Math.max(0.05, 0.5 - p.z * 0.4); ctx.strokeStyle = `rgba(${color},${alpha})`; ctx.lineWidth = 1; if (c < C - 1) { const pr = project(((c + 1) / (C - 1)) * 2 - 1, gy); ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(pr.x, pr.y); ctx.stroke(); } if (r < R - 1) { const pd = project(gx, ((r + 1) / (R - 1)) * 2 - 1); ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(pd.x, pd.y); ctx.stroke(); } } } }, }); return (
{children}
); } ``` ### Caustics Rippling webs of light, like sun through water. URL: https://dev.ononc.com/backgrounds/caustics Path: src/components/backgrounds/caustics.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface CausticsProps extends HTMLAttributes { /** Grid step in pixels (smaller = finer, heavier). */ step?: number; /** Light color as "r,g,b". */ color?: string; children?: ReactNode; } interface State { step: number; } export function Caustics({ className, step = 7, color = "120,200,255", children, ...props }: CausticsProps) { const ref = useCanvas({ init: () => ({ step }), draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); const s = state.step; const cx = width / 2; const cy = height / 2; ctx.globalCompositeOperation = "lighter"; for (let y = 0; y < height; y += s) { for (let x = 0; x < width; x += s) { const v = Math.sin(x * 0.03 + t) + Math.sin(y * 0.03 - t * 0.8) + Math.sin((x + y) * 0.02 + t * 0.5) + Math.sin(Math.hypot(x - cx, y - cy) * 0.04 - t * 1.2); const n = (v + 4) / 8; // 0..1 const b = n * n * n * n; // sharpen into veins if (b < 0.02) continue; ctx.fillStyle = `rgba(${color},${Math.min(0.8, b)})`; ctx.fillRect(x, y, s, s); } } ctx.globalCompositeOperation = "source-over"; }, }); return (
{children}
); } ``` ### Magnetic Field Dipole field lines streaming between two orbiting poles. URL: https://dev.ononc.com/backgrounds/magnetic-field Path: src/components/backgrounds/magnetic-field.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface MagneticFieldProps extends HTMLAttributes { /** Number of field lines traced from the + pole. */ lines?: number; /** Field-line color as "r,g,b". */ color?: string; children?: ReactNode; } interface State { lines: number; } export function MagneticField({ className, lines = 18, color = "139,160,255", children, ...props }: MagneticFieldProps) { const ref = useCanvas({ init: () => ({ lines }), draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); const sep = Math.min(width, height) * 0.22; const poles = [ { x: width / 2 + Math.cos(t * 0.3) * sep, y: height / 2 + Math.sin(t * 0.3) * sep, q: 1 }, { x: width / 2 - Math.cos(t * 0.3) * sep, y: height / 2 - Math.sin(t * 0.3) * sep, q: -1 }, ]; const field = (px: number, py: number) => { let fx = 0; let fy = 0; for (const p of poles) { const dx = px - p.x; const dy = py - p.y; const r2 = dx * dx + dy * dy + 1; const inv = p.q / (r2 * Math.sqrt(r2)); fx += dx * inv; fy += dy * inv; } return [fx, fy] as const; }; ctx.lineWidth = 1.2; const plus = poles[0]; const minus = poles[1]; for (let i = 0; i < state.lines; i++) { const a = (i / state.lines) * Math.PI * 2; let x = plus.x + Math.cos(a) * 16; let y = plus.y + Math.sin(a) * 16; ctx.beginPath(); ctx.moveTo(x, y); for (let step = 0; step < 200; step++) { const [fx, fy] = field(x, y); const mag = Math.hypot(fx, fy) || 1; x += (fx / mag) * 4; y += (fy / mag) * 4; ctx.lineTo(x, y); if (Math.hypot(x - minus.x, y - minus.y) < 14) break; if (x < -20 || x > width + 20 || y < -20 || y > height + 20) break; } ctx.strokeStyle = `rgba(${color},0.3)`; ctx.stroke(); } // Pole glows. for (const p of poles) { const c = p.q > 0 ? "120,200,255" : "244,114,182"; const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, 24); g.addColorStop(0, `rgba(${c},0.9)`); g.addColorStop(1, `rgba(${c},0)`); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(p.x, p.y, 24, 0, Math.PI * 2); ctx.fill(); } }, }); return (
{children}
); } ``` ### Triangles A low-poly mesh shimmering with a moving wave. URL: https://dev.ononc.com/backgrounds/triangles Path: src/components/backgrounds/triangles.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface TrianglesProps extends HTMLAttributes { /** Cell size in pixels. */ size?: number; colors?: string[]; children?: ReactNode; } interface Tri { pts: [number, number][]; cx: number; cy: number; color: string; } interface State { tris: Tri[]; } export function Triangles({ className, size = 64, colors = ["139,124,255", "94,234,255", "244,114,182"], children, ...props }: TrianglesProps) { const ref = useCanvas({ init: ({ width, height }) => { const cols = Math.ceil(width / size) + 1; const rows = Math.ceil(height / size) + 1; const jit = size * 0.32; const verts: [number, number][][] = []; for (let r = 0; r < rows; r++) { verts[r] = []; for (let c = 0; c < cols; c++) { verts[r][c] = [ c * size + (Math.random() - 0.5) * jit, r * size + (Math.random() - 0.5) * jit, ]; } } const tris: Tri[] = []; const push = (a: [number, number], b: [number, number], c: [number, number]) => { tris.push({ pts: [a, b, c], cx: (a[0] + b[0] + c[0]) / 3, cy: (a[1] + b[1] + c[1]) / 3, color: colors[Math.floor(Math.random() * colors.length)], }); }; for (let r = 0; r < rows - 1; r++) { for (let c = 0; c < cols - 1; c++) { push(verts[r][c], verts[r][c + 1], verts[r + 1][c]); push(verts[r][c + 1], verts[r + 1][c + 1], verts[r + 1][c]); } } return { tris }; }, draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); for (const tri of state.tris) { const b = (Math.sin(tri.cx * 0.01 + t) + Math.sin(tri.cy * 0.012 - t * 0.8) + Math.sin((tri.cx + tri.cy) * 0.008 + t * 0.5)) / 3; const n = b * 0.5 + 0.5; ctx.fillStyle = `rgba(${tri.color},${0.04 + n * 0.26})`; ctx.beginPath(); ctx.moveTo(tri.pts[0][0], tri.pts[0][1]); ctx.lineTo(tri.pts[1][0], tri.pts[1][1]); ctx.lineTo(tri.pts[2][0], tri.pts[2][1]); ctx.closePath(); ctx.fill(); ctx.strokeStyle = `rgba(${tri.color},0.07)`; ctx.lineWidth = 1; ctx.stroke(); } }, }); return (
{children}
); } ``` ### Sparkles Twinkling points with a soft cross-flare glint. URL: https://dev.ononc.com/backgrounds/sparkles Path: src/components/backgrounds/sparkles.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface SparklesProps extends HTMLAttributes { count?: number; colors?: string[]; children?: ReactNode; } interface Sparkle { x: number; y: number; size: number; phase: number; speed: number; color: string; } interface State { sparkles: Sparkle[]; } export function Sparkles({ className, count = 70, colors = ["255,255,255", "196,181,253", "150,234,255"], children, ...props }: SparklesProps) { const ref = useCanvas({ init: ({ width, height }) => ({ sparkles: Array.from({ length: count }, () => ({ x: Math.random() * width, y: Math.random() * height, size: 1.4 + Math.random() * 2.6, phase: Math.random() * Math.PI * 2, speed: 1 + Math.random() * 2.2, color: colors[Math.floor(Math.random() * colors.length)], })), }), draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); ctx.globalCompositeOperation = "lighter"; for (const s of state.sparkles) { const tw = Math.sin(t * s.speed + s.phase); if (tw <= 0) continue; const a = tw; const len = s.size * (3 + tw * 3); // Center glow. const glow = ctx.createRadialGradient(s.x, s.y, 0, s.x, s.y, s.size * 2); glow.addColorStop(0, `rgba(${s.color},${a})`); glow.addColorStop(1, `rgba(${s.color},0)`); ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(s.x, s.y, s.size * 2, 0, Math.PI * 2); ctx.fill(); // Cross flare. ctx.lineWidth = 1; const gh = ctx.createLinearGradient(s.x - len, s.y, s.x + len, s.y); gh.addColorStop(0, `rgba(${s.color},0)`); gh.addColorStop(0.5, `rgba(${s.color},${a})`); gh.addColorStop(1, `rgba(${s.color},0)`); ctx.strokeStyle = gh; ctx.beginPath(); ctx.moveTo(s.x - len, s.y); ctx.lineTo(s.x + len, s.y); ctx.stroke(); const gv = ctx.createLinearGradient(s.x, s.y - len, s.x, s.y + len); gv.addColorStop(0, `rgba(${s.color},0)`); gv.addColorStop(0.5, `rgba(${s.color},${a})`); gv.addColorStop(1, `rgba(${s.color},0)`); ctx.strokeStyle = gv; ctx.beginPath(); ctx.moveTo(s.x, s.y - len); ctx.lineTo(s.x, s.y + len); ctx.stroke(); } ctx.globalCompositeOperation = "source-over"; }, }); return (
{children}
); } ``` ### Confetti Colorful confetti fluttering down. URL: https://dev.ononc.com/backgrounds/confetti Path: src/components/backgrounds/confetti.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface ConfettiProps extends HTMLAttributes { count?: number; colors?: string[]; children?: ReactNode; } interface Piece { x: number; y: number; vy: number; vx: number; rot: number; vrot: number; w: number; h: number; seed: number; color: string; } interface State { pieces: Piece[]; } export function Confetti({ className, count = 130, colors = ["139,124,255", "94,234,255", "244,114,182", "250,204,21", "52,211,153"], children, ...props }: ConfettiProps) { const ref = useCanvas({ init: ({ width, height }) => { const make = (): Piece => ({ x: Math.random() * width, y: Math.random() * height, vy: 1 + Math.random() * 2.4, vx: (Math.random() - 0.5) * 0.6, rot: Math.random() * Math.PI, vrot: (Math.random() - 0.5) * 0.18, w: 4 + Math.random() * 5, h: 7 + Math.random() * 7, seed: Math.random() * 1000, color: colors[Math.floor(Math.random() * colors.length)], }); return { pieces: Array.from({ length: count }, make) }; }, draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); for (const p of state.pieces) { p.y += p.vy; p.x += p.vx + Math.sin(t * 2 + p.seed) * 0.7; p.rot += p.vrot; if (p.y > height + 14) { p.y = -14; p.x = Math.random() * width; } // Flip width by rotation for a fluttering look. const sw = p.w * Math.abs(Math.cos(p.rot)); ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot); ctx.fillStyle = `rgba(${p.color},0.92)`; ctx.fillRect(-sw / 2, -p.h / 2, Math.max(1, sw), p.h); ctx.restore(); } }, }); return (
{children}
); } ``` ### Kaleidoscope Orbiting petals mirrored into shifting radial symmetry. URL: https://dev.ononc.com/backgrounds/kaleidoscope Path: src/components/backgrounds/kaleidoscope.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface KaleidoscopeProps extends HTMLAttributes { /** Number of mirrored segments. */ segments?: number; colors?: string[]; children?: ReactNode; } interface Petal { baseDist: number; baseAng: number; r: number; s1: number; s2: number; color: string; } interface State { petals: Petal[]; } export function Kaleidoscope({ className, segments = 10, colors = ["139,124,255", "94,234,255", "244,114,182"], children, ...props }: KaleidoscopeProps) { const ref = useCanvas({ init: ({ width, height }) => { const maxR = Math.min(width, height) * 0.5; const petals: Petal[] = Array.from({ length: 5 }, () => ({ baseDist: maxR * (0.2 + Math.random() * 0.7), baseAng: Math.random() * (Math.PI / segments), r: 10 + Math.random() * 26, s1: 0.4 + Math.random() * 0.9, s2: 0.3 + Math.random() * 0.8, color: colors[Math.floor(Math.random() * colors.length)], })); return { petals }; }, draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); const cx = width / 2; const cy = height / 2; const wedge = (Math.PI * 2) / segments; ctx.globalCompositeOperation = "lighter"; for (let seg = 0; seg < segments; seg++) { for (const mirror of [1, -1]) { ctx.save(); ctx.translate(cx, cy); ctx.rotate(seg * wedge); ctx.scale(1, mirror); for (const p of state.petals) { const ang = p.baseAng + Math.sin(t * p.s1) * 0.18; const dist = p.baseDist + Math.sin(t * p.s2) * 24; const x = Math.cos(ang) * dist; const y = Math.sin(ang) * dist; const glow = ctx.createRadialGradient(x, y, 0, x, y, p.r); glow.addColorStop(0, `rgba(${p.color},0.5)`); glow.addColorStop(1, `rgba(${p.color},0)`); ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(x, y, p.r, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); } } ctx.globalCompositeOperation = "source-over"; }, }); return (
{children}
); } ``` ### Tron Trails Heads race a grid, turning at right angles, leaving light trails. URL: https://dev.ononc.com/backgrounds/tron-trails Path: src/components/backgrounds/tron-trails.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface TronTrailsProps extends HTMLAttributes { /** Number of moving heads. */ heads?: number; /** Grid cell size in pixels. */ cell?: number; colors?: string[]; children?: ReactNode; } interface Head { x: number; y: number; dir: number; since: number; color: string; } interface State { heads: Head[]; cell: number; } const DIRS = [ [1, 0], [0, 1], [-1, 0], [0, -1], ]; export function TronTrails({ className, heads = 7, cell = 22, colors = ["94,234,255", "139,124,255", "244,114,182"], children, ...props }: TronTrailsProps) { const ref = useCanvas({ init: ({ width, height }) => { const make = (): Head => ({ x: Math.floor((Math.random() * width) / cell) * cell, y: Math.floor((Math.random() * height) / cell) * cell, dir: Math.floor(Math.random() * 4), since: 0, color: colors[Math.floor(Math.random() * colors.length)], }); return { heads: Array.from({ length: heads }, make), cell }; }, draw: ({ ctx, width, height }, state, _t, dt) => { // Fade prior frame so trails linger then die. ctx.fillStyle = "rgba(6,7,13,0.06)"; ctx.fillRect(0, 0, width, height); const speed = 90; const move = Math.min(state.cell, speed * dt); ctx.globalCompositeOperation = "lighter"; for (const h of state.heads) { const [dx, dy] = DIRS[h.dir]; h.x += dx * move; h.y += dy * move; h.since += move; if (h.since >= state.cell) { h.since = 0; h.x = Math.round(h.x / state.cell) * state.cell; h.y = Math.round(h.y / state.cell) * state.cell; if (Math.random() < 0.4) { h.dir = (h.dir + (Math.random() < 0.5 ? 1 : 3)) % 4; } } if (h.x < -4) h.x = width; if (h.x > width + 4) h.x = 0; if (h.y < -4) h.y = height; if (h.y > height + 4) h.y = 0; ctx.fillStyle = `rgba(${h.color},0.9)`; ctx.fillRect(h.x - 1.5, h.y - 1.5, 3, 3); } ctx.globalCompositeOperation = "source-over"; }, }); return (
{children}
); } ``` ### God Rays Volumetric light shafts shimmering from a drifting source. URL: https://dev.ononc.com/backgrounds/god-rays Path: src/components/backgrounds/god-rays.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface GodRaysProps extends HTMLAttributes { /** Number of light shafts. */ rays?: number; /** Ray color as "r,g,b". */ color?: string; children?: ReactNode; } interface State { rays: number; } export function GodRays({ className, rays = 26, color = "150,200,255", children, ...props }: GodRaysProps) { const ref = useCanvas({ init: () => ({ rays }), draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); const sx = width * (0.5 + 0.32 * Math.sin(t * 0.15)); const sy = height * 0.18; const maxLen = Math.hypot(width, height) * 1.1; ctx.globalCompositeOperation = "lighter"; const half = (Math.PI * 2) / state.rays / 2.4; for (let k = 0; k < state.rays; k++) { const a = t * 0.05 + (k * (Math.PI * 2)) / state.rays; const intensity = 0.5 + 0.5 * Math.sin(t * 1.4 + k * 1.3); const ex = sx + Math.cos(a) * maxLen; const ey = sy + Math.sin(a) * maxLen; const grad = ctx.createLinearGradient(sx, sy, ex, ey); grad.addColorStop(0, `rgba(${color},${0.12 * intensity})`); grad.addColorStop(1, `rgba(${color},0)`); ctx.fillStyle = grad; ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(sx + Math.cos(a - half) * maxLen, sy + Math.sin(a - half) * maxLen); ctx.lineTo(sx + Math.cos(a + half) * maxLen, sy + Math.sin(a + half) * maxLen); ctx.closePath(); ctx.fill(); } const glow = ctx.createRadialGradient(sx, sy, 0, sx, sy, 90); glow.addColorStop(0, `rgba(${color},0.5)`); glow.addColorStop(1, `rgba(${color},0)`); ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(sx, sy, 90, 0, Math.PI * 2); ctx.fill(); ctx.globalCompositeOperation = "source-over"; }, }); return (
{children}
); } ``` ### Oscilloscope Glowing waveform lines sweeping across a scope grid. URL: https://dev.ononc.com/backgrounds/oscilloscope Path: src/components/backgrounds/oscilloscope.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface OscilloscopeProps extends HTMLAttributes { /** Waveform colors as "r,g,b" (one line each). */ colors?: string[]; children?: ReactNode; } interface State { empty: true; } export function Oscilloscope({ className, colors = ["94,234,255", "139,124,255"], children, ...props }: OscilloscopeProps) { const ref = useCanvas({ init: () => ({ empty: true }), draw: ({ ctx, width, height }, _state, t) => { ctx.clearRect(0, 0, width, height); const cy = height / 2; // Faint scope grid. ctx.strokeStyle = "rgba(120,200,255,0.07)"; ctx.lineWidth = 1; ctx.beginPath(); for (let x = 0; x <= width; x += 32) { ctx.moveTo(x, 0); ctx.lineTo(x, height); } for (let y = 0; y <= height; y += 32) { ctx.moveTo(0, y); ctx.lineTo(width, y); } ctx.stroke(); ctx.globalCompositeOperation = "lighter"; ctx.lineCap = "round"; colors.forEach((color, idx) => { const amp = height * (0.26 - idx * 0.07); const speed = 2 + idx * 1.3; const draw = (lw: number, alpha: number) => { ctx.strokeStyle = `rgba(${color},${alpha})`; ctx.lineWidth = lw; ctx.beginPath(); for (let x = 0; x <= width; x += 4) { const y = cy + (Math.sin(x * 0.02 + t * speed) * 0.6 + Math.sin(x * 0.05 - t * (speed + 1)) * 0.3 + Math.sin(x * 0.011 + t) * 0.2) * amp + idx * 18; if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.stroke(); }; draw(6, 0.12); draw(1.6, 0.9); }); ctx.globalCompositeOperation = "source-over"; }, }); return (
{children}
); } ``` ### Pinwheel Rotating conic color sectors like a spinning pinwheel. URL: https://dev.ononc.com/backgrounds/pinwheel Path: src/components/backgrounds/pinwheel.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface PinwheelProps extends HTMLAttributes { /** Number of sectors (blades). */ sectors?: number; colors?: string[]; children?: ReactNode; } interface State { sectors: number; } export function Pinwheel({ className, sectors = 14, colors = ["139,92,246", "34,211,238", "244,114,182", "79,70,229"], children, ...props }: PinwheelProps) { const ref = useCanvas({ init: () => ({ sectors }), draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); const cx = width / 2; const cy = height / 2; const maxR = Math.hypot(width, height); const step = (Math.PI * 2) / state.sectors; const rot = t * 0.3; for (let k = 0; k < state.sectors; k++) { const a0 = rot + k * step; const color = colors[k % colors.length]; const alpha = k % 2 === 0 ? 0.3 : 0.12; ctx.fillStyle = `rgba(${color},${alpha})`; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, maxR, a0, a0 + step); ctx.closePath(); ctx.fill(); } // Edge vignette + center glow. const vg = ctx.createRadialGradient(cx, cy, 0, cx, cy, Math.max(width, height) * 0.6); vg.addColorStop(0, "rgba(6,7,13,0.1)"); vg.addColorStop(0.55, "rgba(6,7,13,0)"); vg.addColorStop(1, "rgba(6,7,13,0.65)"); ctx.fillStyle = vg; ctx.fillRect(0, 0, width, height); const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, 60); core.addColorStop(0, "rgba(255,255,255,0.18)"); core.addColorStop(1, "rgba(255,255,255,0)"); ctx.fillStyle = core; ctx.beginPath(); ctx.arc(cx, cy, 60, 0, Math.PI * 2); ctx.fill(); }, }); return (
{children}
); } ``` ### Bubbles Wobbling bubbles drifting up with a glassy highlight. URL: https://dev.ononc.com/backgrounds/bubbles Path: src/components/backgrounds/bubbles.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface BubblesProps extends HTMLAttributes { count?: number; /** Bubble color as "r,g,b". */ color?: string; children?: ReactNode; } interface Bubble { x: number; y: number; r: number; vy: number; wob: number; phase: number; } interface State { bubbles: Bubble[]; } export function Bubbles({ className, count = 28, color = "150,220,255", children, ...props }: BubblesProps) { const ref = useCanvas({ init: ({ width, height }) => { const make = (seeded: boolean): Bubble => { const r = 6 + Math.random() * 26; return { x: Math.random() * width, y: seeded ? Math.random() * height : height + r + Math.random() * 40, r, vy: 0.4 + (30 - r) * 0.03 + Math.random() * 0.5, wob: 0.4 + Math.random() * 1.1, phase: Math.random() * Math.PI * 2, }; }; return { bubbles: Array.from({ length: count }, () => make(true)) }; }, draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); for (const b of state.bubbles) { b.y -= b.vy; b.x += Math.sin(t * 1.2 + b.phase) * b.wob; if (b.y < -b.r - 4) { b.y = height + b.r + 4; b.x = Math.random() * width; } const fill = ctx.createRadialGradient( b.x - b.r * 0.3, b.y - b.r * 0.3, 0, b.x, b.y, b.r, ); fill.addColorStop(0, `rgba(${color},0.10)`); fill.addColorStop(1, `rgba(${color},0.02)`); ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = `rgba(${color},0.45)`; ctx.lineWidth = 1; ctx.stroke(); // Specular highlight. ctx.strokeStyle = "rgba(255,255,255,0.5)"; ctx.lineWidth = 1.4; ctx.beginPath(); ctx.arc(b.x - b.r * 0.35, b.y - b.r * 0.35, b.r * 0.28, Math.PI * 1.1, Math.PI * 1.7); ctx.stroke(); } }, }); return (
{children}
); } ``` ### Plexus A rotating 3D node network connected by depth-faded lines. URL: https://dev.ononc.com/backgrounds/plexus Path: src/components/backgrounds/plexus.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface PlexusProps extends HTMLAttributes { /** Approx. nodes per 100k px² (capped). */ density?: number; /** Node + line color as "r,g,b". */ color?: string; children?: ReactNode; } interface Node { x: number; y: number; z: number; vx: number; vy: number; vz: number; } interface State { nodes: Node[]; } export function Plexus({ className, density = 5, color = "139,160,255", children, ...props }: PlexusProps) { const ref = useCanvas({ init: ({ width, height }) => { const count = Math.max( 24, Math.min(80, Math.round(((width * height) / 100000) * density * 6)), ); const rnd = () => Math.random() * 2 - 1; const nodes: Node[] = Array.from({ length: count }, () => ({ x: rnd(), y: rnd(), z: rnd(), vx: rnd() * 0.0016, vy: rnd() * 0.0016, vz: rnd() * 0.0016, })); return { nodes }; }, draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); const cx = width / 2; const cy = height / 2; const spread = Math.min(width, height) * 0.62; const fov = 2.4; const cosA = Math.cos(t * 0.08); const sinA = Math.sin(t * 0.08); const proj = state.nodes.map((n) => { n.x += n.vx; n.y += n.vy; n.z += n.vz; if (n.x < -1 || n.x > 1) n.vx *= -1; if (n.y < -1 || n.y > 1) n.vy *= -1; if (n.z < -1 || n.z > 1) n.vz *= -1; const xr = n.x * cosA - n.z * sinA; const zr = n.x * sinA + n.z * cosA; const scale = fov / (fov + zr); return { sx: cx + xr * scale * spread, sy: cy + n.y * scale * spread, scale, depth: (zr + 1) / 2, }; }); const thresh = 0.62; ctx.lineWidth = 1; for (let i = 0; i < state.nodes.length; i++) { for (let j = i + 1; j < state.nodes.length; j++) { const a = state.nodes[i]; const b = state.nodes[j]; const dx = a.x - b.x; const dy = a.y - b.y; const dz = a.z - b.z; const d = Math.sqrt(dx * dx + dy * dy + dz * dz); if (d < thresh) { const alpha = (1 - d / thresh) * 0.4 * ((proj[i].depth + proj[j].depth) / 2 + 0.3); ctx.strokeStyle = `rgba(${color},${alpha})`; ctx.beginPath(); ctx.moveTo(proj[i].sx, proj[i].sy); ctx.lineTo(proj[j].sx, proj[j].sy); ctx.stroke(); } } } for (const p of proj) { ctx.fillStyle = `rgba(${color},${0.4 + p.depth * 0.55})`; ctx.beginPath(); ctx.arc(p.sx, p.sy, 1 + p.scale * 1.6, 0, Math.PI * 2); ctx.fill(); } }, }); return (
{children}
); } ``` ### Liquid Blob Organic blobs morphing and merging like liquid. URL: https://dev.ononc.com/backgrounds/liquid-blob Path: src/components/backgrounds/liquid-blob.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface LiquidBlobProps extends HTMLAttributes { /** The two blob colors as "r,g,b". */ colors?: [string, string]; children?: ReactNode; } interface State { empty: true; } export function LiquidBlob({ className, colors = ["139,92,246", "34,211,238"], children, ...props }: LiquidBlobProps) { const ref = useCanvas({ init: () => ({ empty: true }), draw: ({ ctx, width, height }, _state, t) => { ctx.clearRect(0, 0, width, height); ctx.globalCompositeOperation = "lighter"; const base = Math.min(width, height) * 0.3; const blob = ( cx: number, cy: number, baseR: number, phase: number, color: string, ) => { ctx.beginPath(); for (let a = 0; a <= Math.PI * 2 + 0.001; a += 0.12) { const r = baseR * (1 + 0.18 * Math.sin(a * 3 + t + phase) + 0.12 * Math.sin(a * 5 - t * 1.3 + phase) + 0.07 * Math.sin(a * 7 + t * 0.7 + phase)); const x = cx + Math.cos(a) * r; const y = cy + Math.sin(a) * r; if (a === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.closePath(); const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, baseR * 1.3); grad.addColorStop(0, `rgba(${color},0.5)`); grad.addColorStop(1, `rgba(${color},0.04)`); ctx.fillStyle = grad; ctx.fill(); }; blob( width / 2 + Math.sin(t * 0.4) * width * 0.08, height / 2 + Math.cos(t * 0.5) * height * 0.08, base * 1.05, 2.5, colors[1], ); blob( width / 2 - Math.sin(t * 0.35) * width * 0.06, height / 2 - Math.cos(t * 0.45) * height * 0.06, base, 0, colors[0], ); ctx.globalCompositeOperation = "source-over"; }, }); return (
{children}
); } ``` ### Perlin Clouds Soft fractal clouds drifting and dissolving. URL: https://dev.ononc.com/backgrounds/perlin-clouds Path: src/components/backgrounds/perlin-clouds.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface PerlinCloudsProps extends HTMLAttributes { /** Grid step in pixels. */ step?: number; /** Cloud color as "r,g,b". */ color?: string; children?: ReactNode; } interface State { step: number; } export function PerlinClouds({ className, step = 10, color = "150,170,220", children, ...props }: PerlinCloudsProps) { const ref = useCanvas({ init: () => ({ step }), draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); const s = state.step; for (let y = 0; y < height; y += s) { for (let x = 0; x < width; x += s) { // Fractal sum of sine octaves ~ value noise. let n = 0.5 * (0.5 + 0.5 * Math.sin(x * 0.006 + y * 0.004 + t * 0.2)); n += 0.25 * (0.5 + 0.5 * Math.sin(x * 0.013 - y * 0.009 - t * 0.3 + 1.3)); n += 0.15 * (0.5 + 0.5 * Math.sin(x * 0.027 + y * 0.021 + t * 0.15 + 2.1)); n += 0.1 * (0.5 + 0.5 * Math.sin((x + y) * 0.04 - t * 0.4)); const b = Math.pow(n, 1.7); if (b < 0.04) continue; ctx.fillStyle = `rgba(${color},${b * 0.5})`; ctx.fillRect(x, y, s, s); } } }, }); return (
{children}
); } ``` ### Ink Drops Ink blots blooming and fading on staggered timers. URL: https://dev.ononc.com/backgrounds/ink-drops Path: src/components/backgrounds/ink-drops.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface InkDropsProps extends HTMLAttributes { count?: number; colors?: string[]; children?: ReactNode; } interface Drop { x: number; y: number; maxR: number; life: number; max: number; delay: number; color: string; } interface State { drops: Drop[]; } export function InkDrops({ className, count = 14, colors = ["139,124,255", "94,234,255", "244,114,182"], children, ...props }: InkDropsProps) { const ref = useCanvas({ init: ({ width, height }) => { const make = (): Drop => ({ x: Math.random() * width, y: Math.random() * height, maxR: 50 + Math.random() * 120, life: 0, max: 2.5 + Math.random() * 3, delay: Math.random() * 4, color: colors[Math.floor(Math.random() * colors.length)], }); return { drops: Array.from({ length: count }, make) }; }, draw: ({ ctx, width, height }, state, _t, dt) => { ctx.clearRect(0, 0, width, height); ctx.globalCompositeOperation = "lighter"; for (const d of state.drops) { if (d.delay > 0) { d.delay -= dt; continue; } d.life += dt; const p = d.life / d.max; if (p >= 1) { d.x = Math.random() * width; d.y = Math.random() * height; d.maxR = 50 + Math.random() * 120; d.life = 0; d.max = 2.5 + Math.random() * 3; d.delay = Math.random() * 1.5; d.color = colors[Math.floor(Math.random() * colors.length)]; continue; } const r = d.maxR * (1 - (1 - p) * (1 - p)); const alpha = Math.sin(p * Math.PI) * 0.45; const glow = ctx.createRadialGradient(d.x, d.y, 0, d.x, d.y, r); glow.addColorStop(0, `rgba(${d.color},${alpha})`); glow.addColorStop(0.6, `rgba(${d.color},${alpha * 0.4})`); glow.addColorStop(1, `rgba(${d.color},0)`); ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(d.x, d.y, r, 0, Math.PI * 2); ctx.fill(); } ctx.globalCompositeOperation = "source-over"; }, }); return (
{children}
); } ``` ### Data Stream Lanes of bright dashes streaming downward. URL: https://dev.ononc.com/backgrounds/data-stream Path: src/components/backgrounds/data-stream.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface DataStreamProps extends HTMLAttributes { /** Lane spacing in pixels. */ gap?: number; colors?: string[]; children?: ReactNode; } interface Lane { x: number; speed: number; offset: number; tail: number; color: string; } interface State { lanes: Lane[]; } export function DataStream({ className, gap = 18, colors = ["94,234,255", "139,124,255", "120,255,180"], children, ...props }: DataStreamProps) { const ref = useCanvas({ init: ({ width }) => { const cols = Math.ceil(width / gap); const lanes: Lane[] = Array.from({ length: cols }, (_, i) => ({ x: i * gap + gap / 2, speed: 60 + Math.random() * 220, offset: Math.random() * 1000, tail: 6 + Math.floor(Math.random() * 12), color: colors[Math.floor(Math.random() * colors.length)], })); return { lanes }; }, draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); ctx.globalCompositeOperation = "lighter"; const dash = 9; const span = height + 200; for (const lane of state.lanes) { const head = ((t * lane.speed + lane.offset) % span) - 100; for (let k = 0; k < lane.tail; k++) { const y = head - k * dash; if (y < -dash || y > height) continue; const alpha = (1 - k / lane.tail) * 0.85; ctx.fillStyle = `rgba(${lane.color},${alpha})`; ctx.fillRect(lane.x - 1, y, 2, dash - 3); } } ctx.globalCompositeOperation = "source-over"; }, }); return (
{children}
); } ``` ### Scanlines CRT scanlines with drifting chromatic glitch bands. URL: https://dev.ononc.com/backgrounds/scanlines Path: src/components/backgrounds/scanlines.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface ScanlinesProps extends HTMLAttributes { /** Number of glitch bands. */ bands?: number; children?: ReactNode; } interface Band { y: number; h: number; speed: number; amp: number; seed: number; color: string; } interface State { bands: Band[]; } export function Scanlines({ className, bands = 5, children, ...props }: ScanlinesProps) { const ref = useCanvas({ init: ({ width, height }) => { const palette = ["94,234,255", "244,114,182", "139,124,255"]; return { bands: Array.from({ length: bands }, () => ({ y: Math.random() * height, h: 6 + Math.random() * 30, speed: 4 + Math.random() * 16, amp: 6 + Math.random() * (width * 0.04), seed: Math.random() * 100, color: palette[Math.floor(Math.random() * palette.length)], })), }; }, draw: ({ ctx, width, height }, state, t, dt) => { ctx.clearRect(0, 0, width, height); ctx.globalCompositeOperation = "lighter"; // Chromatic glitch bands drifting downward with horizontal jitter. for (const b of state.bands) { b.y += b.speed * dt * 6; if (b.y > height + b.h) b.y = -b.h; const jump = Math.sin(t * 13 + b.seed) > 0.93 ? b.amp * 2 : 0; const ox = Math.sin(t * 2 + b.seed) * b.amp + jump; ctx.fillStyle = `rgba(${b.color},0.12)`; ctx.fillRect(ox, b.y, width, b.h); ctx.fillStyle = `rgba(${b.color},0.08)`; ctx.fillRect(-ox * 0.6, b.y, width, b.h); } ctx.globalCompositeOperation = "source-over"; // Scrolling scanlines. const off = (t * 30) % 4; ctx.fillStyle = "rgba(200,220,255,0.035)"; for (let y = off; y < height; y += 4) { ctx.fillRect(0, y, width, 1); } }, }); return (
{children}
); } ``` ### Boids A flocking swarm steering by separation, alignment, and cohesion. URL: https://dev.ononc.com/backgrounds/boids Path: src/components/backgrounds/boids.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface BoidsProps extends HTMLAttributes { count?: number; color?: string; children?: ReactNode; } interface Boid { x: number; y: number; vx: number; vy: number; } interface State { boids: Boid[]; } export function Boids({ className, count = 60, color = "150,180,255", children, ...props }: BoidsProps) { const ref = useCanvas({ init: ({ width, height }) => { const boids: Boid[] = Array.from({ length: count }, () => { const a = Math.random() * Math.PI * 2; const sp = 60 + Math.random() * 40; return { x: Math.random() * width, y: Math.random() * height, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, }; }); return { boids }; }, draw: ({ ctx, width, height }, state, _t, dt) => { ctx.clearRect(0, 0, width, height); const boids = state.boids; const R = 56; const sepR = 24; const maxSpeed = 110; const minSpeed = 50; for (const b of boids) { let ax = 0; let ay = 0; let cx = 0; let cy = 0; let sx = 0; let sy = 0; let n = 0; for (const o of boids) { if (o === b) continue; const dx = o.x - b.x; const dy = o.y - b.y; const d2 = dx * dx + dy * dy; if (d2 < R * R) { ax += o.vx; ay += o.vy; cx += o.x; cy += o.y; n++; if (d2 < sepR * sepR && d2 > 0.01) { sx -= dx / d2; sy -= dy / d2; } } } if (n > 0) { b.vx += (ax / n - b.vx) * 0.04; b.vy += (ay / n - b.vy) * 0.04; b.vx += (cx / n - b.x) * 0.0008; b.vy += (cy / n - b.y) * 0.0008; } b.vx += sx * 600; b.vy += sy * 600; const sp = Math.hypot(b.vx, b.vy) || 1; const clamped = Math.max(minSpeed, Math.min(maxSpeed, sp)); b.vx = (b.vx / sp) * clamped; b.vy = (b.vy / sp) * clamped; b.x += b.vx * dt; b.y += b.vy * dt; if (b.x < 0) b.x += width; if (b.x > width) b.x -= width; if (b.y < 0) b.y += height; if (b.y > height) b.y -= height; const ang = Math.atan2(b.vy, b.vx); ctx.save(); ctx.translate(b.x, b.y); ctx.rotate(ang); ctx.fillStyle = `rgba(${color},0.8)`; ctx.beginPath(); ctx.moveTo(6, 0); ctx.lineTo(-4, 3); ctx.lineTo(-4, -3); ctx.closePath(); ctx.fill(); ctx.restore(); } }, }); return (
{children}
); } ``` ### Fireworks Rockets burst into showers of falling sparks. URL: https://dev.ononc.com/backgrounds/fireworks Path: src/components/backgrounds/fireworks.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface FireworksProps extends HTMLAttributes { colors?: string[]; children?: ReactNode; } interface Spark { x: number; y: number; vx: number; vy: number; life: number; max: number; color: string; } interface Rocket { x: number; y: number; vy: number; burstY: number; color: string; } interface State { rockets: Rocket[]; sparks: Spark[]; timer: number; } export function Fireworks({ className, colors = ["139,124,255", "94,234,255", "244,114,182", "250,204,21", "120,255,180"], children, ...props }: FireworksProps) { const ref = useCanvas({ init: () => ({ rockets: [], sparks: [], timer: 0.3 }), draw: ({ ctx, width, height }, state, _t, dt) => { ctx.fillStyle = "rgba(6,7,13,0.2)"; ctx.fillRect(0, 0, width, height); ctx.globalCompositeOperation = "lighter"; state.timer -= dt; if (state.timer <= 0) { state.rockets.push({ x: width * (0.15 + Math.random() * 0.7), y: height, vy: -(height * 0.55 + Math.random() * height * 0.2), burstY: height * (0.12 + Math.random() * 0.4), color: colors[Math.floor(Math.random() * colors.length)], }); state.timer = 0.5 + Math.random() * 1.1; } for (const r of state.rockets) { r.y += r.vy * dt; ctx.fillStyle = `rgba(${r.color},0.9)`; ctx.beginPath(); ctx.arc(r.x, r.y, 2, 0, Math.PI * 2); ctx.fill(); if (r.y <= r.burstY) { const n = 44 + Math.floor(Math.random() * 28); for (let i = 0; i < n; i++) { const a = (i / n) * Math.PI * 2; const sp = 50 + Math.random() * 150; state.sparks.push({ x: r.x, y: r.y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, life: 1 + Math.random() * 0.8, max: 1.8, color: r.color, }); } } } state.rockets = state.rockets.filter((r) => r.y > r.burstY); for (const s of state.sparks) { s.vy += 80 * dt; s.vx *= 0.985; s.x += s.vx * dt; s.y += s.vy * dt; s.life -= dt; const a = Math.max(0, s.life / s.max); ctx.fillStyle = `rgba(${s.color},${a})`; ctx.beginPath(); ctx.arc(s.x, s.y, 1.8, 0, Math.PI * 2); ctx.fill(); } state.sparks = state.sparks.filter((s) => s.life > 0); ctx.globalCompositeOperation = "source-over"; }, }); return (
{children}
); } ``` ### Comets Comets arc across the sky with glowing curved tails. URL: https://dev.ononc.com/backgrounds/comets Path: src/components/backgrounds/comets.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface CometsProps extends HTMLAttributes { count?: number; colors?: string[]; children?: ReactNode; } interface Comet { x: number; y: number; vx: number; vy: number; hist: [number, number][]; color: string; } interface State { comets: Comet[]; } export function Comets({ className, count = 4, colors = ["196,181,253", "94,234,255", "244,114,182"], children, ...props }: CometsProps) { const ref = useCanvas({ init: ({ width, height }) => { const spawn = (): Comet => { const fromLeft = Math.random() > 0.5; const speed = 120 + Math.random() * 120; const ang = (fromLeft ? 0.2 : Math.PI - 0.2) + (Math.random() - 0.5) * 0.5; return { x: fromLeft ? -40 : width + 40, y: Math.random() * height * 0.6, vx: Math.cos(ang) * speed, vy: Math.sin(ang) * speed, hist: [], color: colors[Math.floor(Math.random() * colors.length)], }; }; return { comets: Array.from({ length: count }, spawn) }; }, draw: ({ ctx, width, height }, state, _t, dt) => { ctx.clearRect(0, 0, width, height); ctx.globalCompositeOperation = "lighter"; ctx.lineCap = "round"; for (const c of state.comets) { // Gentle curve: rotate velocity slightly. const cosr = Math.cos(0.6 * dt); const sinr = Math.sin(0.6 * dt); const nvx = c.vx * cosr - c.vy * sinr; const nvy = c.vx * sinr + c.vy * cosr; c.vx = nvx; c.vy = nvy; c.x += c.vx * dt; c.y += c.vy * dt; c.hist.push([c.x, c.y]); if (c.hist.length > 28) c.hist.shift(); for (let i = 1; i < c.hist.length; i++) { const a = i / c.hist.length; ctx.strokeStyle = `rgba(${c.color},${a * 0.7})`; ctx.lineWidth = a * 3; ctx.beginPath(); ctx.moveTo(c.hist[i - 1][0], c.hist[i - 1][1]); ctx.lineTo(c.hist[i][0], c.hist[i][1]); ctx.stroke(); } const glow = ctx.createRadialGradient(c.x, c.y, 0, c.x, c.y, 8); glow.addColorStop(0, `rgba(${c.color},1)`); glow.addColorStop(1, `rgba(${c.color},0)`); ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(c.x, c.y, 8, 0, Math.PI * 2); ctx.fill(); if (c.x < -60 || c.x > width + 60 || c.y < -60 || c.y > height + 60) { const fromLeft = Math.random() > 0.5; const speed = 120 + Math.random() * 120; const ang = (fromLeft ? 0.2 : Math.PI - 0.2) + (Math.random() - 0.5) * 0.5; c.x = fromLeft ? -40 : width + 40; c.y = Math.random() * height * 0.6; c.vx = Math.cos(ang) * speed; c.vy = Math.sin(ang) * speed; c.hist = []; } } ctx.globalCompositeOperation = "source-over"; }, }); return (
{children}
); } ``` ### Aurora Curtains Vertical curtains of light shimmering like the aurora. URL: https://dev.ononc.com/backgrounds/aurora-curtains Path: src/components/backgrounds/aurora-curtains.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface AuroraCurtainsProps extends HTMLAttributes { colors?: string[]; children?: ReactNode; } interface Curtain { xBase: number; width: number; amp: number; speed: number; phase: number; color: string; } interface State { curtains: Curtain[]; } export function AuroraCurtains({ className, colors = ["139,92,246", "34,211,238", "120,255,180", "244,114,182"], children, ...props }: AuroraCurtainsProps) { const ref = useCanvas({ init: ({ width }) => { const curtains: Curtain[] = Array.from({ length: 6 }, (_, i) => ({ xBase: (width * (i + 0.5)) / 6, width: 40 + Math.random() * 70, amp: 20 + Math.random() * 50, speed: 0.4 + Math.random() * 0.7, phase: i * 1.3, color: colors[i % colors.length], })); return { curtains }; }, draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); ctx.globalCompositeOperation = "lighter"; for (const c of state.curtains) { const leftX = (y: number) => c.xBase + Math.sin(y * 0.011 + t * c.speed + c.phase) * c.amp; ctx.beginPath(); ctx.moveTo(leftX(0), 0); for (let y = 0; y <= height; y += 16) ctx.lineTo(leftX(y), y); for (let y = height; y >= 0; y -= 16) ctx.lineTo(leftX(y) + c.width, y); ctx.closePath(); const grad = ctx.createLinearGradient(0, 0, 0, height); grad.addColorStop(0, `rgba(${c.color},0)`); grad.addColorStop(0.4, `rgba(${c.color},0.22)`); grad.addColorStop(0.75, `rgba(${c.color},0.12)`); grad.addColorStop(1, `rgba(${c.color},0)`); ctx.fillStyle = grad; ctx.fill(); } ctx.globalCompositeOperation = "source-over"; }, }); return (
{children}
); } ``` ### Cloth Flag A flag-like mesh rippling and self-shading in the wind. URL: https://dev.ononc.com/backgrounds/cloth-flag Path: src/components/backgrounds/cloth-flag.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface ClothFlagProps extends HTMLAttributes { cols?: number; rows?: number; /** Left and right edge colors as [r,g,b]. */ from?: [number, number, number]; to?: [number, number, number]; children?: ReactNode; } interface State { cols: number; rows: number; } export function ClothFlag({ className, cols = 22, rows = 15, from = [139, 92, 246], to = [34, 211, 238], children, ...props }: ClothFlagProps) { const ref = useCanvas({ init: () => ({ cols, rows }), draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); const { cols: C, rows: R } = state; const pt = (c: number, r: number) => { const gx = c / (C - 1); const gy = r / (R - 1); const wave = (Math.sin(gx * 6 - t * 2.5) + Math.sin(gy * 4 + t * 1.3) * 0.5) * gx; return { x: gx * width + Math.sin(gy * 5 + t * 2) * gx * width * 0.02, y: gy * height + wave * height * 0.09, z: wave, }; }; for (let r = 0; r < R - 1; r++) { for (let c = 0; c < C - 1; c++) { const tl = pt(c, r); const tr = pt(c + 1, r); const br = pt(c + 1, r + 1); const bl = pt(c, r + 1); const gx = c / (C - 1); const shade = 0.45 + 0.55 * ((tl.z + br.z) / 2 + 0.5); const cr = Math.round((from[0] + (to[0] - from[0]) * gx) * shade); const cg = Math.round((from[1] + (to[1] - from[1]) * gx) * shade); const cb = Math.round((from[2] + (to[2] - from[2]) * gx) * shade); ctx.fillStyle = `rgb(${cr},${cg},${cb})`; ctx.beginPath(); ctx.moveTo(tl.x, tl.y); ctx.lineTo(tr.x, tr.y); ctx.lineTo(br.x, br.y); ctx.lineTo(bl.x, bl.y); ctx.closePath(); ctx.fill(); } } }, }); return (
{children}
); } ``` ### Voronoi Fill Filled Voronoi regions drifting like stained glass. URL: https://dev.ononc.com/backgrounds/voronoi-fill Path: src/components/backgrounds/voronoi-fill.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface VoronoiFillProps extends HTMLAttributes { seeds?: number; /** Grid step in pixels. */ step?: number; colors?: string[]; children?: ReactNode; } interface Seed { x: number; y: number; vx: number; vy: number; color: string; } interface State { seeds: Seed[]; step: number; } export function VoronoiFill({ className, seeds = 13, step = 9, colors = ["139,92,246", "34,211,238", "244,114,182", "79,70,229", "120,255,180"], children, ...props }: VoronoiFillProps) { const ref = useCanvas({ init: ({ width, height }) => ({ step, seeds: Array.from({ length: seeds }, () => ({ x: Math.random() * width, y: Math.random() * height, vx: (Math.random() - 0.5) * 0.4, vy: (Math.random() - 0.5) * 0.4, color: colors[Math.floor(Math.random() * colors.length)], })), }), draw: ({ ctx, width, height }, state) => { ctx.clearRect(0, 0, width, height); for (const s of state.seeds) { s.x += s.vx; s.y += s.vy; if (s.x < 0 || s.x > width) s.vx *= -1; if (s.y < 0 || s.y > height) s.vy *= -1; } const st = state.step; for (let y = 0; y < height; y += st) { for (let x = 0; x < width; x += st) { let m1 = Infinity; let m2 = Infinity; let color = state.seeds[0].color; for (const s of state.seeds) { const dx = x - s.x; const dy = y - s.y; const d = dx * dx + dy * dy; if (d < m1) { m2 = m1; m1 = d; color = s.color; } else if (d < m2) { m2 = d; } } const border = Math.sqrt(m2) - Math.sqrt(m1); const edge = border < 6 ? border / 6 : 1; ctx.fillStyle = `rgba(${color},${0.45 * edge + 0.05})`; ctx.fillRect(x, y, st, st); } } }, }); return (
{children}
); } ``` ### Aurora Soft gradient halos that drift like the northern lights. URL: https://dev.ononc.com/backgrounds/aurora-background Path: src/components/backgrounds/aurora-background.tsx ```tsx import type { CSSProperties, HTMLAttributes, ReactNode } from "react"; import { cn } from "@/lib/utils"; export interface AuroraBackgroundProps extends HTMLAttributes { /** Override the three aurora colors (CSS color strings). */ colors?: [string, string, string]; /** Strength of the blur halo in pixels. */ blur?: number; children?: ReactNode; } /** * AuroraBackground — soft, slowly drifting gradient halos behind content. * Pure CSS (GPU-friendly), honors prefers-reduced-motion via the global rule. */ export function AuroraBackground({ className, colors = ["var(--brand)", "var(--brand-2)", "var(--brand-3)"], blur = 80, children, ...props }: AuroraBackgroundProps) { const [a, b, c] = colors; return (
{/* Top vignette + grain for depth. */}
{children}
); } ``` ### Gradient Mesh A panning multi-point color mesh finished with fine grain. URL: https://dev.ononc.com/backgrounds Path: src/components/backgrounds/gradient-mesh.tsx ```tsx import type { HTMLAttributes, ReactNode } from "react"; import { cn } from "@/lib/utils"; export interface GradientMeshProps extends HTMLAttributes { children?: ReactNode; } /** * GradientMesh — a layered radial-gradient mesh that slowly pans, finished * with a faint SVG grain so large color fields don't band. Pure CSS. */ export function GradientMesh({ className, children, ...props }: GradientMeshProps) { return (
{children}
); } ``` ### Particle Field Drifting particles that link to neighbors and the cursor. URL: https://dev.ononc.com/backgrounds/particle-field Path: src/components/backgrounds/particle-field.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface ParticleFieldProps extends HTMLAttributes { /** Approx. particles per 100k px². Higher = denser. */ density?: number; /** Max distance (px) at which particles link. */ linkDistance?: number; /** Particle + link color. */ color?: string; children?: ReactNode; } interface Particle { x: number; y: number; vx: number; vy: number; r: number; } interface State { ps: Particle[]; pointer: { x: number; y: number; inside: boolean }; } export function ParticleField({ className, density = 9, linkDistance = 130, color = "139,160,255", children, ...props }: ParticleFieldProps) { const ref = useCanvas({ init: ({ width, height }) => { const target = Math.round((width * height) / 100000 * density); const count = Math.max(16, Math.min(120, target)); const ps: Particle[] = Array.from({ length: count }, () => ({ x: Math.random() * width, y: Math.random() * height, vx: (Math.random() - 0.5) * 0.35, vy: (Math.random() - 0.5) * 0.35, r: Math.random() * 1.6 + 0.7, })); return { ps, pointer: { x: -9999, y: -9999, inside: false } }; }, draw: ({ ctx, width, height }, state) => { ctx.clearRect(0, 0, width, height); const { ps, pointer } = state; for (const p of ps) { p.x += p.vx; p.y += p.vy; if (p.x < -10) p.x = width + 10; if (p.x > width + 10) p.x = -10; if (p.y < -10) p.y = height + 10; if (p.y > height + 10) p.y = -10; if (pointer.inside) { const dx = pointer.x - p.x; const dy = pointer.y - p.y; const d2 = dx * dx + dy * dy; if (d2 < 150 * 150 && d2 > 0.01) { const f = 0.02 / Math.sqrt(d2); p.vx += dx * f; p.vy += dy * f; } } // gentle damping keeps drift bounded p.vx *= 0.992; p.vy *= 0.992; } // links between nearby particles for (let i = 0; i < ps.length; i++) { for (let j = i + 1; j < ps.length; j++) { const a = ps[i]; const b = ps[j]; const dx = a.x - b.x; const dy = a.y - b.y; const dist = Math.hypot(dx, dy); if (dist < linkDistance) { const alpha = (1 - dist / linkDistance) * 0.5; ctx.strokeStyle = `rgba(${color},${alpha})`; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); } } } // links to the cursor if (pointer.inside) { for (const p of ps) { const dist = Math.hypot(pointer.x - p.x, pointer.y - p.y); if (dist < linkDistance * 1.4) { const alpha = (1 - dist / (linkDistance * 1.4)) * 0.6; ctx.strokeStyle = `rgba(${color},${alpha})`; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(pointer.x, pointer.y); ctx.lineTo(p.x, p.y); ctx.stroke(); } } } for (const p of ps) { ctx.fillStyle = `rgba(${color},0.9)`; ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); ctx.fill(); } }, onPointer: (state, x, y, inside) => { state.pointer.x = x; state.pointer.y = y; state.pointer.inside = inside; }, }); return (
{children}
); } ``` ### Dot Matrix A grid of dots that swell and brighten near the pointer. URL: https://dev.ononc.com/backgrounds/dot-matrix Path: src/components/backgrounds/dot-matrix.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface DotMatrixProps extends HTMLAttributes { /** Spacing between dots in pixels. */ gap?: number; /** Base dot radius in pixels. */ dotRadius?: number; /** Influence radius of the cursor in pixels. */ reach?: number; /** Dot color as "r,g,b". */ color?: string; children?: ReactNode; } interface Dot { x: number; y: number; } interface State { dots: Dot[]; pointer: { x: number; y: number; inside: boolean }; glow: number; // eased pointer presence 0..1 } export function DotMatrix({ className, gap = 28, dotRadius = 1.3, reach = 130, color = "150,170,255", children, ...props }: DotMatrixProps) { const ref = useCanvas({ init: ({ width, height }) => { const dots: Dot[] = []; const cols = Math.ceil(width / gap); const rows = Math.ceil(height / gap); const offX = (width - (cols - 1) * gap) / 2; const offY = (height - (rows - 1) * gap) / 2; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { dots.push({ x: offX + c * gap, y: offY + r * gap }); } } return { dots, pointer: { x: -9999, y: -9999, inside: false }, glow: 0 }; }, draw: ({ ctx, width, height }, state, _t, dt) => { ctx.clearRect(0, 0, width, height); const { dots, pointer } = state; const targetGlow = pointer.inside ? 1 : 0; state.glow += (targetGlow - state.glow) * Math.min(1, dt * 6 || 0.2); for (const d of dots) { let intensity = 0.18; let radius = dotRadius; if (state.glow > 0.01) { const dist = Math.hypot(pointer.x - d.x, pointer.y - d.y); if (dist < reach) { const f = (1 - dist / reach) * state.glow; intensity = 0.18 + f * 0.82; radius = dotRadius + f * 2.4; } } ctx.fillStyle = `rgba(${color},${intensity})`; ctx.beginPath(); ctx.arc(d.x, d.y, radius, 0, Math.PI * 2); ctx.fill(); } }, onPointer: (state, x, y, inside) => { state.pointer.x = x; state.pointer.y = y; state.pointer.inside = inside; }, }); return (
{children}
); } ``` ### Starfield Twinkling stars drifting with subtle cursor parallax. URL: https://dev.ononc.com/backgrounds/starfield Path: src/components/backgrounds/starfield.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface StarfieldProps extends HTMLAttributes { /** Approx. stars per 100k px². */ density?: number; /** Star color as "r,g,b". */ color?: string; children?: ReactNode; } interface Star { x: number; y: number; z: number; // 0..1 depth → size + speed + twinkle rate phase: number; } interface State { stars: Star[]; pointer: { x: number; y: number; inside: boolean }; } export function Starfield({ className, density = 14, color = "210,220,255", children, ...props }: StarfieldProps) { const ref = useCanvas({ init: ({ width, height }) => { const count = Math.max( 24, Math.min(220, Math.round(((width * height) / 100000) * density)), ); const stars: Star[] = Array.from({ length: count }, () => ({ x: Math.random() * width, y: Math.random() * height, z: Math.random(), phase: Math.random() * Math.PI * 2, })); return { stars, pointer: { x: 0, y: 0, inside: false } }; }, draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); const px = state.pointer.inside ? (state.pointer.x / width - 0.5) * 2 : 0; const py = state.pointer.inside ? (state.pointer.y / height - 0.5) * 2 : 0; for (const s of state.stars) { s.y += (0.05 + s.z * 0.35) * 0.6; if (s.y > height + 2) { s.y = -2; s.x = Math.random() * width; } const twinkle = 0.5 + 0.5 * Math.sin(t * (1 + s.z * 2) + s.phase); const size = 0.4 + s.z * 1.6; const offX = px * s.z * 14; const offY = py * s.z * 14; ctx.fillStyle = `rgba(${color},${0.25 + twinkle * 0.7 * s.z})`; ctx.beginPath(); ctx.arc(s.x + offX, s.y + offY, size, 0, Math.PI * 2); ctx.fill(); } }, onPointer: (state, x, y, inside) => { state.pointer.x = x; state.pointer.y = y; state.pointer.inside = inside; }, }); return (
{children}
); } ``` ### Waves Layered sine waves scrolling across the lower edge. URL: https://dev.ononc.com/backgrounds/waves Path: src/components/backgrounds/waves.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface WavesProps extends HTMLAttributes { /** Wave layer colors as "r,g,b" strings (back to front). */ colors?: string[]; children?: ReactNode; } interface Layer { color: string; amplitude: number; wavelength: number; speed: number; yBase: number; phase: number; } interface State { layers: Layer[]; } export function Waves({ className, colors = ["139,92,246", "34,211,238", "251,113,133"], children, ...props }: WavesProps) { const ref = useCanvas({ init: ({ height }) => { const layers: Layer[] = colors.map((color, i) => ({ color, amplitude: 16 + i * 10, wavelength: 260 + i * 90, speed: 0.6 - i * 0.12, yBase: height * (0.62 + i * 0.13), phase: i * 1.3, })); return { layers }; }, draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); for (const layer of state.layers) { ctx.beginPath(); ctx.moveTo(0, height); for (let x = 0; x <= width; x += 8) { const y = layer.yBase + Math.sin( x / layer.wavelength + t * layer.speed + layer.phase, ) * layer.amplitude; ctx.lineTo(x, y); } ctx.lineTo(width, height); ctx.closePath(); const grad = ctx.createLinearGradient(0, layer.yBase - 60, 0, height); grad.addColorStop(0, `rgba(${layer.color},0.30)`); grad.addColorStop(1, `rgba(${layer.color},0.02)`); ctx.fillStyle = grad; ctx.fill(); } }, }); return (
{children}
); } ``` ### Meteors Glowing meteors streaking diagonally on staggered timers. URL: https://dev.ononc.com/backgrounds/meteors Path: src/components/backgrounds/meteors.tsx ```tsx import type { CSSProperties, HTMLAttributes, ReactNode } from "react"; import { cn, seededRandom } from "@/lib/utils"; export interface MeteorsProps extends HTMLAttributes { /** Number of meteors. */ count?: number; children?: ReactNode; } /** * Meteors — streaks that fall diagonally across the field with glowing tails, * each on its own delay and duration. Deterministic layout for SSR safety. */ export function Meteors({ className, count = 14, children, ...props }: MeteorsProps) { const meteors = Array.from({ length: count }, (_, i) => { const r = seededRandom(i + 3); const r2 = seededRandom(i + 71); return { left: `${r * 100}%`, top: `${r2 * 40}%`, delay: `${(r * 8).toFixed(2)}s`, duration: `${(3 + r2 * 4).toFixed(2)}s`, length: `${40 + r * 60}px`, }; }); return (
{meteors.map((m, i) => ( ))}
{children}
); } ``` ### Plasma Vivid blurred color fields panning while the hue rotates. URL: https://dev.ononc.com/backgrounds Path: src/components/backgrounds/plasma.tsx ```tsx import type { CSSProperties, HTMLAttributes, ReactNode } from "react"; import { cn } from "@/lib/utils"; export interface PlasmaProps extends HTMLAttributes { children?: ReactNode; } /** * Plasma — vivid blurred color fields that pan while their hue slowly rotates, * giving a living, lava-lamp feel. Pure CSS. */ export function Plasma({ className, children, ...props }: PlasmaProps) { return (
{children}
); } ``` ### Flow Grid A tilted perspective grid scrolling toward the horizon. URL: https://dev.ononc.com/backgrounds/flow-grid Path: src/components/backgrounds/flow-grid.tsx ```tsx import type { CSSProperties, HTMLAttributes, ReactNode } from "react"; import { cn } from "@/lib/utils"; export interface FlowGridProps extends HTMLAttributes { /** Grid cell size in pixels. */ size?: number; /** Line color (CSS color string). */ lineColor?: string; children?: ReactNode; } /** * FlowGrid — a tilted perspective grid that scrolls toward the horizon, * faded with a radial mask. Pure CSS; the pan honors reduced-motion globally. */ export function FlowGrid({ className, size = 44, lineColor = "rgba(139,92,246,0.35)", children, ...props }: FlowGridProps) { return (
{/* Horizon glow */}
{children}
); } ``` ### Light Beams Vertical shafts of light, each breathing on its own cadence. URL: https://dev.ononc.com/backgrounds/light-beams Path: src/components/backgrounds/light-beams.tsx ```tsx import type { CSSProperties, HTMLAttributes, ReactNode } from "react"; import { cn, seededRandom } from "@/lib/utils"; export interface LightBeamsProps extends HTMLAttributes { /** Number of beams. */ count?: number; children?: ReactNode; } /** * LightBeams — soft vertical shafts of light raining from the top edge, each * breathing on its own cadence. Deterministic layout (seeded) so SSR matches. */ export function LightBeams({ className, count = 9, children, ...props }: LightBeamsProps) { const beams = Array.from({ length: count }, (_, i) => { const r = seededRandom(i + 1); const r2 = seededRandom(i + 99); return { left: `${(i / count) * 100 + r * 6}%`, width: `${1 + r2 * 2.5}px`, delay: `${-(r * 6).toFixed(2)}s`, duration: `${4 + r2 * 4}s`, opacity: 0.25 + r * 0.45, hue: i % 3, }; }); const palette = ["var(--brand-ink)", "var(--brand-2)", "var(--brand-3)"]; return (
{beams.map((b, i) => ( ))}
{children}
); } ``` ### Pulse Rings Concentric rings expanding outward like sonar. URL: https://dev.ononc.com/backgrounds/pulse-rings Path: src/components/backgrounds/pulse-rings.tsx ```tsx import type { CSSProperties, HTMLAttributes, ReactNode } from "react"; import { cn } from "@/lib/utils"; export interface PulseRingsProps extends HTMLAttributes { /** Number of concentric rings in flight. */ rings?: number; children?: ReactNode; } /** * PulseRings — concentric rings that expand and fade outward like sonar. * Pure CSS via the global `pulse-ring` keyframe; staggered by delay. */ export function PulseRings({ className, rings = 5, children, ...props }: PulseRingsProps) { const items = Array.from({ length: rings }); const period = 2.8; return (
{items.map((_, i) => ( ))}
{children}
); } ``` ### Flowing Lines Silk-like lines waving with layered motion and cursor pull. URL: https://dev.ononc.com/backgrounds/flowing-lines Path: src/components/backgrounds/flowing-lines.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface FlowingLinesProps extends HTMLAttributes { /** Number of flowing lines. */ lines?: number; /** Line colors as "r,g,b" (cycled). */ colors?: string[]; children?: ReactNode; } interface Line { offset: number; amplitude: number; wavelength: number; speed: number; color: string; phase: number; } interface State { lines: Line[]; pointer: { y: number; inside: boolean }; } export function FlowingLines({ className, lines = 18, colors = ["139,92,246", "34,211,238", "251,113,133"], children, ...props }: FlowingLinesProps) { const ref = useCanvas({ init: ({ height }) => { const arr: Line[] = Array.from({ length: lines }, (_, i) => { const f = i / Math.max(1, lines - 1); return { offset: height * (0.12 + f * 0.76), amplitude: 14 + (i % 5) * 7, wavelength: 300 + (i % 4) * 110, speed: 0.25 + (i % 3) * 0.12, color: colors[i % colors.length], phase: i * 0.6, }; }); return { lines: arr, pointer: { y: 0, inside: false } }; }, draw: ({ ctx, width, height }, state, t) => { ctx.clearRect(0, 0, width, height); ctx.lineWidth = 1.5; for (const line of state.lines) { const boost = state.pointer.inside ? Math.max(0, 1 - Math.abs(state.pointer.y - line.offset) / 120) * 26 : 0; ctx.beginPath(); for (let x = 0; x <= width; x += 6) { const y = line.offset + Math.sin(x / line.wavelength + t * line.speed + line.phase) * (line.amplitude + boost) + Math.sin(x / (line.wavelength * 0.4) + t * line.speed * 1.6) * 4; if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.strokeStyle = `rgba(${line.color},0.32)`; ctx.stroke(); } }, onPointer: (state, _x, y, inside) => { state.pointer.y = y; state.pointer.inside = inside; }, }); return (
{children}
); } ``` ### Spotlight Cursor A hidden dot grid revealed only inside a cursor spotlight. URL: https://dev.ononc.com/backgrounds/spotlight-cursor Path: src/components/backgrounds/spotlight-cursor.tsx ```tsx "use client"; import type { CSSProperties, HTMLAttributes, PointerEvent, ReactNode } from "react"; import { useRef } from "react"; import { cn } from "@/lib/utils"; export interface SpotlightCursorProps extends HTMLAttributes { /** Spotlight radius in pixels. */ radius?: number; children?: ReactNode; } /** * SpotlightCursor — a hidden dot grid that's only revealed inside a soft * spotlight tracking the cursor, via a CSS mask updated through variables * (no re-renders). */ export function SpotlightCursor({ className, radius = 180, children, ...props }: SpotlightCursorProps) { const ref = useRef(null); const handleMove = (e: PointerEvent) => { const el = ref.current; if (!el) return; const rect = el.getBoundingClientRect(); el.style.setProperty("--sx", `${e.clientX - rect.left}px`); el.style.setProperty("--sy", `${e.clientY - rect.top}px`); }; const mask = `radial-gradient(${radius}px circle at var(--sx, 50%) var(--sy, 50%), #000 0%, transparent 72%)`; return (
{children}
); } ``` ### Matrix Rain Columns of glyphs falling with glowing fading trails. URL: https://dev.ononc.com/backgrounds/matrix-rain Path: src/components/backgrounds/matrix-rain.tsx ```tsx "use client"; import type { HTMLAttributes, ReactNode } from "react"; import { useCanvas } from "@/lib/use-canvas"; import { cn } from "@/lib/utils"; export interface MatrixRainProps extends HTMLAttributes { /** Font size (px) — also the column width. */ fontSize?: number; /** Glyph color as "r,g,b". */ color?: string; children?: ReactNode; } interface State { drops: number[]; acc: number; } const GLYPHS = "アイウエオカキクケコサシスセソタチツテトナニヌネ0123456789ABCDEF<>{}/*+-"; export function MatrixRain({ className, fontSize = 14, color = "34,211,238", children, ...props }: MatrixRainProps) { const ref = useCanvas({ init: ({ width, height }) => { const cols = Math.ceil(width / fontSize); const drops = Array.from({ length: cols }, () => Math.floor((Math.random() * height) / fontSize), ); return { drops, acc: 0 }; }, draw: ({ ctx, width, height }, state, _t, dt) => { // Fade the previous frame to leave glowing trails. ctx.fillStyle = "rgba(6, 7, 13, 0.1)"; ctx.fillRect(0, 0, width, height); state.acc += dt; const step = state.acc >= 0.05; if (step) state.acc = 0; ctx.font = `${fontSize}px ui-monospace, monospace`; for (let i = 0; i < state.drops.length; i++) { const x = i * fontSize; const y = state.drops[i] * fontSize; const glyph = GLYPHS[Math.floor(Math.random() * GLYPHS.length)]; // Bright head, dimmer tail. ctx.fillStyle = `rgba(${color},0.9)`; ctx.fillText(glyph, x, y); if (step) { if (y > height && Math.random() > 0.975) state.drops[i] = 0; else state.drops[i] += 1; } } }, }); return (
{children}
); } ``` ## Text Animations Typographic effects that draw the eye — reveals, gradients, decoding, and counters. All stay screen-reader friendly. ### Gradient Text A living gradient that pans across the letters. URL: https://dev.ononc.com/text/gradient-text Path: src/components/text/gradient-text.tsx ```tsx import type { CSSProperties, HTMLAttributes, ReactNode } from "react"; import { cn } from "@/lib/utils"; export interface GradientTextProps extends HTMLAttributes { /** Gradient stops, left to right (CSS color strings). */ colors?: string[]; /** Animation duration in seconds. */ speed?: number; children: ReactNode; } /** * GradientText — a multi-stop gradient clipped to the text that pans * horizontally. Pure CSS; freezes under prefers-reduced-motion. */ export function GradientText({ className, colors = ["var(--brand-ink)", "var(--brand-2)", "var(--brand-3)", "var(--brand-ink)"], speed = 8, children, ...props }: GradientTextProps) { return ( {children} ); } ``` ### Shiny Text A glare that sweeps across muted text on a loop. URL: https://dev.ononc.com/text/shiny-text Path: src/components/text/shiny-text.tsx ```tsx import type { CSSProperties, HTMLAttributes, ReactNode } from "react"; import { cn } from "@/lib/utils"; export interface ShinyTextProps extends HTMLAttributes { /** Sweep duration in seconds. */ speed?: number; children: ReactNode; } /** * ShinyText — muted text with a bright glare that sweeps across on a loop. * Pure CSS via the global `shimmer` keyframe. */ export function ShinyText({ className, speed = 3, children, ...props }: ShinyTextProps) { return ( {children} ); } ``` ### Split Reveal Words slide up from behind a mask, staggered, on view. URL: https://dev.ononc.com/text/split-reveal Path: src/components/text/split-reveal.tsx ```tsx "use client"; import { Fragment } from "react"; import { motion, type Variants } from "motion/react"; import { cn } from "@/lib/utils"; export interface SplitRevealProps { /** The text to reveal. */ text: string; className?: string; /** Split granularity. */ by?: "word" | "char"; /** Seconds between each token. */ stagger?: number; /** Delay before the first token (seconds). */ delay?: number; /** Replay every time it scrolls into view (default: once). */ repeat?: boolean; } /** * SplitReveal — each word or character slides up from behind a mask, staggered, * when the element scrolls into view. The full string stays screen-readable. */ export function SplitReveal({ text, className, by = "word", stagger = by === "word" ? 0.08 : 0.028, delay = 0, repeat = false, }: SplitRevealProps) { const tokens = by === "word" ? text.split(" ") : Array.from(text); const container: Variants = { hidden: {}, show: { transition: { staggerChildren: stagger, delayChildren: delay }, }, }; const item: Variants = { hidden: { y: "115%" }, show: { y: 0, transition: { type: "spring", stiffness: 240, damping: 24 }, }, }; return ( {tokens.map((tok, i) => { if (by === "char" && tok === " ") { return {"\u00A0"}; } return ( {tok} {by === "word" && i < tokens.length - 1 ? " " : null} ); })} ); } ``` ### Blur In Words resolve from a soft blur as they fade in. URL: https://dev.ononc.com/text/blur-in-text Path: src/components/text/blur-in-text.tsx ```tsx "use client"; import { Fragment } from "react"; import { motion, type Variants } from "motion/react"; import { cn } from "@/lib/utils"; export interface BlurInTextProps { text: string; className?: string; /** Seconds between each word. */ stagger?: number; /** Delay before the first word (seconds). */ delay?: number; repeat?: boolean; } /** * BlurInText — words resolve from a soft blur while fading and rising, one * after another, when scrolled into view. Stays screen-readable via aria-label. */ export function BlurInText({ text, className, stagger = 0.09, delay = 0, repeat = false, }: BlurInTextProps) { const words = text.split(" "); const container: Variants = { hidden: {}, show: { transition: { staggerChildren: stagger, delayChildren: delay } }, }; const item: Variants = { hidden: { opacity: 0, y: 12, filter: "blur(10px)" }, show: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: 0.5, ease: "easeOut" }, }, }; return ( {words.map((word, i) => ( {word} {i < words.length - 1 ? " " : null} ))} ); } ``` ### Typewriter Types and deletes through a rotating list of phrases. URL: https://dev.ononc.com/text/typewriter Path: src/components/text/typewriter.tsx ```tsx "use client"; import { useEffect, useState } from "react"; import { cn } from "@/lib/utils"; export interface TypewriterProps { /** Phrases to cycle through. */ words: string[]; typingSpeed?: number; deletingSpeed?: number; /** Pause (ms) once a word is fully typed. */ pauseTime?: number; className?: string; cursorClassName?: string; /** Loop back to the first word (default true). */ loop?: boolean; } type Phase = "typing" | "deleting"; /** * Typewriter — types each phrase out, pauses, deletes, and advances to the * next, with a hard-blinking caret. Stops on the last word when loop=false. */ export function Typewriter({ words, typingSpeed = 70, deletingSpeed = 38, pauseTime = 1400, className, cursorClassName, loop = true, }: TypewriterProps) { const [display, setDisplay] = useState(""); const [wordIndex, setWordIndex] = useState(0); const [phase, setPhase] = useState("typing"); useEffect(() => { if (words.length === 0) return; const current = words[wordIndex % words.length] ?? ""; const atLastWord = wordIndex === words.length - 1; let timeout: ReturnType; if (phase === "typing") { if (display.length < current.length) { timeout = setTimeout( () => setDisplay(current.slice(0, display.length + 1)), typingSpeed, ); } else if (loop || !atLastWord) { timeout = setTimeout(() => setPhase("deleting"), pauseTime); } } else if (display.length > 0) { timeout = setTimeout( () => setDisplay(current.slice(0, display.length - 1)), deletingSpeed, ); } else { timeout = setTimeout(() => { setWordIndex((i) => (i + 1) % words.length); setPhase("typing"); }, 400); } return () => clearTimeout(timeout); }, [display, phase, wordIndex, words, typingSpeed, deletingSpeed, pauseTime, loop]); if (words.length === 0) return null; return ( {display} ); } ``` ### Rotating Text Swaps through words in a vertical slot. URL: https://dev.ononc.com/text/rotating-text Path: src/components/text/rotating-text.tsx ```tsx "use client"; import { useEffect, useState } from "react"; import { AnimatePresence, motion } from "motion/react"; import { cn, prefersReducedMotion } from "@/lib/utils"; export interface RotatingTextProps { words: string[]; className?: string; /** Milliseconds each word is shown. */ interval?: number; } /** * RotatingText — swaps through a list of words, each sliding up out of a slot * as the next slides in. The current word is announced politely. */ export function RotatingText({ words, className, interval = 2200, }: RotatingTextProps) { const [index, setIndex] = useState(0); useEffect(() => { if (words.length <= 1 || prefersReducedMotion()) return; const id = setInterval( () => setIndex((i) => (i + 1) % words.length), interval, ); return () => clearInterval(id); }, [words.length, interval]); return ( {words[index]} ); } ``` ### Scramble Text Glyphs flicker and lock into the final string. URL: https://dev.ononc.com/text/scramble-text Path: src/components/text/scramble-text.tsx ```tsx "use client"; import { useEffect, useRef, useState } from "react"; import { useInView } from "motion/react"; import { cn, prefersReducedMotion } from "@/lib/utils"; export interface ScrambleTextProps { /** Target text to decode to. */ text: string; className?: string; /** Glyph pool used while scrambling. */ glyphs?: string; /** Milliseconds per animation step. */ speed?: number; /** Frames each character stays scrambled before locking. */ framesPerChar?: number; /** What starts the effect. */ trigger?: "view" | "hover" | "mount"; } const DEFAULT_GLYPHS = "ABCDEFGHJKLMNPRSTUVWXYZ0123456789@#%&*<>/\\[]{}=+-?"; /** * ScrambleText — characters flicker through random glyphs and lock in * left-to-right. Screen readers get the final text via aria-label. */ export function ScrambleText({ text, className, glyphs = DEFAULT_GLYPHS, speed = 28, framesPerChar = 2, trigger = "view", }: ScrambleTextProps) { const ref = useRef(null); const inView = useInView(ref, { once: true, amount: 0.4 }); const [output, setOutput] = useState(text); const timer = useRef | null>(null); const run = () => { if (prefersReducedMotion()) return; if (timer.current) clearInterval(timer.current); let frame = 0; const total = text.length * framesPerChar; timer.current = setInterval(() => { frame += 1; const revealed = frame / framesPerChar; let out = ""; for (let i = 0; i < text.length; i++) { const ch = text[i]; if (ch === " ") out += " "; else if (i < revealed) out += ch; else out += glyphs[Math.floor(Math.random() * glyphs.length)]; } setOutput(out); if (frame >= total) { setOutput(text); if (timer.current) clearInterval(timer.current); } }, speed); }; useEffect(() => { if (trigger === "mount") run(); else if (trigger === "view" && inView) run(); return () => { if (timer.current) clearInterval(timer.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [inView, trigger]); return ( {output} ); } ``` ### Glitch Text Two color channels jitter and clip for a CRT glitch. URL: https://dev.ononc.com/text/glitch-text Path: src/components/text/glitch-text.tsx ```tsx import type { CSSProperties } from "react"; import { cn } from "@/lib/utils"; export interface GlitchTextProps { text: string; className?: string; } /** * GlitchText — two offset color channels (cyan / rose) jitter and clip over the * base text for a CRT-glitch effect. Pure CSS; settles under reduced-motion. */ export function GlitchText({ text, className }: GlitchTextProps) { return ( {text} {text} {text} ); } ``` ### Wavy Text Each letter rides a continuous sine wave. URL: https://dev.ononc.com/text/wavy-text Path: src/components/text/wavy-text.tsx ```tsx import type { CSSProperties } from "react"; import { cn } from "@/lib/utils"; export interface WavyTextProps { text: string; className?: string; /** Seconds for one full bob cycle. */ duration?: number; /** Seconds of delay added per letter. */ stagger?: number; } /** * WavyText — each letter rides a sine wave, offset by position, for a gentle * continuous ripple. Pure CSS; freezes under reduced-motion. Screen readers * get the whole word via aria-label. */ export function WavyText({ text, className, duration = 1.8, stagger = 0.06, }: WavyTextProps) { return ( {Array.from(text).map((char, i) => ( {char === " " ? "\u00A0" : char} ))} ); } ``` ### Highlight Text A marker stroke sweeps in behind the text on view. URL: https://dev.ononc.com/text/highlight-text Path: src/components/text/highlight-text.tsx ```tsx "use client"; import type { ReactNode } from "react"; import { motion } from "motion/react"; import { cn } from "@/lib/utils"; export interface HighlightTextProps { children: ReactNode; className?: string; /** Marker color (CSS color). */ color?: string; /** Delay before the sweep (seconds). */ delay?: number; } /** * HighlightText — a marker stroke sweeps in behind the text from the left the * first time it scrolls into view. */ export function HighlightText({ children, className, color = "color-mix(in oklab, var(--brand) 45%, transparent)", delay = 0, }: HighlightTextProps) { return ( {children} ); } ``` ### Count Up Eases a number to its target the first time it's seen. URL: https://dev.ononc.com/text/count-up Path: src/components/text/count-up.tsx ```tsx "use client"; import { useEffect, useRef, useState } from "react"; import { useInView } from "motion/react"; import { cn, prefersReducedMotion } from "@/lib/utils"; export interface CountUpProps { /** Target value. */ to: number; /** Starting value. */ from?: number; /** Tween duration in seconds. */ duration?: number; /** Decimal places. */ decimals?: number; prefix?: string; suffix?: string; /** Group thousands with commas. */ separator?: boolean; className?: string; } /** * CountUp — eases a number from `from` to `to` the first time it scrolls into * view (easeOutCubic). Jumps straight to the value under reduced-motion. */ export function CountUp({ to, from = 0, duration = 1.6, decimals = 0, prefix = "", suffix = "", separator = true, className, }: CountUpProps) { const ref = useRef(null); const inView = useInView(ref, { once: true, amount: 0.5 }); const [value, setValue] = useState(from); const raf = useRef(0); useEffect(() => { if (!inView) return; const seconds = prefersReducedMotion() ? 0 : duration; const start = performance.now(); const tick = (now: number) => { const elapsed = (now - start) / 1000; const p = seconds <= 0 ? 1 : Math.min(1, elapsed / seconds); const eased = 1 - Math.pow(1 - p, 3); setValue(from + (to - from) * eased); if (p < 1) raf.current = requestAnimationFrame(tick); }; raf.current = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf.current); }, [inView, to, from, duration]); const formatted = (() => { const fixed = value.toFixed(decimals); if (!separator) return fixed; const [int, dec] = fixed.split("."); const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, ","); return dec ? `${grouped}.${dec}` : grouped; })(); return ( {prefix} {formatted} {suffix} ); } ``` ### Number Ticker Odometer digits that roll to the target on view. URL: https://dev.ononc.com/text/number-ticker Path: src/components/text/number-ticker.tsx ```tsx "use client"; import { useEffect, useRef, useState } from "react"; import { useInView } from "motion/react"; import { cn, prefersReducedMotion } from "@/lib/utils"; function Digit({ digit }: { digit: number }) { return ( {Array.from({ length: 10 }, (_, n) => ( {n} ))} ); } export interface NumberTickerProps { value: number; prefix?: string; suffix?: string; className?: string; } /** * NumberTicker — an odometer. Each digit column rolls to its target when the * element scrolls into view. Exposed value is announced via aria-label. */ export function NumberTicker({ value, prefix = "", suffix = "", className }: NumberTickerProps) { const ref = useRef(null); const inView = useInView(ref, { once: true, amount: 0.6 }); const [display, setDisplay] = useState(0); useEffect(() => { if (!inView) return; const duration = prefersReducedMotion() ? 0 : 1300; const start = performance.now(); let raf = 0; const tick = (now: number) => { const p = duration <= 0 ? 1 : Math.min(1, (now - start) / duration); const eased = 1 - Math.pow(1 - p, 3); setDisplay(Math.round(value * eased)); if (p < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [inView, value]); const formatted = display.toLocaleString("en-US"); return ( {prefix} {formatted.split("").map((char, i) => /\d/.test(char) ? ( ) : ( {char} ), )} {suffix} ); } ``` ### Scroll Reveal Words light up one by one as the line scrolls through view. URL: https://dev.ononc.com/text/scroll-reveal Path: src/components/text/scroll-reveal.tsx ```tsx "use client"; import { useRef } from "react"; import { motion, type MotionValue, useReducedMotion, useScroll, useTransform, } from "motion/react"; import { cn } from "@/lib/utils"; function Word({ children, progress, range, reduce, }: { children: string; progress: MotionValue; range: [number, number]; reduce: boolean; }) { const opacity = useTransform(progress, range, reduce ? [1, 1] : [0.15, 1]); return ( {children}  ); } export interface ScrollRevealProps { text: string; className?: string; } /** * ScrollReveal — a paragraph whose words light up one by one, tied to how far * the element has scrolled through the viewport. The text stays readable via * aria-label. */ export function ScrollReveal({ text, className }: ScrollRevealProps) { const ref = useRef(null); const reduce = useReducedMotion() ?? false; const { scrollYProgress } = useScroll({ target: ref, offset: ["start 0.85", "end 0.45"], }); const words = text.split(" "); return (

{words.map((word, i) => ( {word} ))}

); } ``` ### Flip Text Each character flips up into place on view. URL: https://dev.ononc.com/text/flip-text Path: src/components/text/flip-text.tsx ```tsx "use client"; import { motion, type Variants } from "motion/react"; import { cn } from "@/lib/utils"; export interface FlipTextProps { text: string; className?: string; /** Seconds between each character. */ stagger?: number; delay?: number; repeat?: boolean; } /** * FlipText — each character flips up from a -90° tilt into place, staggered, * when it scrolls into view. The full string stays screen-readable. */ export function FlipText({ text, className, stagger = 0.04, delay = 0, repeat = false, }: FlipTextProps) { const chars = Array.from(text); const container: Variants = { hidden: {}, show: { transition: { staggerChildren: stagger, delayChildren: delay } }, }; const item: Variants = { hidden: { rotateX: -110, y: 16, opacity: 0 }, show: { rotateX: 0, y: 0, opacity: 1, transition: { type: "spring", stiffness: 200, damping: 18, mass: 0.9 }, }, }; return ( {chars.map((char, i) => ( {char === " " ? "\u00A0" : char} ))} ); } ``` ### Gradient Underline A gradient underline that grows in on hover and focus. URL: https://dev.ononc.com/text/gradient-underline Path: src/components/text/gradient-underline.tsx ```tsx import type { AnchorHTMLAttributes, ReactNode } from "react"; import { cn } from "@/lib/utils"; export interface GradientUnderlineProps extends AnchorHTMLAttributes { children: ReactNode; } /** * GradientUnderline — a link with a gradient underline that grows in from the * left on hover and keyboard focus. Pure CSS via an animated background-size. */ export function GradientUnderline({ className, children, ...props }: GradientUnderlineProps) { return ( {children} ); } ``` ### Letters Pull-Up Each letter springs up and fades in, staggered, on view. URL: https://dev.ononc.com/text/letters-pull-up Path: src/components/text/letters-pull-up.tsx ```tsx "use client"; import { motion, useReducedMotion, type Variants } from "motion/react"; import { cn } from "@/lib/utils"; export interface LettersPullUpProps { text: string; className?: string; /** Seconds between each letter. */ stagger?: number; delay?: number; /** Replay every time it scrolls into view (default: once). */ repeat?: boolean; } /** * LettersPullUp — each letter fades and rises into place, staggered, when the * element scrolls into view. The full string stays screen-readable via * aria-label; under reduced motion the letters appear in place. */ export function LettersPullUp({ text, className, stagger = 0.04, delay = 0, repeat = false, }: LettersPullUpProps) { const reduce = useReducedMotion(); const chars = Array.from(text); const container: Variants = { hidden: {}, show: { transition: { staggerChildren: stagger, delayChildren: delay } }, }; const item: Variants = { hidden: { y: "0.45em", opacity: 0 }, show: { y: 0, opacity: 1, transition: { type: "spring", stiffness: 320, damping: 26 }, }, }; return ( {chars.map((char, i) => ( {char === " " ? "\u00A0" : char} ))} ); } ``` ### Text Reveal A clip-path mask wipes the text into view. URL: https://dev.ononc.com/text/text-reveal Path: src/components/text/text-reveal.tsx ```tsx "use client"; import { type ReactNode } from "react"; import { motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; export interface TextRevealProps { children: ReactNode; className?: string; /** Direction the wipe travels from. */ direction?: "left" | "right" | "up" | "down"; delay?: number; duration?: number; repeat?: boolean; } const FROM: Record, string> = { left: "inset(0 100% 0 0)", right: "inset(0 0 0 100%)", up: "inset(100% 0 0 0)", down: "inset(0 0 100% 0)", }; /** * TextReveal — the text is wiped into view behind a sliding clip-path mask. * The text is real (no per-character spans), so it stays fully accessible; * under reduced motion it simply appears. */ export function TextReveal({ children, className, direction = "left", delay = 0, duration = 0.8, repeat = false, }: TextRevealProps) { const reduce = useReducedMotion(); const shown = "inset(0 0 0 0)"; return ( {children} ); } ``` ### Decrypt Text Characters resolve out of random glyphs in scattered order. URL: https://dev.ononc.com/text/decrypt-text Path: src/components/text/decrypt-text.tsx ```tsx "use client"; import { useEffect, useRef, useState } from "react"; import { useInView } from "motion/react"; import { cn, prefersReducedMotion } from "@/lib/utils"; export interface DecryptTextProps { text: string; className?: string; /** Glyph pool shown while a character is still encrypted. */ glyphs?: string; /** Milliseconds per reveal step. */ speed?: number; /** Reveal left-to-right instead of in a scattered order. */ sequential?: boolean; /** What starts the effect. */ trigger?: "view" | "hover" | "mount"; } const DEFAULT_GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!<>-_\\/[]{}=+*^?#"; function shuffle(arr: number[]) { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } /** * DecryptText — characters resolve out of random glyphs in a scattered order * (or left-to-right when sequential), like a decrypting cipher. The final text * is exposed via aria-label and the animated output is aria-hidden; reduced * motion shows the text immediately. */ export function DecryptText({ text, className, glyphs = DEFAULT_GLYPHS, speed = 45, sequential = false, trigger = "view", }: DecryptTextProps) { const ref = useRef(null); const inView = useInView(ref, { once: true, amount: 0.4 }); const [output, setOutput] = useState(text); const timer = useRef | null>(null); const run = () => { if (prefersReducedMotion()) return; if (timer.current) clearInterval(timer.current); const indices = Array.from(text) .map((_, i) => i) .filter((i) => text[i] !== " "); const order = sequential ? indices : shuffle(indices); const revealed = new Set(); let step = 0; timer.current = setInterval(() => { if (step < order.length) { revealed.add(order[step]); step += 1; } let out = ""; for (let i = 0; i < text.length; i++) { if (text[i] === " ") out += " "; else if (revealed.has(i)) out += text[i]; else out += glyphs[Math.floor(Math.random() * glyphs.length)]; } setOutput(out); if (step >= order.length) { setOutput(text); if (timer.current) clearInterval(timer.current); } }, speed); }; useEffect(() => { if (trigger === "mount") run(); else if (trigger === "view" && inView) run(); return () => { if (timer.current) clearInterval(timer.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [inView, trigger]); return ( {output} ); } ``` ### Line Reveal Each line slides up from behind a mask, in sequence. URL: https://dev.ononc.com/text/line-reveal Path: src/components/text/line-reveal.tsx ```tsx "use client"; import { motion, useReducedMotion, type Variants } from "motion/react"; import { cn } from "@/lib/utils"; export interface LineRevealProps { /** Each string is one line, revealed in sequence. */ lines: string[]; className?: string; /** Seconds between each line. */ stagger?: number; delay?: number; repeat?: boolean; } /** * LineReveal — each line slides up from behind its own mask, staggered, when the * element scrolls into view. Ideal for multi-line headings. The full text is * exposed via aria-label; reduced motion shows the lines in place. */ export function LineReveal({ lines, className, stagger = 0.12, delay = 0, repeat = false, }: LineRevealProps) { const reduce = useReducedMotion(); const container: Variants = { hidden: {}, show: { transition: { staggerChildren: stagger, delayChildren: delay } }, }; const item: Variants = { hidden: { y: "110%" }, show: { y: 0, transition: { type: "spring", stiffness: 200, damping: 26 }, }, }; return ( {lines.map((line, i) => ( {line} ))} ); } ``` ### Tracking In Letter-spacing expands out of a blur as the text fades in. URL: https://dev.ononc.com/text/tracking-in Path: src/components/text/tracking-in.tsx ```tsx "use client"; import { type ReactNode } from "react"; import { motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; export interface TrackingInProps { children: ReactNode; className?: string; delay?: number; duration?: number; repeat?: boolean; } /** * TrackingIn — the text expands from tight, blurred letter-spacing into place as * it fades in (the classic "tracking-in" title effect), on view. The text is * real and fully accessible; reduced motion shows it settled. */ export function TrackingIn({ children, className, delay = 0, duration = 0.9, repeat = false, }: TrackingInProps) { const reduce = useReducedMotion(); return ( {children} ); } ``` ### Focus Text Focus rolls across the words, blurring all but the active one. URL: https://dev.ononc.com/text/focus-text Path: src/components/text/focus-text.tsx ```tsx "use client"; import { useEffect, useRef, useState } from "react"; import { useInView, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; export interface FocusTextProps { words: string[]; className?: string; /** Milliseconds each word stays in focus. */ interval?: number; } /** * FocusText — the words read as a phrase while focus rolls across them: the * active word is sharp, the rest are dimmed and blurred. Loops while in view and * pauses off-screen. Under reduced motion every word stays sharp. The full * phrase is exposed via aria-label. */ export function FocusText({ words, className, interval = 1500 }: FocusTextProps) { const ref = useRef(null); const inView = useInView(ref); const reduce = useReducedMotion(); const [active, setActive] = useState(0); useEffect(() => { if (reduce || !inView || words.length < 2) return; const id = setInterval( () => setActive((a) => (a + 1) % words.length), interval, ); return () => clearInterval(id); }, [inView, reduce, words.length, interval]); return ( {words.map((word, i) => { const dim = !reduce && i !== active; return ( {word} ); })} ); } ``` ### Text Pressure Letters swell and thicken toward the cursor. URL: https://dev.ononc.com/text/text-pressure Path: src/components/text/text-pressure.tsx ```tsx "use client"; import { type PointerEvent, useEffect, useRef } from "react"; import { cn, prefersReducedMotion } from "@/lib/utils"; export interface TextPressureProps { text: string; className?: string; /** Pixel radius of influence around the pointer. */ radius?: number; /** Maximum extra scale applied to the closest letter. */ maxScale?: number; } /** * TextPressure — letters swell and thicken toward the pointer, like a pressure * field. Pointer-driven (no animation loop); style is written directly to the * DOM in a rAF. The full string is exposed via aria-label, and reduced-motion * users get a static, unreactive label. */ export function TextPressure({ text, className, radius = 130, maxScale = 0.6, }: TextPressureProps) { const charRefs = useRef<(HTMLSpanElement | null)[]>([]); const raf = useRef(0); useEffect(() => () => cancelAnimationFrame(raf.current), []); const apply = (clientX: number, clientY: number) => { charRefs.current.forEach((el) => { if (!el) return; const r = el.getBoundingClientRect(); const cx = r.left + r.width / 2; const cy = r.top + r.height / 2; const dist = Math.hypot(clientX - cx, clientY - cy); const t = Math.max(0, 1 - dist / radius); el.style.transform = `scale(${1 + t * maxScale})`; el.style.fontWeight = String(400 + Math.round(t * 500)); }); }; const onPointerMove = (e: PointerEvent) => { if (prefersReducedMotion()) return; const { clientX, clientY } = e; cancelAnimationFrame(raf.current); raf.current = requestAnimationFrame(() => apply(clientX, clientY)); }; const reset = () => { cancelAnimationFrame(raf.current); charRefs.current.forEach((el) => { if (el) { el.style.transform = ""; el.style.fontWeight = ""; } }); }; return ( {Array.from(text).map((char, i) => ( { charRefs.current[i] = el; }} aria-hidden className="inline-block origin-center transition-transform duration-150 will-change-transform" style={{ whiteSpace: "pre" }} > {char === " " ? "\u00A0" : char} ))} ); } ``` ### Underline Draw A hand-drawn gradient underline draws in beneath the text. URL: https://dev.ononc.com/text/underline-draw Path: src/components/text/underline-draw.tsx ```tsx "use client"; import { type ReactNode, useId } from "react"; import { motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; export interface UnderlineDrawProps { children: ReactNode; className?: string; delay?: number; duration?: number; repeat?: boolean; } /** * UnderlineDraw — a hand-drawn gradient underline that draws itself beneath the * text when it scrolls into view. The underline is decorative (aria-hidden); the * text stays real and accessible. Reduced motion shows the finished stroke. */ export function UnderlineDraw({ children, className, delay = 0.1, duration = 0.7, repeat = false, }: UnderlineDrawProps) { const id = useId(); const reduce = useReducedMotion(); return ( {children} ); } ``` ### Neon Text Glowing sign text with a subtle flicker and deep blur halo. URL: https://dev.ononc.com/text/neon-text Path: src/components/text/neon-text.tsx ```tsx import type { CSSProperties } from "react"; import { cn } from "@/lib/utils"; export interface NeonTextProps { text: string; className?: string; /** Glow color (default: brand-2/cyan). */ color?: string; /** Flicker intensity 0-1. 0 = steady. */ flicker?: number; } /** * NeonText — glowing sign text with an optional subtle flicker. Two overlay * layers create depth: a wide blurred glow behind crisp text. Pure CSS; * freezes under reduced-motion. Screen readers get the raw text. */ export function NeonText({ text, className, color = "var(--brand-2)", flicker = 0.3, }: NeonTextProps) { return ( {/* Wide blur glow behind */} 0 ? `neon-flicker ${(3 - flicker * 2.4).toFixed(2)}s ease-in-out infinite alternate` : "none", } as CSSProperties } > {text} {/* Sharp text layer */} {text} ); } ``` ### Holographic Text Iridescent text whose colors shift through the spectrum continuously. URL: https://dev.ononc.com/text/holographic-text Path: src/components/text/holographic-text.tsx ```tsx import type { CSSProperties, HTMLAttributes, ReactNode } from "react"; import { cn } from "@/lib/utils"; export interface HolographicTextProps extends HTMLAttributes { /** Speed of the hue rotation in seconds. */ speed?: number; children: ReactNode; } /** * HolographicText — iridescent text whose colors continuously shift through * the spectrum, with a subtle shimmer overlay. Pure CSS clip + hue-rotate. * Freezes under reduced-motion. */ export function HolographicText({ className, speed = 3.5, children, ...props }: HolographicTextProps) { return ( {children} ); } ``` ### Shadow Text Multi-layered colored shadows that pulse and create dimensional depth. URL: https://dev.ononc.com/text/shadow-text Path: src/components/text/shadow-text.tsx ```tsx import type { CSSProperties, HTMLAttributes, ReactNode } from "react"; import { cn } from "@/lib/utils"; export interface ShadowTextProps extends HTMLAttributes { /** Glow color for the layered shadows (default: brand). */ color?: string; children: ReactNode; } /** * ShadowText — multi-layered colored text-shadows that shift and pulse, * creating a dimensional glow around the text. Pure CSS; freezes under * reduced-motion. Stays readable via the crisp foreground layer. */ export function ShadowText({ className, color = "var(--brand)", children, ...props }: ShadowTextProps) { return ( {children} ); } ``` ### Breathing Text Text gently expands and contracts like a breathing rhythm. URL: https://dev.ononc.com/text/breathing-text Path: src/components/text/breathing-text.tsx ```tsx import type { CSSProperties, HTMLAttributes, ReactNode } from "react"; import { cn } from "@/lib/utils"; export interface BreathingTextProps extends HTMLAttributes { /** Seconds for one full breath cycle. */ duration?: number; /** Maximum scale factor (e.g. 1.06). */ scale?: number; children: ReactNode; } /** * BreathingText — text gently expands and contracts like a breathing rhythm. * Uses the global `float` keyframe with a scale-only variant. Pure CSS; * settles under reduced-motion. Stays fully readable. */ export function BreathingText({ className, duration = 4, scale = 1.04, children, ...props }: BreathingTextProps) { return ( {children} ); } ``` ### Ghost Text Ethereal text that fades in and out like an apparition, with a subtle drift. URL: https://dev.ononc.com/text/ghost-text Path: src/components/text/ghost-text.tsx ```tsx import type { CSSProperties } from "react"; import { cn } from "@/lib/utils"; export interface GhostTextProps { text: string; className?: string; /** Seconds for one apparition cycle. */ duration?: number; } /** * GhostText — ethereal text that continuously fades in and out like an * apparition, with a subtle vertical drift. Pure CSS; freezes under * reduced-motion. Accessible via aria-label. */ export function GhostText({ text, className, duration = 3.4, }: GhostTextProps) { return ( {Array.from(text).map((char, i) => ( {char === " " ? "\u00A0" : char} ))} ); } ``` ### Glow Pulse Text Expanding glow rings pulse outward from the text like a heartbeat. URL: https://dev.ononc.com/text/glow-pulse-text Path: src/components/text/glow-pulse-text.tsx ```tsx import type { CSSProperties, HTMLAttributes, ReactNode } from "react"; import { cn } from "@/lib/utils"; export interface GlowPulseTextProps extends HTMLAttributes { /** Glow color (default: brand). */ color?: string; /** Seconds for one pulse cycle. */ duration?: number; children: ReactNode; } /** * GlowPulseText — text with an expanding/contracting glow halo like a * heartbeat. Two rings pulse outward alternating, creating depth. Pure CSS; * freezes under reduced-motion. */ export function GlowPulseText({ className, color = "var(--brand)", duration = 2.2, children, ...props }: GlowPulseTextProps) { return ( {/* Outer pulse ring */} {children} {/* Inner pulse ring — offset timing */} {children} {children} ); } ``` ### Ticker Text News-ticker style scrolling text that loops seamlessly. URL: https://dev.ononc.com/text/ticker-text Path: src/components/text/ticker-text.tsx ```tsx import type { CSSProperties, HTMLAttributes, ReactNode } from "react"; import { cn } from "@/lib/utils"; export interface TickerTextProps extends HTMLAttributes { /** Seconds for one full scroll cycle. */ speed?: number; /** Direction of scroll. */ direction?: "left" | "right"; /** Pause on hover (default true). */ pauseOnHover?: boolean; children: ReactNode; } /** * TickerText — a news-ticker style scrolling text. Content is duplicated so * the loop is seamless. Pure CSS marquee; freezes under reduced-motion. */ export function TickerText({ className, speed = 16, direction = "left", pauseOnHover = true, children, ...props }: TickerTextProps) { return (
_span]:[animation-play-state:paused]", className, )} {...props} > {children} {/* Duplicate for seamless loop */} {children}
); } ``` ### Striped Text Animated diagonal stripes flow through the letters. URL: https://dev.ononc.com/text/striped-text Path: src/components/text/striped-text.tsx ```tsx import type { CSSProperties, HTMLAttributes, ReactNode } from "react"; import { cn } from "@/lib/utils"; export interface StripedTextProps extends HTMLAttributes { /** Colors to stripe through (default: brand spectrum). */ colors?: string[]; /** Stripe angle in degrees. */ angle?: number; /** Seconds for one full cycle. */ speed?: number; children: ReactNode; } /** * StripedText — animated diagonal stripes flow through the text via * background-clip. Pure CSS; freezes under reduced-motion. */ export function StripedText({ className, colors = ["var(--brand)", "var(--brand-2)", "var(--brand-3)", "var(--brand-ink)"], angle = 45, speed = 4, children, ...props }: StripedTextProps) { const stops = colors .flatMap((c, i, arr) => { const start = (i / arr.length) * 100; const end = ((i + 1) / arr.length) * 100; return [`${c} ${start}%`, `${c} ${end}%`]; }) .join(", "); return ( {children} ); } ``` ### Staggered Fade Each character fades, slides, and rotates into place one by one. URL: https://dev.ononc.com/text/staggered-fade Path: src/components/text/staggered-fade.tsx ```tsx "use client"; import { Fragment } from "react"; import { motion, type Variants } from "motion/react"; import { cn } from "@/lib/utils"; export interface StaggeredFadeProps { text: string; className?: string; /** Seconds between each character entry. */ stagger?: number; /** Initial rotation in degrees. */ rotate?: number; delay?: number; repeat?: boolean; } /** * StaggeredFade — each character fades in, slides up, and rotates into * place one by one on view. Accessible via aria-label. */ export function StaggeredFade({ text, className, stagger = 0.04, rotate = 12, delay = 0, repeat = false, }: StaggeredFadeProps) { const chars = Array.from(text); const container: Variants = { hidden: {}, show: { transition: { staggerChildren: stagger, delayChildren: delay } }, }; const item: Variants = { hidden: { opacity: 0, y: 18, rotate: rotate }, show: { opacity: 1, y: 0, rotate: 0, transition: { type: "spring", stiffness: 180, damping: 18 }, }, }; return ( {chars.map((char, i) => ( {char === " " ? "\u00A0" : char} ))} ); } ``` ### Rising Text Each character rises up from below with a spring settle. URL: https://dev.ononc.com/text/rising-text Path: src/components/text/rising-text.tsx ```tsx "use client"; import { Fragment } from "react"; import { motion, type Variants } from "motion/react"; import { cn } from "@/lib/utils"; export interface RisingTextProps { text: string; className?: string; /** Seconds between each character. */ stagger?: number; /** Vertical rise distance in px. */ rise?: number; delay?: number; repeat?: boolean; } /** * RisingText — each character rises up from below, fades in, and scales * slightly larger before settling. Staggered on scroll into view. Accessible * via aria-label. */ export function RisingText({ text, className, stagger = 0.03, rise = 32, delay = 0, repeat = false, }: RisingTextProps) { const chars = Array.from(text); const container: Variants = { hidden: {}, show: { transition: { staggerChildren: stagger, delayChildren: delay } }, }; const item: Variants = { hidden: { opacity: 0, y: rise, scale: 0.7 }, show: { opacity: 1, y: 0, scale: 1, transition: { type: "spring", stiffness: 300, damping: 24 }, }, }; return ( {chars.map((char, i) => ( {char === " " ? "\u00A0" : char} ))} ); } ``` ### Kinetic Reveal Words burst in with scale, rotation, and blur in a springy sequence. URL: https://dev.ononc.com/text/kinetic-reveal Path: src/components/text/kinetic-reveal.tsx ```tsx "use client"; import { Fragment } from "react"; import { motion, type Variants } from "motion/react"; import { cn } from "@/lib/utils"; export interface KineticRevealProps { text: string; className?: string; /** Seconds between each word. */ stagger?: number; delay?: number; repeat?: boolean; } /** * KineticReveal — each word bursts in with scale, rotation, and a blur that * resolves, staggered on scroll into view. Energetic entry suited for hero * headlines. Accessible via aria-label. */ export function KineticReveal({ text, className, stagger = 0.1, delay = 0, repeat = false, }: KineticRevealProps) { const words = text.split(" "); const container: Variants = { hidden: {}, show: { transition: { staggerChildren: stagger, delayChildren: delay } }, }; const item: Variants = { hidden: { opacity: 0, scale: 0.3, rotate: -8, filter: "blur(12px)", }, show: { opacity: 1, scale: 1, rotate: 0, filter: "blur(0px)", transition: { type: "spring", stiffness: 200, damping: 16, mass: 0.8, }, }, }; return ( {words.map((word, i) => ( {word} ))} ); } ``` ### Fire Text Letters flicker with flame-like scale, opacity, and blur in a continuous loop. URL: https://dev.ononc.com/text/fire-text Path: src/components/text/fire-text.tsx ```tsx "use client"; import { Fragment } from "react"; import { motion, type Variants } from "motion/react"; import type { CSSProperties } from "react"; import { cn } from "@/lib/utils"; export interface FireTextProps { text: string; className?: string; /** Seconds for one full flicker cycle per letter. */ duration?: number; } /** * FireText — letters flicker with flame-like scale, opacity, color, and blur * shifts, each offset slightly. Uses motion variants inside a container that * animates continuously. Accessible via aria-label. */ export function FireText({ text, className, duration = 1.6, }: FireTextProps) { const chars = Array.from(text); const container: Variants = { animate: { transition: { staggerChildren: 0.07, repeat: Infinity, repeatType: "loop", }, }, }; const flame = (i: number): Variants => ({ animate: { scale: [1, 1.06, 0.96, 1.04, 0.98, 1], opacity: [0.85, 1, 0.8, 1, 0.88, 0.85], filter: [ "blur(0px)", "blur(0.6px)", "blur(0px)", "blur(0.4px)", "blur(0px)", "blur(0px)", ], transition: { duration, delay: i * 0.02, ease: "easeInOut", repeat: Infinity, repeatType: "loop", }, }, }); return ( {chars.map((char, i) => ( {char === " " ? "\u00A0" : char} ))} ); } ``` ### Clip-Draw Text SVG stroke outline draws in character-by-character, then fills. URL: https://dev.ononc.com/text/clip-draw-text Path: src/components/text/clip-draw-text.tsx ```tsx "use client"; import { motion, type Variants } from "motion/react"; import { cn } from "@/lib/utils"; export interface ClipDrawTextProps { text: string; className?: string; /** Stroke color. */ color?: string; /** Seconds between each character. */ stagger?: number; delay?: number; } /** * ClipDrawText — each character's outline is drawn stroke-by-stroke using * SVG text with stroke-dasharray animation. On scroll, the outline "draws in" * and fills. Accessible via aria-label on the wrapper. */ export function ClipDrawText({ text, className, color = "var(--brand)", stagger = 0.06, delay = 0, }: ClipDrawTextProps) { const fill: Variants = { hidden: { opacity: 0 }, show: { opacity: 1, transition: { delay: stagger * text.length * 0.35 + delay, duration: 0.4 }, }, }; return ( {text} {/* Fill that appears after stroke completes */} {text} ); } ``` ### Morphing Text Each word scales down while the next scales up in a smooth morph. URL: https://dev.ononc.com/text/morphing-text Path: src/components/text/morphing-text.tsx ```tsx "use client"; import { useEffect, useState } from "react"; import { AnimatePresence, motion } from "motion/react"; import { cn } from "@/lib/utils"; export interface MorphingTextProps { /** Words to cycle through. */ words: string[]; /** Display time per word in ms. */ interval?: number; className?: string; } /** * MorphingText — each word scales down and fades out while the next scales * up and fades in, creating a smooth morph transition. Resets at end of list. */ export function MorphingText({ words, interval = 2600, className, }: MorphingTextProps) { const [index, setIndex] = useState(0); useEffect(() => { if (words.length <= 1) return; const timer = setInterval(() => { setIndex((prev) => (prev + 1) % words.length); }, interval); return () => clearInterval(timer); }, [words, interval]); if (words.length === 0) return null; return ( {words[index]} ); } ``` ### Split Flap Retro split-flap display. Each word flips down like a departure board. URL: https://dev.ononc.com/text/split-flap Path: src/components/text/split-flap.tsx ```tsx "use client"; import { useEffect, useState } from "react"; import { AnimatePresence, motion } from "motion/react"; import { cn } from "@/lib/utils"; export interface SplitFlapProps { /** Words to cycle through, split-flap style. */ words: string[]; /** Display time per word in ms. */ interval?: number; className?: string; } /** * SplitFlap — old-school split-flap display. Each word flips down (top half * fades while bottom half scales in), one after another. Accessible via * aria-live. */ export function SplitFlap({ words, interval = 2800, className, }: SplitFlapProps) { const [index, setIndex] = useState(0); useEffect(() => { if (words.length <= 1) return; const timer = setInterval(() => { setIndex((prev) => (prev + 1) % words.length); }, interval); return () => clearInterval(timer); }, [words, interval]); if (words.length === 0) return null; return ( {words[index]} ); } ``` ### Scatter Text Characters fly apart and reassemble on hover like scattered cards. URL: https://dev.ononc.com/text/scatter-text Path: src/components/text/scatter-text.tsx ```tsx "use client"; import { Fragment, useEffect, useState } from "react"; import { AnimatePresence, motion } from "motion/react"; import { cn } from "@/lib/utils"; export interface ScatterTextProps { text: string; className?: string; /** Trigger: "hover" or "inView" (default: hover). */ trigger?: "hover" | "inView"; /** If trigger=inView, whether to repeat. */ repeat?: boolean; } /** * ScatterText — on hover, characters fly apart with random offsets and blur, * then reassemble when the cursor leaves. On trigger=inView, scatters once * on scroll then settles. Accessible via aria-label. */ export function ScatterText({ text, className, trigger = "hover", repeat = false, }: ScatterTextProps) { const chars = Array.from(text); const [scattered, setScattered] = useState(false); // Seed per character for deterministic random offsets. const seededRandom = (seed: number) => { let t = seed + 0x6d2b79f5; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; return ( trigger === "hover" && setScattered(true)} onMouseLeave={() => trigger === "hover" && setScattered(false)} {...(trigger === "inView" ? { onViewportEnter: () => { setScattered(true); setTimeout(() => setScattered(false), 800); }, } : {})} > {chars.map((char, i) => { const r1 = seededRandom(i * 7 + 1); const r2 = seededRandom(i * 13 + 3); const dx = (r1 - 0.5) * 80; const dy = (r2 - 0.5) * 60 - 10; const rot = (r1 - 0.5) * 90; return ( {char === " " ? "\u00A0" : char} ); })} ); } ``` ### Perspective Text Text tilts in 3D towards the mouse with a parallax glow. URL: https://dev.ononc.com/text/perspective-text Path: src/components/text/perspective-text.tsx ```tsx "use client"; import { type PointerEvent, useCallback, useRef } from "react"; import { cn } from "@/lib/utils"; export interface PerspectiveTextProps { text: string; className?: string; /** Maximum tilt in degrees. */ tilt?: number; } /** * PerspectiveText — text tilts in 3D toward the mouse pointer with * a subtle parallax glow. Pointer-driven via CSS transform on a rAF. * Reduced-motion users see static text. */ export function PerspectiveText({ text, className, tilt = 16, }: PerspectiveTextProps) { const rootRef = useRef(null); const raf = useRef(0); const onPointerMove = useCallback( (e: PointerEvent) => { const el = rootRef.current; if (!el) return; const rect = el.getBoundingClientRect(); const x = (e.clientX - rect.left) / rect.width - 0.5; const y = (e.clientY - rect.top) / rect.height - 0.5; cancelAnimationFrame(raf.current); raf.current = requestAnimationFrame(() => { el.style.transform = `perspective(600px) rotateY(${x * tilt}deg) rotateX(${-y * tilt}deg)`; el.style.textShadow = [ `${x * 12}px ${-y * 12}px 18px rgba(139, 92, 246, 0.3)`, `${-x * 8}px ${y * 8}px 14px rgba(8, 145, 178, 0.25)`, ].join(", "); }); }, [tilt], ); const reset = useCallback(() => { cancelAnimationFrame(raf.current); const el = rootRef.current; if (el) { el.style.transform = "perspective(600px) rotateY(0deg) rotateX(0deg)"; el.style.textShadow = ""; } }, []); return ( {Array.from(text).map((char, i) => ( {char === " " ? "\u00A0" : char} ))} ); } ``` ### Elastic Text Letters stretch toward the pointer like a rubber band. URL: https://dev.ononc.com/text/elastic-text Path: src/components/text/elastic-text.tsx ```tsx "use client"; import { type PointerEvent, useCallback, useRef } from "react"; import { cn } from "@/lib/utils"; export interface ElasticTextProps { text: string; className?: string; /** Radius of elastic influence around the pointer (px). */ radius?: number; /** Maximum extra scale applied to letters near the cursor. */ maxScale?: number; } /** * ElasticText — letters stretch toward the pointer like rubber, with a * gravity-like falloff. Pointer-driven on a rAF; reduced-motion users * see static text. Screen readers get the raw text via aria-label. */ export function ElasticText({ text, className, radius = 120, maxScale = 0.5, }: ElasticTextProps) { const charRefs = useRef<(HTMLSpanElement | null)[]>([]); const raf = useRef(0); const onPointerMove = useCallback( (e: PointerEvent) => { const { clientX, clientY } = e; cancelAnimationFrame(raf.current); raf.current = requestAnimationFrame(() => { charRefs.current.forEach((el) => { if (!el) return; const r = el.getBoundingClientRect(); const cx = r.left + r.width / 2; const cy = r.top + r.height / 2; const dx = clientX - cx; const dist = Math.hypot(dx, clientY - cy); const t = Math.max(0, 1 - dist / radius); const scaleX = 1 + t * maxScale; const scaleY = 1 - t * maxScale * 0.4; el.style.transform = `scale(${scaleX}, ${scaleY})`; }); }); }, [radius, maxScale], ); const reset = useCallback(() => { cancelAnimationFrame(raf.current); charRefs.current.forEach((el) => { if (el) el.style.transform = ""; }); }, []); return ( {Array.from(text).map((char, i) => ( { charRefs.current[i] = el; }} aria-hidden className="inline-block origin-center" style={{ whiteSpace: "pre", transition: "transform 0.2s ease-out" }} > {char === " " ? "\u00A0" : char} ))} ); } ``` ### Ripple Text Letters undulate away from the cursor like ripples on water. URL: https://dev.ononc.com/text/ripple-text Path: src/components/text/ripple-text.tsx ```tsx "use client"; import { type PointerEvent, useCallback, useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; export interface RippleTextProps { text: string; className?: string; /** Radius of the ripple wave in px. */ radius?: number; /** Maximum vertical displacement of letters. */ amplitude?: number; } /** * RippleText — letters undulate away from the cursor like a ripple on water. * Pointer-driven on a rAF; reduced-motion users see static text. Accessible * via aria-label. */ export function RippleText({ text, className, radius = 100, amplitude = 14, }: RippleTextProps) { const charRefs = useRef<(HTMLSpanElement | null)[]>([]); const raf = useRef(0); const onPointerMove = useCallback( (e: PointerEvent) => { const { clientX, clientY } = e; cancelAnimationFrame(raf.current); raf.current = requestAnimationFrame(() => { charRefs.current.forEach((el) => { if (!el) return; const r = el.getBoundingClientRect(); const cx = r.left + r.width / 2; const cy = r.top + r.height / 2; const dist = Math.hypot(clientX - cx, clientY - cy); const t = Math.max(0, 1 - dist / radius); // Ripple: sine wave over distance const wave = Math.sin(t * Math.PI * 3) * t * amplitude; el.style.transform = `translateY(${wave}px)`; }); }); }, [radius, amplitude], ); const reset = useCallback(() => { cancelAnimationFrame(raf.current); charRefs.current.forEach((el) => { if (el) el.style.transform = ""; }); }, []); return ( {Array.from(text).map((char, i) => ( { charRefs.current[i] = el; }} aria-hidden className="inline-block origin-center" style={{ whiteSpace: "pre", transition: "transform 0.15s ease-out" }} > {char === " " ? "\u00A0" : char} ))} ); } ``` ### Twinkle Text Each character twinkles with staggered random delays like starlight. URL: https://dev.ononc.com/text/twinkle-text Path: src/components/text/twinkle-text.tsx ```tsx import type { CSSProperties } from "react"; import { cn } from "@/lib/utils"; export interface TwinkleTextProps { text: string; className?: string; /** Base duration per character in seconds. */ duration?: number; } /** * TwinkleText — each character twinkles with staggered random delays like * starlight. Pure CSS; freezes under reduced-motion. Accessible via aria-label. */ export function TwinkleText({ text, className, duration = 2.2, }: TwinkleTextProps) { const mulberry32 = (seed: number) => { let t = seed + 0x6d2b79f5; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; return ( {Array.from(text).map((char, i) => { const delay = (mulberry32(i * 31 + 7) * duration).toFixed(2); const dur = (duration * 0.6 + mulberry32(i * 17 + 3) * duration * 0.8).toFixed(2); return ( {char === " " ? "\u00A0" : char} ); })} ); } ``` ### Pulse Wave Text A brightness wave sweeps left to right through the text in a loop. URL: https://dev.ononc.com/text/pulse-wave-text Path: src/components/text/pulse-wave-text.tsx ```tsx import type { CSSProperties, HTMLAttributes, ReactNode } from "react"; import { cn } from "@/lib/utils"; export interface PulseWaveTextProps extends HTMLAttributes { /** Glow color. */ color?: string; /** Seconds for one full wave cycle. */ duration?: number; children: ReactNode; } /** * PulseWaveText — a brightness/glow wave sweeps left-to-right through the * text in a continuous loop. Uses a gradient mask animated over the text * background. Pure CSS; freezes under reduced-motion. */ export function PulseWaveText({ className, color = "var(--brand-2)", duration = 3, children, ...props }: PulseWaveTextProps) { return ( {children} ); } ``` ### Color Cycle Text Each character independently cycles through the brand palette. URL: https://dev.ononc.com/text/color-cycle-text Path: src/components/text/color-cycle-text.tsx ```tsx import type { CSSProperties } from "react"; import { cn } from "@/lib/utils"; export interface ColorCycleTextProps { text: string; className?: string; /** Seconds for one full cycle per character. */ duration?: number; } /** * ColorCycleText — each character independently cycles through the brand * palette, staggered so a rainbow wave travels through the text. Pure CSS * with per-character keyframes; freezes under reduced-motion. Accessible * via aria-label. */ export function ColorCycleText({ text, className, duration = 4, }: ColorCycleTextProps) { const PALETTE = [ "var(--brand)", // violet "var(--brand-2)", // cyan "var(--brand-3)", // rose "var(--brand-ink)", // deep violet ]; return ( {Array.from(text).map((char, i) => { const keyframes = ["cc-a", "cc-b", "cc-c", "cc-d"][i % 4]; return ( {char === " " ? "\u00A0" : char} ); })} ); } ``` ### Flicker In Text Rapid stroboscopic on/off flicker before text stabilizes, like dying neon. URL: https://dev.ononc.com/text/flicker-in-text Path: src/components/text/flicker-in-text.tsx ```tsx import type { CSSProperties } from "react"; import { cn } from "@/lib/utils"; export interface FlickerInTextProps { text: string; className?: string; } /** * FlickerInText — text rapidly strobes on/off (like a dying fluorescent) * before stabilizing permanently. Pure CSS animation that runs once; * accessible via aria-label. */ export function FlickerInText({ text, className }: FlickerInTextProps) { return ( {Array.from(text).map((char, i) => ( {char === " " ? "\u00A0" : char} ))} ); } ``` ### Revolve Text Each character does a 360 spin on its own axis, staggered on view. URL: https://dev.ononc.com/text/revolve-text Path: src/components/text/revolve-text.tsx ```tsx "use client"; import { Fragment } from "react"; import { motion, type Variants } from "motion/react"; import { cn } from "@/lib/utils"; export interface RevolveTextProps { text: string; className?: string; /** Seconds between each character. */ stagger?: number; delay?: number; repeat?: boolean; } /** * RevolveText — each character does a full 360° rotateY spin into place, * staggered on scroll into view. The 3D perspective gives a coin-flip feel. * Accessible via aria-label. */ export function RevolveText({ text, className, stagger = 0.05, delay = 0, repeat = false, }: RevolveTextProps) { const chars = Array.from(text); const container: Variants = { hidden: {}, show: { transition: { staggerChildren: stagger, delayChildren: delay } }, }; const item: Variants = { hidden: { rotateY: 180, opacity: 0, scale: 0.4 }, show: { rotateY: 360, opacity: 1, scale: 1, transition: { type: "spring", stiffness: 160, damping: 16, mass: 0.6 }, }, }; return ( {chars.map((char, i) => ( {char === " " ? "\u00A0" : char} ))} ); } ``` ### Gravity Text Letters drop in from above with heavy bounce physics and wobble settle. URL: https://dev.ononc.com/text/gravity-text Path: src/components/text/gravity-text.tsx ```tsx "use client"; import { Fragment } from "react"; import { motion, type Variants } from "motion/react"; import { cn } from "@/lib/utils"; export interface GravityTextProps { text: string; className?: string; /** Seconds between each character. */ stagger?: number; /** Drop height in px. */ drop?: number; delay?: number; repeat?: boolean; } /** * GravityText — each character drops in from above with a heavy bounce, * settling with a secondary wobble. The physics feel (low damping, high mass) * differs from RisingText which uses a lighter spring. Accessible via * aria-label. */ export function GravityText({ text, className, stagger = 0.04, drop = 60, delay = 0, repeat = false, }: GravityTextProps) { const chars = Array.from(text); const container: Variants = { hidden: {}, show: { transition: { staggerChildren: stagger, delayChildren: delay } }, }; const item: Variants = { hidden: { y: -drop, opacity: 0 }, show: { y: 0, opacity: 1, transition: { type: "spring", stiffness: 120, damping: 8, mass: 1.4 }, }, }; return ( {chars.map((char, i) => ( {char === " " ? "\u00A0" : char} ))} ); } ``` ### Zoom Blur Text Characters zoom in from a huge distant scale with extreme motion blur. URL: https://dev.ononc.com/text/zoom-blur-text Path: src/components/text/zoom-blur-text.tsx ```tsx "use client"; import { Fragment } from "react"; import { motion, type Variants } from "motion/react"; import { cn } from "@/lib/utils"; export interface ZoomBlurTextProps { text: string; className?: string; /** Seconds between each character. */ stagger?: number; /** Starting scale. */ fromScale?: number; delay?: number; repeat?: boolean; } /** * ZoomBlurText — each character zooms in from a huge distant scale with * extreme motion blur, then snaps into place. Dramatic entry suited for * hero impact moments. Accessible via aria-label. */ export function ZoomBlurText({ text, className, stagger = 0.05, fromScale = 4, delay = 0, repeat = false, }: ZoomBlurTextProps) { const chars = Array.from(text); const container: Variants = { hidden: {}, show: { transition: { staggerChildren: stagger, delayChildren: delay } }, }; const item: Variants = { hidden: { scale: fromScale, opacity: 0, filter: "blur(20px)" }, show: { scale: 1, opacity: 1, filter: "blur(0px)", transition: { type: "spring", stiffness: 240, damping: 22, mass: 0.7 }, }, }; return ( {chars.map((char, i) => ( {char === " " ? "\u00A0" : char} ))} ); } ``` ### Warp In Text Characters warp in from a compressed, skewed state with an energetic snap. URL: https://dev.ononc.com/text/warp-in-text Path: src/components/text/warp-in-text.tsx ```tsx "use client"; import { Fragment } from "react"; import { motion, type Variants } from "motion/react"; import { cn } from "@/lib/utils"; export interface WarpInTextProps { text: string; className?: string; /** Seconds between each character. */ stagger?: number; delay?: number; repeat?: boolean; } /** * WarpInText — each character warps in from a compressed, skewed, and * blurred state, stretching into place with an energetic snap. Differs * from ZoomBlur by using skewX and scaleX compression instead of uniform * scale. Accessible via aria-label. */ export function WarpInText({ text, className, stagger = 0.04, delay = 0, repeat = false, }: WarpInTextProps) { const chars = Array.from(text); const container: Variants = { hidden: {}, show: { transition: { staggerChildren: stagger, delayChildren: delay } }, }; const item: Variants = { hidden: { scaleX: 0.1, skewX: -20, opacity: 0, filter: "blur(6px)" }, show: { scaleX: 1, skewX: 0, opacity: 1, filter: "blur(0px)", transition: { type: "spring", stiffness: 280, damping: 20, mass: 0.5 }, }, }; return ( {chars.map((char, i) => ( {char === " " ? "\u00A0" : char} ))} ); } ``` ### Expand Text Text expands from compressed letter-spacing into natural breathing room. URL: https://dev.ononc.com/text/expand-text Path: src/components/text/expand-text.tsx ```tsx "use client"; import { Fragment } from "react"; import { motion, type Variants } from "motion/react"; import { cn } from "@/lib/utils"; export interface ExpandTextProps { text: string; className?: string; /** Seconds between each word. */ stagger?: number; delay?: number; repeat?: boolean; } /** * ExpandText — text expands from heavily compressed letter-spacing into * natural breathing room, staggered word by word. The kerning opens up * like an accordion. Accessible via aria-label. */ export function ExpandText({ text, className, stagger = 0.12, delay = 0, repeat = false, }: ExpandTextProps) { const words = text.split(" "); const container: Variants = { hidden: { letterSpacing: "-0.15em", opacity: 0 }, show: { letterSpacing: "normal", opacity: 1, transition: { staggerChildren: stagger, delayChildren: delay, letterSpacing: { duration: 1.2, ease: [0.32, 0.72, 0, 1] }, opacity: { duration: 0.4 }, }, }, }; const item: Variants = { hidden: { opacity: 0, scale: 0.92 }, show: { opacity: 1, scale: 1, transition: { duration: 0.6, ease: [0.25, 1, 0.5, 1] }, }, }; return ( {words.map((word, i) => ( {word} {i < words.length - 1 ? "\u00A0" : null} ))} ); } ``` ### Dual Tone Text Two text layers slide apart vertically as you scroll. URL: https://dev.ononc.com/text/dual-tone-text Path: src/components/text/dual-tone-text.tsx ```tsx "use client"; import { useRef } from "react"; import { motion, useScroll, useTransform } from "motion/react"; import { cn } from "@/lib/utils"; export interface DualToneTextProps { topText: string; bottomText: string; className?: string; } /** * DualToneText — two text layers slide apart vertically as the user scrolls, * revealing a split-color effect. The top layer moves up while the bottom * moves down. Accessible via separate aria labels on each layer. */ export function DualToneText({ topText, bottomText, className, }: DualToneTextProps) { const ref = useRef(null); const { scrollYProgress } = useScroll({ target: ref, offset: ["start end", "end start"], }); const topY = useTransform(scrollYProgress, [0, 0.5], ["0%", "-32%"]); const bottomY = useTransform(scrollYProgress, [0, 0.5], ["0%", "32%"]); const topOpacity = useTransform(scrollYProgress, [0, 0.5, 0.7], [1, 1, 0.3]); const bottomOpacity = useTransform(scrollYProgress, [0, 0.5, 0.7], [1, 1, 0.3]); return (
{topText} {bottomText}
); } ``` ### Swap Cascade Random glyphs cascade left to right, locking into the target text. URL: https://dev.ononc.com/text/swap-cascade Path: src/components/text/swap-cascade.tsx ```tsx "use client"; import { useEffect, useMemo, useState } from "react"; import { cn } from "@/lib/utils"; export interface SwapCascadeProps { text: string; className?: string; } const GLYPHS = "abcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?"; /** * SwapCascade — each character rapidly swaps between random glyphs in a * cascading wave from left to right, finally settling into the target string * one by one. Runs once on mount. Accessible via aria-label. */ export function SwapCascade({ text, className }: SwapCascadeProps) { return ; } function SwapCascadeText({ text, className }: SwapCascadeProps) { const chars = useMemo(() => Array.from(text), [text]); const [progress, setProgress] = useState(0); useEffect(() => { const total = chars.length; const duration = 1200 + total * 80; const start = performance.now(); let frameId = 0; const tick = () => { const elapsed = performance.now() - start; const p = Math.min(elapsed / duration, 1); setProgress(p); if (p < 1) { frameId = requestAnimationFrame(tick); } }; frameId = requestAnimationFrame(tick); return () => cancelAnimationFrame(frameId); }, [chars.length]); const getGlyph = (i: number, p: number) => { const revealPoint = (i / chars.length) * 1.0; if (p > revealPoint) return chars[i]; const seed = Math.floor(i * 17 + p * 300); return GLYPHS[seed % GLYPHS.length]; }; return ( {chars.map((char, i) => { const display = char === " " ? "\u00A0" : getGlyph(i, progress); const revealed = progress > (i / chars.length) * 1.0; return ( {display} ); })} ); } ``` ### Digi-Clock Text Characters flip through glyphs like a digital clock, then resolve. URL: https://dev.ononc.com/text/digi-clock-text Path: src/components/text/digi-clock-text.tsx ```tsx "use client"; import { useEffect, useState } from "react"; import { cn } from "@/lib/utils"; export interface DigiClockTextProps { text: string; className?: string; /** Seconds before the scramble resolves. */ duration?: number; } /** * DigiClockText — characters rapidly flip through random alphanumeric * glyphs like a digital clock or old airport board, finally resolving * into the target text. Runs once on mount. Accessible via aria-label. */ export function DigiClockText({ text, className, duration = 2, }: DigiClockTextProps) { const chars = Array.from(text); const [frame, setFrame] = useState(0); const GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; useEffect(() => { const start = performance.now(); let id: number; const tick = () => { const elapsed = performance.now() - start; const p = Math.min(elapsed / (duration * 1000), 1); setFrame(Math.floor(p * 40)); if (p < 1) { id = requestAnimationFrame(tick); } }; id = requestAnimationFrame(tick); return () => cancelAnimationFrame(id); }, [text, duration]); const getChar = (i: number, f: number) => { const pGlobal = f / 40; const reveal = (i / chars.length) * 1.0; if (pGlobal > reveal) return chars[i]; const seed = i * 31 + f * 7; return GLYPHS[seed % GLYPHS.length]; }; return ( {chars.map((char, i) => { if (char === " ") { return ( {"\u00A0"} ); } const display = getChar(i, frame); const stable = (frame / 40) > (i / chars.length) * 1.0; return ( {display} ); })} ); } ``` ### Shake Text Text vibrates with random micro-displacements on pointer move. URL: https://dev.ononc.com/text/shake-text Path: src/components/text/shake-text.tsx ```tsx "use client"; import { type PointerEvent, useCallback, useRef, type ReactNode } from "react"; import { cn } from "@/lib/utils"; export interface ShakeTextProps { text: string; className?: string; /** Shake intensity in px. */ intensity?: number; } /** * ShakeText — text vibrates with random micro-displacements on pointer move, * settling instantly when the pointer leaves. No animation loop; direct DOM * rAF writes. Reduced-motion users get static text. Accessible via aria-label. */ export function ShakeText({ text, className, intensity = 2.5, }: ShakeTextProps) { const rootRef = useRef(null); const raf = useRef(0); const mulberry32 = (seed: number) => { let t = seed + 0x6d2b79f5; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; const onPointerMove = useCallback( (e: PointerEvent) => { const el = rootRef.current; if (!el) return; cancelAnimationFrame(raf.current); const seed = Math.floor(e.timeStamp / 40); raf.current = requestAnimationFrame(() => { const dx = (mulberry32(seed) - 0.5) * intensity * 2; const dy = (mulberry32(seed + 100) - 0.5) * intensity * 2; el.style.transform = `translate(${dx}px, ${dy}px)`; }); }, [intensity], ); const reset = useCallback(() => { cancelAnimationFrame(raf.current); const el = rootRef.current; if (el) el.style.transform = ""; }, []); return ( {Array.from(text).map((char, i) => ( {char === " " ? "\u00A0" : char} ))} ); } ``` ### Shatter Text Characters shatter outward and reform on hover, like breaking glass. URL: https://dev.ononc.com/text/shatter-text Path: src/components/text/shatter-text.tsx ```tsx "use client"; import { Fragment, useCallback, useRef, useState } from "react"; import { motion } from "motion/react"; import { cn } from "@/lib/utils"; export interface ShatterTextProps { text: string; className?: string; /** Trigger: "hover" (default) or "click". */ trigger?: "hover" | "click"; } /** * ShatterText — on trigger, each character flies outward in a random * direction with blur, then reassembles. Feels like glass shattering and * reforming. Accessible via aria-label. */ export function ShatterText({ text, className, trigger = "hover", }: ShatterTextProps) { const [active, setActive] = useState(false); const chars = Array.from(text); const mulberry32 = (seed: number) => { let t = seed + 0x6d2b79f5; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; const enter = useCallback(() => setActive(true), []); const leave = useCallback(() => setActive(false), []); const events = trigger === "hover" ? { onMouseEnter: enter, onMouseLeave: leave } : { onClick: () => setActive((p) => !p) }; return ( {chars.map((char, i) => { const r1 = mulberry32(i * 19 + 5); const r2 = mulberry32(i * 23 + 11); const angle = r1 * Math.PI * 2; const dist = 30 + r2 * 50; const dx = Math.cos(angle) * dist; const dy = Math.sin(angle) * dist; const rot = (r1 - 0.5) * 180; return ( {char === " " ? "\u00A0" : char} ); })} ); } ``` ### Magnetic Text Each letter is pulled toward the cursor like iron filings to a magnet. URL: https://dev.ononc.com/text/magnetic-text Path: src/components/text/magnetic-text.tsx ```tsx "use client"; import { type PointerEvent, useCallback, useRef } from "react"; import { cn } from "@/lib/utils"; export interface MagneticTextProps { text: string; className?: string; /** Maximum pixel displacement toward the cursor. */ pull?: number; /** Radius of magnetic influence. */ radius?: number; } /** * MagneticText — each letter is pulled toward the cursor like iron filings * toward a magnet, then springs back on leave. Pointer-driven rAF; freezes * under reduced-motion. Accessible via aria-label. */ export function MagneticText({ text, className, pull = 14, radius = 150, }: MagneticTextProps) { const charRefs = useRef<(HTMLSpanElement | null)[]>([]); const raf = useRef(0); const onPointerMove = useCallback( (e: PointerEvent) => { const { clientX, clientY } = e; cancelAnimationFrame(raf.current); raf.current = requestAnimationFrame(() => { charRefs.current.forEach((el) => { if (!el) return; const r = el.getBoundingClientRect(); const cx = r.left + r.width / 2; const cy = r.top + r.height / 2; const dx = clientX - cx; const dy = clientY - cy; const dist = Math.hypot(dx, dy); const t = Math.max(0, 1 - dist / radius); const smoothT = t * t; el.style.transform = `translate(${dx * smoothT * (pull / radius) * 3}px, ${dy * smoothT * (pull / radius) * 3}px)`; }); }); }, [pull, radius], ); const reset = useCallback(() => { cancelAnimationFrame(raf.current); charRefs.current.forEach((el) => { if (el) el.style.transform = ""; }); }, []); return ( {Array.from(text).map((char, i) => ( { charRefs.current[i] = el; }} aria-hidden className="inline-block transition-transform duration-300 ease-out" style={{ whiteSpace: "pre" }} > {char === " " ? "\u00A0" : char} ))} ); } ``` ### Split Color Text Text is split horizontally at the cursor: top half in one color, bottom in another. URL: https://dev.ononc.com/text/split-color-text Path: src/components/text/split-color-text.tsx ```tsx "use client"; import { type PointerEvent, useCallback, useRef } from "react"; import { cn } from "@/lib/utils"; export interface SplitColorTextProps { text: string; className?: string; } /** * SplitColorText — text is split horizontally at the cursor position: * the top half renders in one brand color, the bottom in another. The * split line follows the pointer vertically. Pure CSS clip-path driven * by rAF; freezes under reduced-motion. Accessible via aria-label. */ export function SplitColorText({ text, className, }: SplitColorTextProps) { const rootRef = useRef(null); const splitRef = useRef(null); const raf = useRef(0); const onPointerMove = useCallback((e: PointerEvent) => { const root = rootRef.current; const split = splitRef.current; if (!root || !split) return; const rect = root.getBoundingClientRect(); const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height)); cancelAnimationFrame(raf.current); raf.current = requestAnimationFrame(() => { split.style.clipPath = `inset(${(y * 100).toFixed(1)}% 0 0 0)`; }); }, []); const reset = useCallback(() => { cancelAnimationFrame(raf.current); const split = splitRef.current; if (split) split.style.clipPath = "inset(50% 0 0 0)"; }, []); return (
{/* Top layer: brand-ink */} {text} {/* Bottom layer: brand-2, clipped */} {text}
); } ``` ## Components Interactive building blocks — cards, buttons, disclosure, tabs, toasts and more. Keyboard and reduced-motion aware. ### Magnetic Button Springs toward the cursor, then snaps back on leave. URL: https://dev.ononc.com/ui Path: src/components/ui/magnetic-button.tsx ```tsx "use client"; import type { PointerEvent, ReactNode } from "react"; import { useRef } from "react"; import { motion, useMotionValue, useSpring } from "motion/react"; import { cn } from "@/lib/utils"; export interface MagneticButtonProps { children: ReactNode; className?: string; /** Fraction of the cursor offset the button follows (0–1). */ strength?: number; onClick?: () => void; type?: "button" | "submit" | "reset"; "aria-label"?: string; } /** * MagneticButton — the button is gently pulled toward the pointer while it * hovers, snapping back with a spring on leave. The transform lives on a * wrapper so all native button semantics stay intact. */ export function MagneticButton({ children, className, strength = 0.4, onClick, type = "button", ...aria }: MagneticButtonProps) { const ref = useRef(null); const x = useMotionValue(0); const y = useMotionValue(0); const spring = { stiffness: 220, damping: 14, mass: 0.3 }; const sx = useSpring(x, spring); const sy = useSpring(y, spring); const handleMove = (e: PointerEvent) => { const el = ref.current; if (!el) return; const rect = el.getBoundingClientRect(); x.set((e.clientX - (rect.left + rect.width / 2)) * strength); y.set((e.clientY - (rect.top + rect.height / 2)) * strength); }; const reset = () => { x.set(0); y.set(0); }; return ( ); } ``` ### Shimmer Button A band of light orbits the border continuously. URL: https://dev.ononc.com/ui Path: src/components/ui/shimmer-button.tsx ```tsx import type { ButtonHTMLAttributes, ReactNode } from "react"; import { cn } from "@/lib/utils"; export interface ShimmerButtonProps extends ButtonHTMLAttributes { children: ReactNode; } /** * ShimmerButton — a pill button ringed by a slowly rotating conic gradient, * so a band of light continuously orbits the border. Pure CSS animation. */ export function ShimmerButton({ className, children, ...props }: ShimmerButtonProps) { return ( ); } ``` ### Ripple Button Emits a ripple from the exact press point. URL: https://dev.ononc.com/ui Path: src/components/ui/ripple-button.tsx ```tsx "use client"; import { type ButtonHTMLAttributes, type PointerEvent, type ReactNode, useState, } from "react"; import { cn } from "@/lib/utils"; interface Ripple { id: number; x: number; y: number; size: number; } export interface RippleButtonProps extends ButtonHTMLAttributes { children: ReactNode; } /** * RippleButton — emits a circular ripple from the exact press point on each * pointer-down, cleaning each ripple up when its animation ends. */ export function RippleButton({ className, children, onPointerDown, ...props }: RippleButtonProps) { const [ripples, setRipples] = useState([]); const handlePointerDown = (e: PointerEvent) => { const rect = e.currentTarget.getBoundingClientRect(); const size = Math.max(rect.width, rect.height); setRipples((prev) => [ ...prev, { id: Date.now() + Math.random(), x: e.clientX - rect.left - size / 2, y: e.clientY - rect.top - size / 2, size, }, ]); onPointerDown?.(e); }; const remove = (id: number) => setRipples((prev) => prev.filter((r) => r.id !== id)); return ( ); } ``` ### Tilt Card Tilts in 3D toward the pointer with a tracking glare. URL: https://dev.ononc.com/ui Path: src/components/ui/tilt-card.tsx ```tsx "use client"; import type { PointerEvent, ReactNode } from "react"; import { useRef } from "react"; import { motion, useMotionTemplate, useMotionValue, useSpring, useTransform, } from "motion/react"; import { cn } from "@/lib/utils"; export interface TiltCardProps { children: ReactNode; className?: string; /** Max tilt in degrees. */ max?: number; /** Show the moving glare highlight. */ glare?: boolean; } /** * TiltCard — tilts in 3D toward the pointer with a spring, and casts a soft * glare that tracks the cursor. Flattens smoothly on leave. */ export function TiltCard({ children, className, max = 12, glare = true, }: TiltCardProps) { const ref = useRef(null); const px = useMotionValue(0.5); const py = useMotionValue(0.5); const spring = { stiffness: 220, damping: 18, mass: 0.4 }; const rotateX = useSpring(useTransform(py, [0, 1], [max, -max]), spring); const rotateY = useSpring(useTransform(px, [0, 1], [-max, max]), spring); const glareX = useTransform(px, [0, 1], ["0%", "100%"]); const glareY = useTransform(py, [0, 1], ["0%", "100%"]); const glareBg = useMotionTemplate`radial-gradient(180px circle at ${glareX} ${glareY}, rgba(255,255,255,0.22), transparent 60%)`; const handleMove = (e: PointerEvent) => { const el = ref.current; if (!el) return; const rect = el.getBoundingClientRect(); px.set((e.clientX - rect.left) / rect.width); py.set((e.clientY - rect.top) / rect.height); }; const reset = () => { px.set(0.5); py.set(0.5); }; return (
{children} {glare && ( )}
); } ``` ### Spotlight Card A radial glow follows the cursor across the surface. URL: https://dev.ononc.com/ui Path: src/components/ui/spotlight-card.tsx ```tsx "use client"; import type { HTMLAttributes, PointerEvent, ReactNode } from "react"; import { useRef } from "react"; import { cn } from "@/lib/utils"; export interface SpotlightCardProps extends HTMLAttributes { /** Spotlight color (CSS color). */ color?: string; /** Spotlight radius in pixels. */ radius?: number; children: ReactNode; } /** * SpotlightCard — a soft radial glow follows the cursor across the surface. * Position is written to CSS variables on pointer move, so it never re-renders. */ export function SpotlightCard({ className, color = "var(--brand)", radius = 280, children, ...props }: SpotlightCardProps) { const ref = useRef(null); const handleMove = (e: PointerEvent) => { const el = ref.current; if (!el) return; const rect = el.getBoundingClientRect(); el.style.setProperty("--spot-x", `${e.clientX - rect.left}px`); el.style.setProperty("--spot-y", `${e.clientY - rect.top}px`); }; return (
{children}
); } ``` ### Dock macOS-style icons that magnify by cursor proximity. URL: https://dev.ononc.com/ui Path: src/components/ui/dock.tsx ```tsx "use client"; import type { ReactNode } from "react"; import { useRef } from "react"; import { motion, useMotionValue, useSpring, useTransform, type MotionValue, } from "motion/react"; import { cn } from "@/lib/utils"; export interface DockItem { icon: ReactNode; label: string; onClick?: () => void; } export interface DockProps { items: DockItem[]; className?: string; /** Resting icon size (px). */ baseSize?: number; /** Peak icon size at the cursor (px). */ magnify?: number; /** Distance (px) over which magnification falls off. */ range?: number; } function DockButton({ mouseX, item, baseSize, magnify, range, }: { mouseX: MotionValue; item: DockItem; baseSize: number; magnify: number; range: number; }) { const ref = useRef(null); const distance = useTransform(mouseX, (val) => { const rect = ref.current?.getBoundingClientRect(); const center = rect ? rect.x + rect.width / 2 : 0; return val - center; }); const sizeTarget = useTransform( distance, [-range, 0, range], [baseSize, magnify, baseSize], ); const size = useSpring(sizeTarget, { stiffness: 320, damping: 22, mass: 0.2, }); return ( {item.icon} {item.label} ); } /** * Dock — a row of icon buttons that magnify based on cursor proximity, like * the macOS dock. Each item is a real button with an accessible label. */ export function Dock({ items, className, baseSize = 48, magnify = 80, range = 140, }: DockProps) { const mouseX = useMotionValue(Infinity); return (
mouseX.set(e.clientX)} onPointerLeave={() => mouseX.set(Infinity)} className={cn( "mx-auto flex items-end gap-3 rounded-3xl border border-border bg-surface/60 px-4 pb-3 pt-2 backdrop-blur-xl", className, )} > {items.map((item, i) => ( ))}
); } ``` ### Marquee A seamless infinite scroller that pauses on hover. URL: https://dev.ononc.com/ui Path: src/components/ui/marquee.tsx ```tsx "use client"; import type { CSSProperties, ReactNode } from "react"; import { cn } from "@/lib/utils"; export interface MarqueeProps { children: ReactNode; className?: string; /** Seconds for one full loop. */ duration?: number; /** Scroll direction. */ reverse?: boolean; /** Pause while hovered. */ pauseOnHover?: boolean; /** Gap between items (CSS length). */ gap?: string; /** Fade the edges into the background. */ fade?: boolean; } /** * Marquee — an endlessly scrolling row. Two identical groups, each carrying a * trailing gap and animated a full width, give a seamless loop with no hitch. */ export function Marquee({ children, className, duration = 28, reverse = false, pauseOnHover = true, gap = "1.5rem", fade = true, }: MarqueeProps) { const groupClass = cn( "animate-marquee flex min-w-full shrink-0 items-center justify-around", pauseOnHover && "group-hover:[animation-play-state:paused]", reverse && "[animation-direction:reverse]", ); const styleVars = { gap, paddingInlineEnd: gap, "--marquee-duration": `${duration}s`, } as CSSProperties; return (
{children}
{children}
); } ``` ### Carousel Looping slides with arrows, dots, and arrow-key support. URL: https://dev.ononc.com/ui Path: src/components/ui/carousel.tsx ```tsx "use client"; import type { KeyboardEvent, ReactNode } from "react"; import { useState } from "react"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { cn } from "@/lib/utils"; export interface CarouselProps { slides: ReactNode[]; className?: string; /** Accessible label for the carousel region. */ label?: string; } /** * Carousel — a track of full-width slides with prev/next controls, dot * indicators, looping, and arrow-key navigation. Built on ARIA carousel roles. */ export function Carousel({ slides, className, label = "Gallery", }: CarouselProps) { const [index, setIndex] = useState(0); const count = slides.length; const go = (i: number) => setIndex(((i % count) + count) % count); const onKeyDown = (e: KeyboardEvent) => { if (e.key === "ArrowRight") { e.preventDefault(); go(index + 1); } else if (e.key === "ArrowLeft") { e.preventDefault(); go(index - 1); } }; if (count === 0) return null; return (
{slides.map((slide, i) => (
{slide}
))}
{slides.map((_, i) => (
); } ``` ### Accordion Accessible disclosure list with smooth height transitions. URL: https://dev.ononc.com/ui Path: src/components/ui/accordion.tsx ```tsx "use client"; import { type ReactNode, useId, useState } from "react"; import { ChevronDown } from "lucide-react"; import { cn } from "@/lib/utils"; export interface AccordionItem { title: string; content: ReactNode; } export interface AccordionProps { items: AccordionItem[]; className?: string; /** Allow multiple panels open at once. */ multiple?: boolean; /** Index open by default. */ defaultIndex?: number | null; } /** * Accordion — accessible disclosure list. Each header is a button wired with * aria-expanded/aria-controls; panels expand via a grid-rows height transition. */ export function Accordion({ items, className, multiple = false, defaultIndex = null, }: AccordionProps) { const baseId = useId(); const [open, setOpen] = useState>( () => new Set(defaultIndex != null ? [defaultIndex] : []), ); const toggle = (i: number) => setOpen((prev) => { const next = new Set(multiple ? prev : []); if (prev.has(i)) next.delete(i); else next.add(i); return next; }); return (
{items.map((item, i) => { const isOpen = open.has(i); const headerId = `${baseId}-h-${i}`; const panelId = `${baseId}-p-${i}`; return (

{item.content}
); })}
); } ``` ### Tabs Accessible tablist with a sliding underline indicator. URL: https://dev.ononc.com/ui Path: src/components/ui/tabs.tsx ```tsx "use client"; import { type KeyboardEvent, type ReactNode, useId, useState } from "react"; import { motion } from "motion/react"; import { cn } from "@/lib/utils"; export interface TabItem { label: string; content: ReactNode; } export interface TabsProps { items: TabItem[]; className?: string; defaultIndex?: number; } /** * Tabs — an accessible tablist (roles, arrow-key roving focus) with a sliding * underline indicator shared across tabs via motion's layoutId. */ export function Tabs({ items, className, defaultIndex = 0 }: TabsProps) { const baseId = useId(); const [active, setActive] = useState(defaultIndex); const onKeyDown = (e: KeyboardEvent) => { if (e.key === "ArrowRight" || e.key === "ArrowLeft") { e.preventDefault(); const dir = e.key === "ArrowRight" ? 1 : -1; const next = (active + dir + items.length) % items.length; setActive(next); document.getElementById(`${baseId}-tab-${next}`)?.focus(); } }; return (
{items.map((item, i) => { const selected = i === active; return ( ); })}
{items.map((item, i) => ( ))}
); } ``` ### Tooltip Shows on hover and keyboard focus, linked via aria. URL: https://dev.ononc.com/ui Path: src/components/ui/tooltip.tsx ```tsx "use client"; import { type ReactNode, useId, useState } from "react"; import { AnimatePresence, motion } from "motion/react"; import { cn } from "@/lib/utils"; type Side = "top" | "bottom" | "left" | "right"; export interface TooltipProps { /** Tooltip text. */ label: string; children: ReactNode; side?: Side; className?: string; } const SIDE_STYLES: Record = { top: "bottom-full left-1/2 mb-2 -translate-x-1/2", bottom: "top-full left-1/2 mt-2 -translate-x-1/2", left: "right-full top-1/2 mr-2 -translate-y-1/2", right: "left-full top-1/2 ml-2 -translate-y-1/2", }; /** * Tooltip — shows a label on hover and on keyboard focus, linked to the trigger * via aria-describedby and exposed with role="tooltip". */ export function Tooltip({ label, children, side = "top", className }: TooltipProps) { const id = useId(); const [open, setOpen] = useState(false); return ( setOpen(true)} onPointerLeave={() => setOpen(false)} onFocusCapture={() => setOpen(true)} onBlurCapture={() => setOpen(false)} onKeyDown={(e) => { if (e.key === "Escape") setOpen(false); }} > {children} {open && ( {label} )} ); } ``` ### Switch Accessible toggle with a spring thumb. URL: https://dev.ononc.com/ui/switch Path: src/components/ui/switch.tsx ```tsx "use client"; import { useState } from "react"; import { motion } from "motion/react"; import { cn } from "@/lib/utils"; export interface SwitchProps { checked?: boolean; defaultChecked?: boolean; onCheckedChange?: (checked: boolean) => void; disabled?: boolean; label?: string; className?: string; } /** * Switch — an accessible toggle (role="switch", aria-checked). Works controlled * or uncontrolled; the thumb slides with a spring. */ export function Switch({ checked, defaultChecked = false, onCheckedChange, disabled = false, label, className, }: SwitchProps) { const [internal, setInternal] = useState(defaultChecked); const isControlled = checked !== undefined; const on = isControlled ? checked : internal; const toggle = () => { if (disabled) return; const next = !on; if (!isControlled) setInternal(next); onCheckedChange?.(next); }; return ( ); } ``` ### Segmented Control Single-select with a pill that slides to the choice. URL: https://dev.ononc.com/ui/segmented-control Path: src/components/ui/segmented-control.tsx ```tsx "use client"; import { type KeyboardEvent, useId, useState } from "react"; import { motion } from "motion/react"; import { cn } from "@/lib/utils"; export interface SegmentedControlProps { options: string[]; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; className?: string; "aria-label"?: string; "aria-labelledby"?: string; } /** * SegmentedControl — single-select control with a pill that slides to the * active option (motion layoutId). Exposed as a radiogroup with arrow keys. */ export function SegmentedControl({ options, defaultValue, value, onValueChange, className, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby, }: SegmentedControlProps) { const baseId = useId(); const [internal, setInternal] = useState(defaultValue ?? options[0]); const selected = value ?? internal; const select = (option: string) => { if (value === undefined) setInternal(option); onValueChange?.(option); }; const onKeyDown = (e: KeyboardEvent) => { if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return; e.preventDefault(); const idx = options.indexOf(selected); const dir = e.key === "ArrowRight" ? 1 : -1; const nextIndex = (idx + dir + options.length) % options.length; select(options[nextIndex]); document.getElementById(`${baseId}-${nextIndex}`)?.focus(); }; return (
{options.map((option, i) => { const isSelected = option === selected; return ( ); })}
); } ``` ### Modal Portaled dialog with focus management and Escape to close. URL: https://dev.ononc.com/ui Path: src/components/ui/modal.tsx ```tsx "use client"; import { type ReactNode, useEffect, useRef } from "react"; import { createPortal } from "react-dom"; import { AnimatePresence, motion } from "motion/react"; import { X } from "lucide-react"; import { useHydrated } from "@/lib/use-hydrated"; import { cn } from "@/lib/utils"; export interface ModalProps { open: boolean; onClose: () => void; title?: string; children: ReactNode; className?: string; } /** * Modal — an accessible dialog rendered in a portal. Closes on Escape and * backdrop click, locks body scroll, focuses the panel on open, and restores * focus to the trigger on close. */ export function Modal({ open, onClose, title, children, className }: ModalProps) { const mounted = useHydrated(); const panelRef = useRef(null); const lastFocused = useRef(null); const onCloseRef = useRef(onClose); useEffect(() => { onCloseRef.current = onClose; }); useEffect(() => { if (!open) return; lastFocused.current = document.activeElement as HTMLElement | null; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onCloseRef.current(); }; document.addEventListener("keydown", onKey); const prevOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; const id = requestAnimationFrame(() => panelRef.current?.focus()); return () => { document.removeEventListener("keydown", onKey); document.body.style.overflow = prevOverflow; cancelAnimationFrame(id); lastFocused.current?.focus?.(); }; }, [open]); if (!mounted) return null; return createPortal( {open && (
{title && (

{title}

)}
{children}
)}
, document.body, ); } ``` ### Toast Imperative toasts via a tiny store and a Toaster. URL: https://dev.ononc.com/ui Path: src/components/ui/toast.tsx ```tsx "use client"; import { useSyncExternalStore } from "react"; import { AnimatePresence, motion } from "motion/react"; import { X } from "lucide-react"; import { cn } from "@/lib/utils"; export type ToastVariant = "default" | "success" | "error" | "info"; interface ToastItem { id: number; message: string; variant: ToastVariant; } const ACCENT: Record = { default: "var(--brand)", success: "#34d399", error: "var(--brand-3)", info: "var(--brand-2)", }; let counter = 0; let store: ToastItem[] = []; const listeners = new Set<() => void>(); function emit() { for (const listener of listeners) listener(); } function subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; } function getSnapshot() { return store; } function dismiss(id: number) { store = store.filter((t) => t.id !== id); emit(); } /** Push a toast onto the stack. Auto-dismisses after `duration` ms. */ export function toast( message: string, opts?: { variant?: ToastVariant; duration?: number }, ) { const id = ++counter; store = [...store, { id, message, variant: opts?.variant ?? "default" }]; emit(); setTimeout(() => dismiss(id), opts?.duration ?? 3500); return id; } /** Mount once near the root to render queued toasts. */ export function Toaster({ className }: { className?: string }) { const items = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); return (
{items.map((item) => (

{item.message}

))}
); } ``` ### Command Palette ⌘K launcher with live filtering and full keyboard nav. URL: https://dev.ononc.com/ui Path: src/components/ui/command-palette.tsx ```tsx "use client"; import { type ReactNode, useEffect, useMemo, useRef, useState, } from "react"; import { createPortal } from "react-dom"; import { AnimatePresence, motion } from "motion/react"; import { Search } from "lucide-react"; import { useHydrated } from "@/lib/use-hydrated"; import { cn } from "@/lib/utils"; export interface CommandItem { id: string; label: string; group?: string; shortcut?: string; icon?: ReactNode; onSelect?: () => void; } export interface CommandPaletteProps { open: boolean; onClose: () => void; items: CommandItem[]; placeholder?: string; } /** * CommandPalette — a ⌘K-style launcher. Filters items as you type, moves the * active row with ↑/↓, runs it with Enter, and closes on Escape. The active * option is tracked with aria-activedescendant for assistive tech. */ export function CommandPalette({ open, onClose, items, placeholder = "Type a command or search…", }: CommandPaletteProps) { const mounted = useHydrated(); const [query, setQuery] = useState(""); const [active, setActive] = useState(0); const inputRef = useRef(null); const listRef = useRef(null); const lastFocused = useRef(null); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return items; return items.filter((i) => i.label.toLowerCase().includes(q)); }, [items, query]); useEffect(() => { if (!open) return; lastFocused.current = document.activeElement as HTMLElement | null; const id = requestAnimationFrame(() => { setQuery(""); setActive(0); inputRef.current?.focus(); }); const prevOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { cancelAnimationFrame(id); document.body.style.overflow = prevOverflow; lastFocused.current?.focus?.(); }; }, [open]); const run = (item: CommandItem | undefined) => { if (!item) return; item.onSelect?.(); onClose(); }; const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); onClose(); } else if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(a + 1, filtered.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); } else if (e.key === "Enter") { e.preventDefault(); run(filtered[active]); } }; useEffect(() => { listRef.current ?.querySelector('[data-active="true"]') ?.scrollIntoView({ block: "nearest" }); }, [active]); if (!mounted) return null; return createPortal( {open && (
{ setQuery(e.target.value); setActive(0); }} placeholder={placeholder} role="combobox" aria-expanded aria-controls="command-list" aria-activedescendant={ filtered[active] ? `command-${filtered[active].id}` : undefined } className="w-full bg-transparent py-3.5 text-sm outline-none placeholder:text-muted-2" /> ESC
{filtered.length === 0 ? (

No results for “{query}”.

) : ( filtered.map((item, i) => ( )) )}
)}
, document.body, ); } ``` ### Dropdown Menu Menu button with roving focus, Escape, and outside-click. URL: https://dev.ononc.com/ui Path: src/components/ui/dropdown-menu.tsx ```tsx "use client"; import { type ReactNode, useEffect, useRef, useState, } from "react"; import { AnimatePresence, motion } from "motion/react"; import { ChevronDown } from "lucide-react"; import { cn } from "@/lib/utils"; export interface MenuItem { label: string; icon?: ReactNode; onSelect?: () => void; danger?: boolean; } export interface DropdownMenuProps { label: string; items: MenuItem[]; className?: string; } /** * DropdownMenu — an accessible menu button. Opens with click/Enter, moves focus * with ↑/↓ (Home/End), selects with Enter, and closes on Escape or outside * click — returning focus to the trigger. */ export function DropdownMenu({ label, items, className }: DropdownMenuProps) { const [open, setOpen] = useState(false); const [active, setActive] = useState(0); const rootRef = useRef(null); const triggerRef = useRef(null); const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); useEffect(() => { if (!open) return; const onClick = (e: PointerEvent) => { if (!rootRef.current?.contains(e.target as Node)) setOpen(false); }; document.addEventListener("pointerdown", onClick); return () => document.removeEventListener("pointerdown", onClick); }, [open]); useEffect(() => { if (open) itemRefs.current[active]?.focus(); }, [open, active]); const close = (returnFocus = true) => { setOpen(false); if (returnFocus) triggerRef.current?.focus(); }; const onMenuKey = (e: React.KeyboardEvent) => { switch (e.key) { case "ArrowDown": e.preventDefault(); setActive((a) => (a + 1) % items.length); break; case "ArrowUp": e.preventDefault(); setActive((a) => (a - 1 + items.length) % items.length); break; case "Home": e.preventDefault(); setActive(0); break; case "End": e.preventDefault(); setActive(items.length - 1); break; case "Escape": e.preventDefault(); close(); break; } }; return (
{open && ( {items.map((item, i) => ( ))} )}
); } ``` ### Popover Floating panel of arbitrary content with focus return. URL: https://dev.ononc.com/ui Path: src/components/ui/popover.tsx ```tsx "use client"; import { type ReactNode, useEffect, useRef, useState } from "react"; import { AnimatePresence, motion } from "motion/react"; import { cn } from "@/lib/utils"; export interface PopoverProps { label: ReactNode; children: ReactNode; className?: string; panelClassName?: string; } /** * Popover — a trigger that reveals a floating panel of arbitrary content. * Closes on Escape and outside click, returning focus to the trigger. */ export function Popover({ label, children, className, panelClassName }: PopoverProps) { const [open, setOpen] = useState(false); const rootRef = useRef(null); const triggerRef = useRef(null); useEffect(() => { if (!open) return; const onPointer = (e: PointerEvent) => { if (!rootRef.current?.contains(e.target as Node)) setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { setOpen(false); triggerRef.current?.focus(); } }; document.addEventListener("pointerdown", onPointer); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("pointerdown", onPointer); document.removeEventListener("keydown", onKey); }; }, [open]); return (
{open && ( {children} )}
); } ``` ### Drawer Slide-in side panel with focus management and Escape. URL: https://dev.ononc.com/ui Path: src/components/ui/drawer.tsx ```tsx "use client"; import { type ReactNode, useEffect, useRef } from "react"; import { createPortal } from "react-dom"; import { AnimatePresence, motion } from "motion/react"; import { X } from "lucide-react"; import { useHydrated } from "@/lib/use-hydrated"; import { cn } from "@/lib/utils"; export interface DrawerProps { open: boolean; onClose: () => void; side?: "left" | "right"; title?: string; children: ReactNode; className?: string; } /** * Drawer — a panel that slides in from the edge. Portaled, closes on Escape and * backdrop click, locks body scroll, and manages focus like a dialog. */ export function Drawer({ open, onClose, side = "right", title, children, className, }: DrawerProps) { const mounted = useHydrated(); const panelRef = useRef(null); const lastFocused = useRef(null); const onCloseRef = useRef(onClose); useEffect(() => { onCloseRef.current = onClose; }); useEffect(() => { if (!open) return; lastFocused.current = document.activeElement as HTMLElement | null; const onKey = (e: KeyboardEvent) => e.key === "Escape" && onCloseRef.current(); document.addEventListener("keydown", onKey); const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; const id = requestAnimationFrame(() => panelRef.current?.focus()); return () => { document.removeEventListener("keydown", onKey); document.body.style.overflow = prev; cancelAnimationFrame(id); lastFocused.current?.focus?.(); }; }, [open]); if (!mounted) return null; return createPortal( {open && (
{title &&

{title}

}
{children}
)}
, document.body, ); } ``` ### OTP Input Segmented code entry with auto-advance and paste. URL: https://dev.ononc.com/ui Path: src/components/ui/otp-input.tsx ```tsx "use client"; import { type ClipboardEvent, type KeyboardEvent, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export interface OTPInputProps { length?: number; onComplete?: (code: string) => void; className?: string; } /** * OTPInput — a segmented one-time-code field. Typing auto-advances, Backspace * steps back, ←/→ move between cells, and pasting a code fills every cell. */ export function OTPInput({ length = 6, onComplete, className }: OTPInputProps) { const [values, setValues] = useState(() => Array.from({ length }, () => ""), ); const refs = useRef<(HTMLInputElement | null)[]>([]); const focusAt = (i: number) => refs.current[Math.max(0, Math.min(i, length - 1))]?.focus(); const commit = (next: string[]) => { setValues(next); if (next.every((v) => v !== "")) onComplete?.(next.join("")); }; const handleChange = (i: number, raw: string) => { const digit = raw.replace(/\D/g, "").slice(-1); const next = [...values]; next[i] = digit; commit(next); if (digit) focusAt(i + 1); }; const handleKeyDown = (i: number, e: KeyboardEvent) => { if (e.key === "Backspace") { if (values[i] === "" && i > 0) { e.preventDefault(); const next = [...values]; next[i - 1] = ""; setValues(next); focusAt(i - 1); } } else if (e.key === "ArrowLeft") { e.preventDefault(); focusAt(i - 1); } else if (e.key === "ArrowRight") { e.preventDefault(); focusAt(i + 1); } }; const handlePaste = (e: ClipboardEvent) => { e.preventDefault(); const digits = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, length); if (!digits) return; const next = Array.from({ length }, (_, i) => digits[i] ?? ""); commit(next); focusAt(digits.length); }; return (
{values.map((value, i) => ( { refs.current[i] = el; }} type="text" inputMode="numeric" autoComplete={i === 0 ? "one-time-code" : "off"} maxLength={1} value={value} aria-label={`Digit ${i + 1}`} onChange={(e) => handleChange(i, e.target.value)} onKeyDown={(e) => handleKeyDown(i, e)} onFocus={(e) => e.target.select()} className="size-12 rounded-xl border border-border bg-surface text-center text-lg font-semibold tabular-nums outline-none transition-colors focus-visible:border-brand focus-visible:ring-2 focus-visible:ring-brand/40" /> ))}
); } ``` ### Slider Range input with pointer drag and keyboard control. URL: https://dev.ononc.com/ui/slider Path: src/components/ui/slider.tsx ```tsx "use client"; import { type KeyboardEvent, type PointerEvent, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export interface SliderProps { min?: number; max?: number; step?: number; defaultValue?: number; value?: number; onValueChange?: (value: number) => void; className?: string; "aria-label"?: string; } /** * Slider — an accessible range input. Drag the thumb or the track; ←/→ and * Home/End adjust by step. Works controlled or uncontrolled. */ export function Slider({ min = 0, max = 100, step = 1, defaultValue = 50, value, onValueChange, className, ...aria }: SliderProps) { const trackRef = useRef(null); const dragging = useRef(false); const [internal, setInternal] = useState(defaultValue); const current = value ?? internal; const pct = ((current - min) / (max - min)) * 100; const set = (v: number) => { const snapped = Math.round(Math.max(min, Math.min(max, v)) / step) * step; if (value === undefined) setInternal(snapped); onValueChange?.(snapped); }; const fromClientX = (clientX: number) => { const rect = trackRef.current?.getBoundingClientRect(); if (!rect) return; set(min + ((clientX - rect.left) / rect.width) * (max - min)); }; const onPointerDown = (e: PointerEvent) => { dragging.current = true; e.currentTarget.setPointerCapture(e.pointerId); fromClientX(e.clientX); }; const onPointerMove = (e: PointerEvent) => { if (dragging.current) fromClientX(e.clientX); }; const onPointerUp = (e: PointerEvent) => { dragging.current = false; e.currentTarget.releasePointerCapture(e.pointerId); }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "ArrowRight" || e.key === "ArrowUp") { e.preventDefault(); set(current + step); } else if (e.key === "ArrowLeft" || e.key === "ArrowDown") { e.preventDefault(); set(current - step); } else if (e.key === "Home") { e.preventDefault(); set(min); } else if (e.key === "End") { e.preventDefault(); set(max); } }; return (
); } ``` ### Rating Star rating with hover preview and arrow-key adjust. URL: https://dev.ononc.com/ui/rating Path: src/components/ui/rating.tsx ```tsx "use client"; import { type KeyboardEvent, useState } from "react"; import { Star } from "lucide-react"; import { cn } from "@/lib/utils"; export interface RatingProps { max?: number; defaultValue?: number; onChange?: (value: number) => void; className?: string; } /** * Rating — a star rating exposed as a slider. Hover previews, click sets, and * ←/→ (Home/End) adjust the value for keyboard users. */ export function Rating({ max = 5, defaultValue = 0, onChange, className, }: RatingProps) { const [value, setValue] = useState(defaultValue); const [hover, setHover] = useState(null); const shown = hover ?? value; const set = (v: number) => { const clamped = Math.max(0, Math.min(max, v)); setValue(clamped); onChange?.(clamped); }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "ArrowRight" || e.key === "ArrowUp") { e.preventDefault(); set(value + 1); } else if (e.key === "ArrowLeft" || e.key === "ArrowDown") { e.preventDefault(); set(value - 1); } else if (e.key === "Home") { e.preventDefault(); set(0); } else if (e.key === "End") { e.preventDefault(); set(max); } }; return (
setHover(null)} className={cn( "inline-flex gap-1 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-brand/50", className, )} > {Array.from({ length: max }, (_, i) => { const filled = i < shown; return ( ); })}
); } ``` ### Stepper Multi-step progress with completed and active states. URL: https://dev.ononc.com/ui Path: src/components/ui/stepper.tsx ```tsx import { Check } from "lucide-react"; import { cn } from "@/lib/utils"; export interface StepperProps { steps: string[]; /** Zero-based index of the current step. */ current: number; className?: string; } /** * Stepper — a horizontal progress indicator. Completed steps show a check, * the current step is highlighted, and a filled bar tracks progress. */ export function Stepper({ steps, current, className }: StepperProps) { const pct = steps.length > 1 ? (current / (steps.length - 1)) * 100 : 0; return (
    {steps.map((step, i) => { const done = i < current; const activeStep = i === current; return (
  1. {done ? : i + 1} {step}
  2. ); })}
); } ``` ### Progress Ring Radial dial that eases to its value on view. URL: https://dev.ononc.com/ui/progress-ring Path: src/components/ui/progress-ring.tsx ```tsx "use client"; import { useEffect, useId, useRef, useState } from "react"; import { useInView } from "motion/react"; import { cn, prefersReducedMotion } from "@/lib/utils"; export interface ProgressRingProps { /** Target percentage (0–100). */ value?: number; size?: number; stroke?: number; className?: string; } /** * ProgressRing — a radial progress dial that eases from 0 to its value the * first time it scrolls into view, with a gradient arc and a live percentage. */ export function ProgressRing({ value = 72, size = 120, stroke = 10, className, }: ProgressRingProps) { const gradId = useId(); const ref = useRef(null); const inView = useInView(ref, { once: true, amount: 0.5 }); const [display, setDisplay] = useState(0); useEffect(() => { if (!inView) return; const duration = prefersReducedMotion() ? 0 : 1200; const start = performance.now(); let raf = 0; const tick = (now: number) => { const p = duration <= 0 ? 1 : Math.min(1, (now - start) / duration); const eased = 1 - Math.pow(1 - p, 3); setDisplay(value * eased); if (p < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [inView, value]); const radius = (size - stroke) / 2; const circumference = 2 * Math.PI * radius; const offset = circumference * (1 - display / 100); return (
{Math.round(display)}%
); } ``` ### Scroll Progress Gradient bar tied to scroll position (window or container). URL: https://dev.ononc.com/ui Path: src/components/ui/scroll-progress.tsx ```tsx "use client"; import { type RefObject } from "react"; import { motion, useScroll, useSpring } from "motion/react"; import { cn } from "@/lib/utils"; export interface ScrollProgressProps { /** Track this scroll container instead of the window. */ containerRef?: RefObject; className?: string; } /** * ScrollProgress — a gradient bar that fills with scroll progress. Tracks the * window by default, or a given scroll container. Position it with className * (defaults to fixed at the top of the viewport). */ export function ScrollProgress({ containerRef, className }: ScrollProgressProps) { const { scrollYProgress } = useScroll( containerRef ? { container: containerRef } : undefined, ); const scaleX = useSpring(scrollYProgress, { stiffness: 120, damping: 30, mass: 0.3, }); return ( ); } ``` ### Image Compare Before/after wipe with a draggable, keyboardable handle. URL: https://dev.ononc.com/ui Path: src/components/ui/image-compare.tsx ```tsx "use client"; import { type KeyboardEvent, type PointerEvent, type ReactNode, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export interface ImageCompareProps { before: ReactNode; after: ReactNode; className?: string; /** Starting split position (0–100). */ defaultPosition?: number; } /** * ImageCompare — drag (or use ←/→) to wipe between a "before" and "after" * layer. The handle is an accessible slider. */ export function ImageCompare({ before, after, className, defaultPosition = 50, }: ImageCompareProps) { const [pos, setPos] = useState(defaultPosition); const ref = useRef(null); const dragging = useRef(false); const update = (clientX: number) => { const rect = ref.current?.getBoundingClientRect(); if (!rect) return; setPos(Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100))); }; const onPointerDown = (e: PointerEvent) => { dragging.current = true; e.currentTarget.setPointerCapture(e.pointerId); update(e.clientX); }; const onPointerMove = (e: PointerEvent) => { if (dragging.current) update(e.clientX); }; const onPointerUp = (e: PointerEvent) => { dragging.current = false; e.currentTarget.releasePointerCapture(e.pointerId); }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "ArrowLeft") { e.preventDefault(); setPos((p) => Math.max(0, p - 4)); } else if (e.key === "ArrowRight") { e.preventDefault(); setPos((p) => Math.min(100, p + 4)); } }; return (
{before}
{after}
); } ``` ### Avatar Stack Overlapping avatars with an overflow +N chip. URL: https://dev.ononc.com/ui Path: src/components/ui/avatar-stack.tsx ```tsx import { cn } from "@/lib/utils"; export interface AvatarStackProps { names: string[]; /** Max avatars before collapsing into a +N chip. */ max?: number; className?: string; } const GRADIENTS = [ "from-brand to-brand-2", "from-brand-2 to-brand-3", "from-brand-3 to-brand", "from-indigo-500 to-brand", ]; function initials(name: string) { return name .split(" ") .map((p) => p[0]) .slice(0, 2) .join("") .toUpperCase(); } /** * AvatarStack — overlapping avatar bubbles (colored initials) that fan apart * slightly on hover, with a +N chip when the list overflows. */ export function AvatarStack({ names, max = 4, className }: AvatarStackProps) { const shown = names.slice(0, max); const overflow = names.length - shown.length; return (
{shown.map((name, i) => ( 0 && "-ml-3", )} > {initials(name)} ))} {overflow > 0 && ( +{overflow} )}
); } ``` ### Breadcrumbs Accessible trail with chevrons and aria-current. URL: https://dev.ononc.com/ui Path: src/components/ui/breadcrumbs.tsx ```tsx import { Fragment } from "react"; import { ChevronRight } from "lucide-react"; import { cn } from "@/lib/utils"; export interface Crumb { label: string; href?: string; } export interface BreadcrumbsProps { items: Crumb[]; className?: string; } /** * Breadcrumbs — an accessible breadcrumb trail. The last item is marked * aria-current="page"; the rest are links. */ export function Breadcrumbs({ items, className }: BreadcrumbsProps) { return ( ); } ``` ### Skeleton Shimmering placeholders for loading states. URL: https://dev.ononc.com/ui Path: src/components/ui/skeleton.tsx ```tsx import type { CSSProperties, HTMLAttributes } from "react"; import { cn } from "@/lib/utils"; export type SkeletonProps = HTMLAttributes; /** * Skeleton — a shimmering placeholder for loading states. Size it with utility * classes (e.g. `h-4 w-32`). A light band sweeps across via the shimmer keyframe. */ export function Skeleton({ className, style, ...props }: SkeletonProps) { return (
); } ``` ### Combobox Accessible autocomplete with live filtering and keyboard nav. URL: https://dev.ononc.com/ui Path: src/components/ui/combobox.tsx ```tsx "use client"; import { type KeyboardEvent, useEffect, useId, useRef, useState } from "react"; import { Check, ChevronsUpDown } from "lucide-react"; import { cn } from "@/lib/utils"; export interface ComboboxProps { options: string[]; placeholder?: string; defaultValue?: string; onChange?: (value: string) => void; className?: string; } /** * Combobox — an accessible autocomplete. Type to filter, ↑/↓ to move, Enter to * select, Escape to close; closes on outside click. Wired with combobox/listbox * roles and aria-activedescendant. */ export function Combobox({ options, placeholder = "Select…", defaultValue = "", onChange, className, }: ComboboxProps) { const id = useId(); const rootRef = useRef(null); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [selected, setSelected] = useState(defaultValue); const [active, setActive] = useState(0); const filtered = options.filter((o) => o.toLowerCase().includes(query.toLowerCase()), ); useEffect(() => { if (!open) return; const onDown = (e: PointerEvent) => { if (!rootRef.current?.contains(e.target as Node)) setOpen(false); }; document.addEventListener("pointerdown", onDown); return () => document.removeEventListener("pointerdown", onDown); }, [open]); const choose = (value: string) => { setSelected(value); setQuery(""); setOpen(false); onChange?.(value); }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "ArrowDown") { e.preventDefault(); if (!open) { setOpen(true); return; } setActive((a) => Math.min(a + 1, filtered.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); } else if (e.key === "Enter") { e.preventDefault(); if (open && filtered[active]) choose(filtered[active]); else setOpen(true); } else if (e.key === "Escape") { setOpen(false); } }; return (
{ setQuery(e.target.value); setActive(0); setOpen(true); }} onFocus={() => setOpen(true)} onKeyDown={onKeyDown} className="w-full rounded-lg border border-border bg-surface px-3 py-2 pr-9 text-sm outline-none focus-visible:border-brand" />
{open && (
    {filtered.length === 0 ? (
  • No matches
  • ) : ( filtered.map((option, i) => (
  • { e.preventDefault(); choose(option); }} onMouseMove={() => setActive(i)} className={cn( "flex cursor-pointer items-center justify-between rounded-md px-3 py-2 text-sm", i === active && "bg-brand/15", )} > {option} {option === selected && }
  • )) )}
)}
); } ``` ### Toggle Group Single or multiple selection of toggle buttons. URL: https://dev.ononc.com/ui Path: src/components/ui/toggle-group.tsx ```tsx "use client"; import { type ReactNode, useState } from "react"; import { cn } from "@/lib/utils"; export interface ToggleOption { value: string; label: ReactNode; } export interface ToggleGroupProps { options: ToggleOption[]; type?: "single" | "multiple"; defaultValue?: string[]; onChange?: (value: string[]) => void; className?: string; } /** * ToggleGroup — a row of toggle buttons supporting single or multiple * selection. Each button reflects state via aria-pressed inside a group. */ export function ToggleGroup({ options, type = "single", defaultValue = [], onChange, className, }: ToggleGroupProps) { const [value, setValue] = useState>(() => new Set(defaultValue)); const toggle = (v: string) => { setValue((prev) => { let next: Set; if (type === "single") { next = prev.has(v) ? new Set() : new Set([v]); } else { next = new Set(prev); if (next.has(v)) next.delete(v); else next.add(v); } onChange?.([...next]); return next; }); }; return (
{options.map((option) => { const pressed = value.has(option.value); return ( ); })}
); } ``` ### Tag Input Add chips with Enter, remove with × or Backspace. URL: https://dev.ononc.com/ui Path: src/components/ui/tag-input.tsx ```tsx "use client"; import { type KeyboardEvent, useState } from "react"; import { X } from "lucide-react"; import { cn } from "@/lib/utils"; export interface TagInputProps { defaultTags?: string[]; placeholder?: string; onChange?: (tags: string[]) => void; className?: string; } /** * TagInput — type and press Enter (or comma) to add a chip; click the × or * press Backspace on an empty field to remove the last one. */ export function TagInput({ defaultTags = [], placeholder = "Add a tag…", onChange, className, }: TagInputProps) { const [tags, setTags] = useState(defaultTags); const [input, setInput] = useState(""); const update = (next: string[]) => { setTags(next); onChange?.(next); }; const add = (raw: string) => { const tag = raw.trim(); if (tag && !tags.includes(tag)) update([...tags, tag]); setInput(""); }; const removeAt = (i: number) => update(tags.filter((_, idx) => idx !== i)); const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Enter" || e.key === ",") { e.preventDefault(); add(input); } else if (e.key === "Backspace" && input === "" && tags.length > 0) { removeAt(tags.length - 1); } }; return (
    {tags.map((tag, i) => (
  • {tag}
  • ))}
setInput(e.target.value)} onKeyDown={onKeyDown} className="min-w-[6rem] flex-1 bg-transparent px-1.5 py-1 text-sm outline-none" />
); } ``` ### Pagination Numbered pages with prev/next and ellipsis collapsing. URL: https://dev.ononc.com/ui Path: src/components/ui/pagination.tsx ```tsx "use client"; import { useState } from "react"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { cn } from "@/lib/utils"; export interface PaginationProps { count: number; page?: number; defaultPage?: number; onPageChange?: (page: number) => void; /** Pages shown on each side of the current page. */ siblingCount?: number; className?: string; } function range(start: number, end: number) { return Array.from({ length: end - start + 1 }, (_, i) => start + i); } /** * Pagination — numbered pages with prev/next and ellipsis collapsing for long * ranges. Works controlled (`page`) or uncontrolled (`defaultPage`). */ export function Pagination({ count, page, defaultPage = 1, onPageChange, siblingCount = 1, className, }: PaginationProps) { const [internal, setInternal] = useState(defaultPage); const current = page ?? internal; const go = (p: number) => { const next = Math.max(1, Math.min(count, p)); if (page === undefined) setInternal(next); onPageChange?.(next); }; const left = Math.max(2, current - siblingCount); const right = Math.min(count - 1, current + siblingCount); const items: (number | "…")[] = [1]; if (left > 2) items.push("…"); items.push(...range(left, right)); if (right < count - 1) items.push("…"); if (count > 1) items.push(count); const navBtn = "grid size-9 place-items-center rounded-lg border border-border text-sm transition-colors disabled:opacity-40"; return ( ); } ``` ### Progress Bar Linear meter that fills to its value on view. URL: https://dev.ononc.com/ui/progress-bar Path: src/components/ui/progress-bar.tsx ```tsx "use client"; import { useRef } from "react"; import { useInView } from "motion/react"; import { cn } from "@/lib/utils"; export interface ProgressBarProps { /** Target value (0–100). */ value: number; label?: string; showValue?: boolean; className?: string; } /** * ProgressBar — a linear meter that fills to its value the first time it * scrolls into view. Exposed as an ARIA progressbar. */ export function ProgressBar({ value, label, showValue = true, className, }: ProgressBarProps) { const ref = useRef(null); const inView = useInView(ref, { once: true, amount: 0.6 }); const v = Math.max(0, Math.min(100, value)); return (
{(label || showValue) && (
{label ? {label} : } {showValue && {v}%}
)}
); } ``` ### File Dropzone Drag-and-drop file picker with a list (front-end only). URL: https://dev.ononc.com/ui Path: src/components/ui/file-dropzone.tsx ```tsx "use client"; import { type DragEvent, useRef, useState } from "react"; import { File as FileIcon, UploadCloud, X } from "lucide-react"; import { cn } from "@/lib/utils"; export interface FileDropzoneProps { multiple?: boolean; accept?: string; onFiles?: (files: File[]) => void; className?: string; } /** * FileDropzone — drag files in or click to browse, with a selected-file list. * NOTE: front-end only — it surfaces the chosen File objects via `onFiles` and * uploads nothing. Wire `onFiles` to your own authenticated upload. */ export function FileDropzone({ multiple = true, accept, onFiles, className, }: FileDropzoneProps) { const inputRef = useRef(null); const [dragging, setDragging] = useState(false); const [files, setFiles] = useState([]); const handle = (list: FileList | null) => { if (!list) return; const arr = Array.from(list); setFiles(arr); onFiles?.(arr); }; const onDrop = (e: DragEvent) => { e.preventDefault(); setDragging(false); handle(e.dataTransfer.files); }; return (
handle(e.target.files)} /> {files.length > 0 && (
    {files.map((file, i) => (
  • {file.name} {(file.size / 1024).toFixed(0)} KB
  • ))}
)}
); } ``` ### Checkbox Tri-state checkbox with an indeterminate option and labels. URL: https://dev.ononc.com/ui Path: src/components/ui/checkbox.tsx ```tsx "use client"; import { type ReactNode, useId, useState } from "react"; import { motion } from "motion/react"; import { Check, Minus } from "lucide-react"; import { cn } from "@/lib/utils"; export interface CheckboxProps { checked?: boolean; defaultChecked?: boolean; /** Renders the mixed (–) state; reports aria-checked="mixed". */ indeterminate?: boolean; onCheckedChange?: (checked: boolean) => void; disabled?: boolean; label?: ReactNode; description?: ReactNode; className?: string; } /** * Checkbox — an accessible tri-state checkbox (role="checkbox", aria-checked). * Works controlled or uncontrolled; the check springs in. The visible label * becomes the accessible name, and an optional description is wired via * aria-describedby. */ export function Checkbox({ checked, defaultChecked = false, indeterminate = false, onCheckedChange, disabled = false, label, description, className, }: CheckboxProps) { const descId = useId(); const [internal, setInternal] = useState(defaultChecked); const isControlled = checked !== undefined; const on = isControlled ? checked : internal; const marked = on || indeterminate; const toggle = () => { if (disabled) return; const next = !on; if (!isControlled) setInternal(next); onCheckedChange?.(next); }; return ( ); } ``` ### Radio Group Single-select with roving focus and arrow-key navigation. URL: https://dev.ononc.com/ui Path: src/components/ui/radio-group.tsx ```tsx "use client"; import { type KeyboardEvent, type ReactNode, useId, useRef, useState } from "react"; import { motion } from "motion/react"; import { cn } from "@/lib/utils"; export interface RadioOption { value: string; label: ReactNode; description?: ReactNode; disabled?: boolean; } export interface RadioGroupProps { options: RadioOption[]; value?: string; defaultValue?: string; onValueChange?: (value: string) => void; className?: string; "aria-label"?: string; } /** * RadioGroup — an accessible single-select (role="radiogroup"). One stop in the * tab order; ↑/↓/←/→ move and select the next enabled option (wrapping), and * Home/End jump to the ends. Works controlled or uncontrolled. */ export function RadioGroup({ options, value, defaultValue, onValueChange, className, ...aria }: RadioGroupProps) { const groupId = useId(); const isControlled = value !== undefined; const [internal, setInternal] = useState(defaultValue ?? ""); const selected = isControlled ? value : internal; const refs = useRef<(HTMLButtonElement | null)[]>([]); const enabledIndexes = options .map((o, i) => (o.disabled ? -1 : i)) .filter((i) => i >= 0); const select = (next: string) => { if (!isControlled) setInternal(next); onValueChange?.(next); }; const moveTo = (index: number) => { const opt = options[index]; if (!opt || opt.disabled) return; select(opt.value); refs.current[index]?.focus(); }; const onKeyDown = (e: KeyboardEvent, index: number) => { const pos = enabledIndexes.indexOf(index); if (e.key === "ArrowDown" || e.key === "ArrowRight") { e.preventDefault(); moveTo(enabledIndexes[(pos + 1) % enabledIndexes.length]); } else if (e.key === "ArrowUp" || e.key === "ArrowLeft") { e.preventDefault(); moveTo( enabledIndexes[(pos - 1 + enabledIndexes.length) % enabledIndexes.length], ); } else if (e.key === "Home") { e.preventDefault(); moveTo(enabledIndexes[0]); } else if (e.key === "End") { e.preventDefault(); moveTo(enabledIndexes[enabledIndexes.length - 1]); } }; // The roving tabindex lands on the selected option, else the first enabled one. const tabStop = options.findIndex((o) => o.value === selected && !o.disabled); const fallbackStop = enabledIndexes[0] ?? -1; return (
{options.map((option, i) => { const isChecked = option.value === selected; const isTabStop = i === (tabStop >= 0 ? tabStop : fallbackStop); const descId = `${groupId}-${i}-desc`; return ( ); })}
); } ``` ### Select Listbox-style picker with type-ahead and full keyboard nav. URL: https://dev.ononc.com/ui Path: src/components/ui/select.tsx ```tsx "use client"; import { type KeyboardEvent, useEffect, useId, useRef, useState, } from "react"; import { AnimatePresence, motion } from "motion/react"; import { Check, ChevronsUpDown } from "lucide-react"; import { cn } from "@/lib/utils"; export interface SelectOption { value: string; label: string; disabled?: boolean; } export interface SelectProps { options: SelectOption[]; value?: string; defaultValue?: string; onValueChange?: (value: string) => void; placeholder?: string; disabled?: boolean; className?: string; "aria-label"?: string; } /** * Select — an accessible listbox-style picker. Open with Enter/Space/↑/↓, * navigate with arrows + Home/End, type to jump to a match, Enter selects and * Escape closes. Closes on outside click and restores focus to the trigger. * Wired with listbox/option roles and aria-activedescendant. */ export function Select({ options, value, defaultValue, onValueChange, placeholder = "Select…", disabled = false, className, ...aria }: SelectProps) { const id = useId(); const rootRef = useRef(null); const triggerRef = useRef(null); const listRef = useRef(null); const typeahead = useRef({ query: "", at: 0 }); const isControlled = value !== undefined; const [internal, setInternal] = useState(defaultValue ?? ""); const selected = isControlled ? value : internal; const [open, setOpen] = useState(false); const [active, setActive] = useState(0); const selectedOption = options.find((o) => o.value === selected); const enabledIndexes = options .map((o, i) => (o.disabled ? -1 : i)) .filter((i) => i >= 0); useEffect(() => { if (!open) return; const onDown = (e: PointerEvent) => { if (!rootRef.current?.contains(e.target as Node)) setOpen(false); }; document.addEventListener("pointerdown", onDown); return () => document.removeEventListener("pointerdown", onDown); }, [open]); // Keep the active option scrolled into view. useEffect(() => { if (!open) return; listRef.current ?.querySelector(`#${CSS.escape(`${id}-opt-${active}`)}`) ?.scrollIntoView({ block: "nearest" }); }, [open, active, id]); const openList = () => { if (disabled) return; const start = options.findIndex((o) => o.value === selected && !o.disabled); setActive(start >= 0 ? start : (enabledIndexes[0] ?? 0)); setOpen(true); }; const close = () => { setOpen(false); triggerRef.current?.focus(); }; const choose = (index: number) => { const opt = options[index]; if (!opt || opt.disabled) return; if (!isControlled) setInternal(opt.value); onValueChange?.(opt.value); setOpen(false); triggerRef.current?.focus(); }; const step = (dir: 1 | -1) => { const pos = enabledIndexes.indexOf(active); const nextPos = pos < 0 ? 0 : (pos + dir + enabledIndexes.length) % enabledIndexes.length; setActive(enabledIndexes[nextPos]); }; const onType = (key: string) => { const now = Date.now(); const t = typeahead.current; t.query = now - t.at > 600 ? key : t.query + key; t.at = now; const match = options.findIndex( (o) => !o.disabled && o.label.toLowerCase().startsWith(t.query.toLowerCase()), ); if (match >= 0) { if (open) setActive(match); else choose(match); } }; const onKeyDown = (e: KeyboardEvent) => { if (!open) { if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) { e.preventDefault(); openList(); } else if (e.key.length === 1) { onType(e.key); } return; } switch (e.key) { case "ArrowDown": e.preventDefault(); step(1); break; case "ArrowUp": e.preventDefault(); step(-1); break; case "Home": e.preventDefault(); setActive(enabledIndexes[0] ?? 0); break; case "End": e.preventDefault(); setActive(enabledIndexes[enabledIndexes.length - 1] ?? 0); break; case "Enter": case " ": e.preventDefault(); choose(active); break; case "Escape": e.preventDefault(); close(); break; case "Tab": setOpen(false); break; default: if (e.key.length === 1) onType(e.key); } }; return (
{open && ( {options.map((option, i) => { const isSelected = option.value === selected; return (
  • { e.preventDefault(); choose(i); }} onMouseMove={() => !option.disabled && setActive(i)} className={cn( "flex items-center justify-between gap-2 rounded-md px-3 py-2 text-sm", option.disabled ? "cursor-not-allowed text-muted-2" : "cursor-pointer text-foreground", i === active && !option.disabled && "bg-brand/15", )} > {option.label} {isSelected && }
  • ); })}
    )}
    ); } ``` ### Number Input Numeric stepper with clamp, step, and keyboard control. URL: https://dev.ononc.com/ui/number-input Path: src/components/ui/number-input.tsx ```tsx "use client"; import { type KeyboardEvent, useState } from "react"; import { Minus, Plus } from "lucide-react"; import { cn } from "@/lib/utils"; export interface NumberInputProps { value?: number; defaultValue?: number; min?: number; max?: number; step?: number; onValueChange?: (value: number) => void; disabled?: boolean; className?: string; "aria-label"?: string; } const decimalsOf = (step: number) => (step.toString().split(".")[1]?.length ?? 0); /** * NumberInput — an accessible numeric stepper (role="spinbutton"). The −/+ * buttons and ↑/↓ adjust by step, PageUp/PageDown by ten steps, Home/End jump * to the bounds, and every value is clamped to [min, max]. Controlled or * uncontrolled. */ export function NumberInput({ value, defaultValue = 0, min = -Infinity, max = Infinity, step = 1, onValueChange, disabled = false, className, ...aria }: NumberInputProps) { const isControlled = value !== undefined; const [internal, setInternal] = useState(defaultValue); const current = isControlled ? value : internal; const [draft, setDraft] = useState(null); const clamp = (n: number) => Math.min(max, Math.max(min, n)); const round = (n: number) => { const d = decimalsOf(step); return d > 0 ? Number(n.toFixed(d)) : n; }; const commit = (n: number) => { if (Number.isNaN(n)) return; const next = round(clamp(n)); if (!isControlled) setInternal(next); onValueChange?.(next); return next; }; const nudge = (delta: number) => { setDraft(null); commit(current + delta); }; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "ArrowUp") { e.preventDefault(); nudge(step); } else if (e.key === "ArrowDown") { e.preventDefault(); nudge(-step); } else if (e.key === "PageUp") { e.preventDefault(); nudge(step * 10); } else if (e.key === "PageDown") { e.preventDefault(); nudge(-step * 10); } else if (e.key === "Home" && Number.isFinite(min)) { e.preventDefault(); setDraft(null); commit(min); } else if (e.key === "End" && Number.isFinite(max)) { e.preventDefault(); setDraft(null); commit(max); } }; const btn = "grid size-9 shrink-0 place-items-center text-muted transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"; return (
    { const raw = e.target.value; setDraft(raw); const parsed = Number(raw); if (raw.trim() !== "" && !Number.isNaN(parsed)) commit(parsed); }} onKeyDown={onKeyDown} onBlur={() => { if (draft !== null) { const parsed = Number(draft); commit(Number.isNaN(parsed) ? current : parsed); } setDraft(null); }} className="w-14 border-x border-border bg-transparent py-2 text-center text-sm tabular-nums outline-none" />
    ); } ``` ### Textarea Auto-resizing multiline field with an optional counter. URL: https://dev.ononc.com/ui Path: src/components/ui/textarea.tsx ```tsx "use client"; import { type ChangeEvent, type TextareaHTMLAttributes, useCallback, useEffect, useId, useRef, useState, } from "react"; import { cn } from "@/lib/utils"; export interface TextareaProps extends Omit, "rows"> { /** Minimum visible rows before content grows the field. */ minRows?: number; /** Maximum rows the field grows to before it scrolls. */ maxRows?: number; /** Show a live character counter (requires maxLength). */ showCount?: boolean; } /** * Textarea — an auto-resizing multiline field. It grows with its content from * minRows up to maxRows, then scrolls. With maxLength + showCount it renders a * polite live character counter. Height is driven by direct DOM measurement, so * it works controlled or uncontrolled. */ export function Textarea({ minRows = 3, maxRows = 10, showCount = false, className, value, defaultValue, maxLength, onChange, ...props }: TextareaProps) { const id = useId(); const ref = useRef(null); const [internalCount, setInternalCount] = useState( String(defaultValue ?? "").length, ); const count = value !== undefined ? String(value).length : internalCount; const resize = useCallback(() => { const el = ref.current; if (!el) return; const style = window.getComputedStyle(el); const lineHeight = parseFloat(style.lineHeight) || 20; const paddingY = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom); const borderY = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth); const maxHeight = lineHeight * maxRows + paddingY + borderY; el.style.height = "auto"; const next = Math.min(el.scrollHeight + borderY, maxHeight); el.style.height = `${next}px`; el.style.overflowY = el.scrollHeight + borderY > maxHeight ? "auto" : "hidden"; }, [maxRows]); // Resize on mount and whenever a controlled value changes. useEffect(() => { resize(); }, [resize, value]); const handleChange = (e: ChangeEvent) => { setInternalCount(e.target.value.length); resize(); onChange?.(e); }; return (