# Mellow UI > Mellow UI is a shadcn-compatible, copy-paste React component library with an editorial / print aesthetic. Each component is a single self-contained file — install via the shadcn CLI, own the source. Built for React 19, Next.js 15, TypeScript 5.8, and motion/react v11. Design tokens (`--ink`, `--background`, `--mute`, `--fog`, `--rule`, font families) auto-flip between dark and light themes. All components are client components, respect `prefers-reduced-motion`, and have no shared utilities — they are meant to be copied, not imported. ## Install any component ```bash npx shadcn@latest add https://mellowui.com/r/.json ``` ## Components - [Encrypted Text Reveal](https://mellowui.com/llms/encrypted-text.md): Characters scramble through random glyphs then resolve to the final text on scroll. - [Warp Grid Background](https://mellowui.com/llms/warp-grid.md): Canvas grid vertices displaced by evolving value noise — drifts continuously, or warps locally around the cursor. - [Phosphor Sweep Background](https://mellowui.com/llms/phosphor-sweep.md): Rotating beam sweeps a dot grid, leaving dots to fade with phosphor-persistence decay. - [Flow Field Background](https://mellowui.com/llms/flow-field.md): Curl noise vector field rendered as dense directional strokes — cursor parts the flow. - [Depth Button](https://mellowui.com/llms/depth-button.md): A tactile raised button with a fixed depth base — lifts on hover, snaps down on press. - [Magnetic Button](https://mellowui.com/llms/magnetic-button.md): A button that translates toward the cursor with spring physics, with the label counter-drifting to create a sense of mass. - [Kinetic Wall](https://mellowui.com/llms/kinetic-wall.md): A grid of miniature analog clocks whose hands choreograph to spell words — inspired by kinetic clock installations. - [Combination Text](https://mellowui.com/llms/combination-text.md): A mechanical combination lock that spins through characters to form words. - [Springy Text](https://mellowui.com/llms/springy-text.md): Words fall into place with individual spring physics on scroll enter, drop away downward on exit. - [Matrix Hero](https://mellowui.com/llms/matrix-hero.md): Full-width hero section with cascading matrix rain and centered green-phosphor glow text. - [Mechanical Keyboard](https://mellowui.com/llms/mechanical-keyboard.md): Full Mac keyboard with tactile depth-button keycaps — lifts on hover, snaps on press, plays synthesised mechanical click sounds via WebAudio. ## Resources - [Homepage](https://mellowui.com) - [llms-full.txt (everything in one file)](https://mellowui.com/llms-full.txt) - [Registry manifest](https://mellowui.com/registry.json) --- # Encrypted Text Reveal (`encrypted-text`) > Characters scramble through random glyphs then resolve to the final text on scroll. - **Docs:** https://mellowui.com/components/encrypted-text - **Registry:** https://mellowui.com/r/encrypted-text.json - **Categories:** text, scroll, animation - **Dependencies:** motion ## Install ```bash npx shadcn@latest add https://mellowui.com/r/encrypted-text.json ``` ## When to use Add an EncryptedText component from the mellow library that scrambles characters through random glyphs before revealing the final text. It accepts a `text` prop, a `trigger` prop ('scroll' | 'hover' | 'mount'), and a `duration` prop in ms. Use trigger='mount' for hero headings and trigger='scroll' for content sections. ## Props | Prop | Type | Default | Description | |---|---|---|---| | `text` | `string` | — | The text to display and animate. | | `trigger` | `"scroll" \| "hover" \| "mount"` | `"scroll"` | What triggers the reveal animation. | | `duration` | `number` | `1500` | Total animation duration in milliseconds. | | `stagger` | `number` | `30` | Per-character stagger delay in milliseconds. | | `glyphs` | `string` | `"ABCDEFGHIJKLMNOPQRSTUVWXYZ..."` | Pool of characters used during the scramble phase. | | `className` | `string` | — | Additional CSS classes. | ## Design notes - Client component (`"use client"`) — required for animation and refs. - Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors. - Respects `prefers-reduced-motion: reduce` where applicable. - Self-contained — no shared utilities. Copy-paste, not a package. ## Source ```tsx "use client"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { useInView } from "motion/react"; const DEFAULT_GLYPHS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%&*!"; interface EncryptedTextProps { text: string; trigger?: "scroll" | "hover" | "mount"; duration?: number; stagger?: number; glyphs?: string; className?: string; } function useReducedMotion(): boolean { const [reduced, setReduced] = useState(false); useEffect(() => { const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); setReduced(mq.matches); const handler = (e: MediaQueryListEvent) => setReduced(e.matches); mq.addEventListener("change", handler); return () => mq.removeEventListener("change", handler); }, []); return reduced; } function detGlyph(text: string, i: number, glyphs: string): string { let h = 0x811c9dc5; for (let j = 0; j < text.length; j++) { h = Math.imul(h ^ text.charCodeAt(j), 0x01000193); } h = Math.imul(h ^ (i * 2654435761), 0x01000193); h ^= h >>> 13; h = Math.imul(h, 0x5bd1e995); h ^= h >>> 15; const idx = (h >>> 0) % glyphs.length; return glyphs[idx]; } function randGlyph(glyphs: string): string { return glyphs[Math.floor(Math.random() * glyphs.length)]; } function EncryptedTextInner({ text, trigger = "scroll", duration = 1500, stagger = 30, glyphs = DEFAULT_GLYPHS, className, }: EncryptedTextProps) { const reducedMotion = useReducedMotion(); const containerRef = useRef(null); const [hovered, setHovered] = useState(false); const [hasMounted, setHasMounted] = useState(false); const isInView = useInView(containerRef, { once: true, margin: "0px 0px -15% 0px", }); const startScrambled = trigger === "scroll" || trigger === "mount"; const [chars, setChars] = useState(() => { if (!startScrambled) return text.split(""); return text.split("").map((c, i) => c === " " ? " " : detGlyph(text, i, glyphs) ); }); const active = useMemo(() => { if (trigger === "scroll") return isInView; if (trigger === "hover") return hovered; if (trigger === "mount") return hasMounted; return false; }, [trigger, isInView, hovered, hasMounted]); useEffect(() => { setHasMounted(true); }, []); useEffect(() => { if (reducedMotion) { setChars(text.split("")); return; } if (!active) { if (trigger === "hover") { setChars(text.split("")); } return; } const target = text.split(""); const charState = target.map((c, i) => ({ glyph: c === " " ? " " : detGlyph(text, i, glyphs), lastSwap: 0, })); let raf = 0; let startTime = 0; let cancelled = false; const tick = (timestamp: number) => { if (cancelled) return; if (!startTime) { startTime = timestamp; for (const s of charState) s.lastSwap = timestamp; } const elapsed = timestamp - startTime; const next = target.map((targetChar, i) => { if (targetChar === " ") return " "; const charDelay = i * stagger; const charElapsed = elapsed - charDelay; const state = charState[i]; if (charElapsed < 0) { if (timestamp - state.lastSwap >= 28) { state.glyph = randGlyph(glyphs); state.lastSwap = timestamp; } return state.glyph; } const resolveDuration = Math.max(duration - charDelay, 220); const progress = Math.min(charElapsed / resolveDuration, 1); if (progress >= 1) { state.glyph = targetChar; return targetChar; } let swapInterval: number; if (progress < 0.6) { swapInterval = 28; } else { const t = (progress - 0.6) / 0.4; const eased = t * t * t; swapInterval = 28 + eased * 260; } if (timestamp - state.lastSwap >= swapInterval) { state.glyph = randGlyph(glyphs); state.lastSwap = timestamp; } return state.glyph; }); setChars(next); const totalDuration = duration + target.length * stagger + 60; if (elapsed < totalDuration) { raf = requestAnimationFrame(tick); } else { setChars(target); } }; raf = requestAnimationFrame(tick); return () => { cancelled = true; cancelAnimationFrame(raf); }; }, [active, trigger, text, duration, stagger, glyphs, reducedMotion]); const handlers = trigger === "hover" ? { onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), } : {}; return ( {text} ); } export function EncryptedText(props: EncryptedTextProps) { return ; } export default EncryptedText; ``` ## Demo ```tsx "use client"; import React, { useState } from "react"; import { EncryptedText } from "../mellow/encrypted-text"; export default function EncryptedTextDemo() { const [trigger, setTrigger] = useState<"scroll" | "hover" | "mount">("mount"); return (
{(["mount", "hover", "scroll"] as const).map((t) => ( ))}

trigger = “{trigger}”

); } ``` --- # Warp Grid Background (`warp-grid`) > Canvas grid vertices displaced by evolving value noise — drifts continuously, or warps locally around the cursor. - **Docs:** https://mellowui.com/components/warp-grid - **Registry:** https://mellowui.com/r/warp-grid.json - **Categories:** background, canvas, interactive, animation - **Dependencies:** none ## Install ```bash npx shadcn@latest add https://mellowui.com/r/warp-grid.json ``` ## When to use Add a WarpGrid background component from the mellow library. It displaces canvas grid vertices with evolving value noise. Use cursor={true} for a cursor-local radial warp (rest of grid stays flat) or leave it off for continuous ambient warping. Key props: lineColor, spacing, lineWidth, strength, speed, cursor, influenceRadius. ## Props | Prop | Type | Default | Description | |---|---|---|---| | `lineColor` | `string` | `"rgba(var(--ink-rgb),0.15)"` | Color of the grid lines. | | `spacing` | `number` | `40` | Grid cell size in pixels. | | `lineWidth` | `number` | `0.6` | Stroke width of the grid lines. | | `strength` | `number` | `28` | Maximum vertex displacement in pixels. | | `speed` | `number` | `1` | Noise evolution speed multiplier. | | `cursor` | `boolean` | `false` | When true, warp is radially scoped to the cursor — rest of the grid stays flat. | | `influenceRadius` | `number` | `180` | Radius of cursor warp influence in pixels (only used when cursor={true}). | | `className` | `string` | — | Additional CSS classes. | | `children` | `React.ReactNode` | — | Content rendered above the grid. | ## Design notes - Client component (`"use client"`) — required for animation and refs. - Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors. - Respects `prefers-reduced-motion: reduce` where applicable. - Self-contained — no shared utilities. Copy-paste, not a package. ## Source ```tsx "use client"; import React, { useEffect, useRef, useState } from "react"; function useInkRgb(): string { const [rgb, setRgb] = useState("244, 241, 236"); useEffect(() => { const read = () => { const v = getComputedStyle(document.documentElement) .getPropertyValue("--ink-rgb") .trim(); if (v) setRgb(v); }; read(); const obs = new MutationObserver(read); obs.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme"], }); return () => obs.disconnect(); }, []); return rgb; } export interface WarpGridProps { lineColor?: string; spacing?: number; lineWidth?: number; strength?: number; speed?: number; cursor?: boolean; influenceRadius?: number; style?: React.CSSProperties; className?: string; children?: React.ReactNode; } function hash(x: number, y: number): number { const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453; return n - Math.floor(n); } function vnoise(x: number, y: number): number { const ix = Math.floor(x), iy = Math.floor(y); const fx = x - ix, fy = y - iy; const ux = fx * fx * (3 - 2 * fx); const uy = fy * fy * (3 - 2 * fy); const ab = hash(ix, iy) + (hash(ix + 1, iy) - hash(ix, iy)) * ux; const cd = hash(ix, iy + 1) + (hash(ix + 1, iy + 1) - hash(ix, iy + 1)) * ux; return ab + (cd - ab) * uy; } function smoothstep(t: number): number { return t * t * (3 - 2 * t); } export function WarpGrid({ lineColor, spacing = 40, lineWidth = 0.6, strength = 28, speed = 1, cursor = false, influenceRadius = 180, style, className, children, }: WarpGridProps) { const inkRgb = useInkRgb(); const resolvedLineColor = lineColor ?? `rgba(${inkRgb}, 0.15)`; const wrapperRef = useRef(null); const canvasRef = useRef(null); const sizeRef = useRef({ w: 0, h: 0 }); const cursorRef = useRef({ x: -99999, y: -99999 }); useEffect(() => { if (!cursor) return; const wrapper = wrapperRef.current; if (!wrapper) return; const onMove = (e: MouseEvent) => { const rect = wrapper.getBoundingClientRect(); cursorRef.current = { x: e.clientX - rect.left, y: e.clientY - rect.top }; }; const onLeave = () => { cursorRef.current = { x: -99999, y: -99999 }; }; wrapper.addEventListener("mousemove", onMove); wrapper.addEventListener("mouseleave", onLeave); return () => { wrapper.removeEventListener("mousemove", onMove); wrapper.removeEventListener("mouseleave", onLeave); }; }, [cursor]); useEffect(() => { const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (reduced) return; if (!canvasRef.current) return; const canvas: HTMLCanvasElement = canvasRef.current; const ctxOrNull = canvas.getContext("2d"); if (!ctxOrNull) return; const ctx: CanvasRenderingContext2D = ctxOrNull; let rafId: number; let t = 0; function resize() { const dpr = Math.min(window.devicePixelRatio, 2); const rect = canvas.getBoundingClientRect(); sizeRef.current = { w: rect.width, h: rect.height }; canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; ctx.scale(dpr, dpr); } const ro = new ResizeObserver(resize); ro.observe(canvas); resize(); function draw() { const { w, h } = sizeRef.current; if (w === 0 || h === 0) { rafId = requestAnimationFrame(draw); return; } ctx.clearRect(0, 0, w, h); const noiseScale = 0.003; const pad = Math.ceil(strength / spacing) + 1; const cols = Math.ceil(w / spacing) + pad * 2 + 1; const rows = Math.ceil(h / spacing) + pad * 2 + 1; const offX = -pad * spacing; const offY = -pad * spacing; const cx = cursorRef.current.x; const cy = cursorRef.current.y; const vx: number[][] = []; const vy: number[][] = []; for (let row = 0; row < rows; row++) { vx[row] = []; vy[row] = []; for (let col = 0; col < cols; col++) { const bx = offX + col * spacing; const by = offY + row * spacing; const nx = bx * noiseScale; const ny = by * noiseScale; let s = strength; if (cursor) { const dist = Math.sqrt((bx - cx) ** 2 + (by - cy) ** 2); const raw = Math.max(0, 1 - dist / influenceRadius); s = strength * smoothstep(raw); } vx[row][col] = bx + (vnoise(nx, ny + t) * 2 - 1) * s; vy[row][col] = by + (vnoise(nx + 5.2, ny + 1.3 + t) * 2 - 1) * s; } } ctx.strokeStyle = resolvedLineColor; ctx.lineWidth = lineWidth; ctx.beginPath(); for (let row = 0; row < rows; row++) { ctx.moveTo(vx[row][0], vy[row][0]); for (let col = 1; col < cols; col++) { ctx.lineTo(vx[row][col], vy[row][col]); } } for (let col = 0; col < cols; col++) { ctx.moveTo(vx[0][col], vy[0][col]); for (let row = 1; row < rows; row++) { ctx.lineTo(vx[row][col], vy[row][col]); } } ctx.stroke(); t += 0.003 * speed; rafId = requestAnimationFrame(draw); } rafId = requestAnimationFrame(draw); return () => { cancelAnimationFrame(rafId); ro.disconnect(); }; }, [resolvedLineColor, spacing, lineWidth, strength, speed, cursor, influenceRadius]); return (
); } export default WarpGrid; ``` ## Demo ```tsx "use client"; import React, { useState } from "react"; import { WarpGrid } from "../mellow/warp-grid"; type Mode = "continuous" | "cursor"; export default function WarpGridDemo() { const [mode, setMode] = useState("continuous"); return (
{(["continuous", "cursor"] as Mode[]).map((m) => { const active = mode === m; return ( ); })}
{mode === "continuous" ? ( <>

Curl noise vector field — strokes follow the flow

Vertices drift continuously

) : ( <>

Move cursor to warp the grid

Rest of grid stays flat

)}
); } ``` --- # Phosphor Sweep Background (`phosphor-sweep`) > Rotating beam sweeps a dot grid, leaving dots to fade with phosphor-persistence decay. - **Docs:** https://mellowui.com/components/phosphor-sweep - **Registry:** https://mellowui.com/r/phosphor-sweep.json - **Categories:** background, canvas, animation - **Dependencies:** none ## Install ```bash npx shadcn@latest add https://mellowui.com/r/phosphor-sweep.json ``` ## When to use Add a PhosphorSweep background component from the mellow library. A rotating radar beam sweeps across a dot grid — dots glow on hit then fade with exponential phosphor-persistence decay. Entirely autonomous, no cursor interaction needed. Key props: dotSize, spacing, speed (rotations/sec), decay (fade seconds). ## Props | Prop | Type | Default | Description | |---|---|---|---| | `dotColor` | `string` | `"rgba(var(--ink-rgb),0.07)"` | Base color of unlit dots. | | `dotSize` | `number` | `1.5` | Radius of each dot in pixels. | | `spacing` | `number` | `28` | Gap between dots in pixels. | | `speed` | `number` | `0.12` | Beam rotations per second. | | `decay` | `number` | `2.5` | Phosphor fade duration in seconds. | | `className` | `string` | — | Additional CSS classes. | | `children` | `React.ReactNode` | — | Content rendered above the canvas. | ## Design notes - Client component (`"use client"`) — required for animation and refs. - Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors. - Respects `prefers-reduced-motion: reduce` where applicable. - Self-contained — no shared utilities. Copy-paste, not a package. ## Source ```tsx "use client"; import React, { useEffect, useRef, useCallback, useState } from "react"; function useInkRgb(): string { const [rgb, setRgb] = useState("244, 241, 236"); useEffect(() => { const read = () => { const v = getComputedStyle(document.documentElement) .getPropertyValue("--ink-rgb") .trim(); if (v) setRgb(v); }; read(); const obs = new MutationObserver(read); obs.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme"], }); return () => obs.disconnect(); }, []); return rgb; } export interface PhosphorSweepProps { dotColor?: string; dotSize?: number; spacing?: number; speed?: number; decay?: number; style?: React.CSSProperties; className?: string; children?: React.ReactNode; } export function PhosphorSweep({ dotColor, dotSize = 1.5, spacing = 28, speed = 0.12, decay = 2.5, style, className, children, }: PhosphorSweepProps) { const inkRgb = useInkRgb(); const resolvedDotColor = dotColor ?? `rgba(${inkRgb}, 0.07)`; const canvasRef = useRef(null); const rafRef = useRef(0); const angleRef = useRef(0); const lastHitsRef = useRef>(new Map()); const prevTimeRef = useRef(0); const sizeRef = useRef({ w: 0, h: 0 }); const draw = useCallback( (timestamp: number) => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const now = timestamp / 1000; const dt = prevTimeRef.current ? Math.min(now - prevTimeRef.current, 0.05) : 0.016; prevTimeRef.current = now; const { w: W, h: H } = sizeRef.current; if (!W || !H) { rafRef.current = requestAnimationFrame(draw); return; } ctx.clearRect(0, 0, canvas.width, canvas.height); const cx = W / 2; const cy = H / 2; angleRef.current += speed * Math.PI * 2 * dt; const beamAngle = angleRef.current; const lastHits = lastHitsRef.current; const cols = Math.ceil(W / spacing) + 1; const rows = Math.ceil(H / spacing) + 1; const offX = (W % spacing) / 2; const offY = (H % spacing) / 2; for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { const x = offX + col * spacing; const y = offY + row * spacing; const key = `${col},${row}`; const dotAngle = Math.atan2(y - cy, x - cx); let diff = ((dotAngle - beamAngle) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2); if (diff > Math.PI) diff -= Math.PI * 2; if (Math.abs(diff) < 0.05) { lastHits.set(key, now); } const lastHit = lastHits.get(key); let intensity = 0; if (lastHit !== undefined) { const age = now - lastHit; if (age < decay) { intensity = Math.exp((-age * 4) / decay); } else { lastHits.delete(key); } } ctx.beginPath(); ctx.arc( x, y, dotSize * (1 + intensity * 1.2), 0, Math.PI * 2 ); if (intensity > 0.01) { ctx.fillStyle = `rgba(${inkRgb},${(0.07 + intensity * 0.83).toFixed(3)})`; } else { ctx.fillStyle = resolvedDotColor; } ctx.fill(); } } const reach = Math.hypot(W, H); const bx = cx + Math.cos(beamAngle) * reach; const by = cy + Math.sin(beamAngle) * reach; const grad = ctx.createLinearGradient(cx, cy, bx, by); grad.addColorStop(0, `rgba(${inkRgb},0.18)`); grad.addColorStop(0.35, `rgba(${inkRgb},0.06)`); grad.addColorStop(1, `rgba(${inkRgb},0)`); ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(bx, by); ctx.strokeStyle = grad; ctx.lineWidth = 1.5; ctx.stroke(); rafRef.current = requestAnimationFrame(draw); }, [resolvedDotColor, inkRgb, dotSize, spacing, speed, decay] ); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const reduced = window.matchMedia( "(prefers-reduced-motion: reduce)" ).matches; if (reduced) return; const ro = new ResizeObserver((entries) => { for (const entry of entries) { const { width, height } = entry.contentRect; const dpr = Math.min(window.devicePixelRatio, 2); sizeRef.current = { w: width, h: height }; canvas.width = width * dpr; canvas.height = height * dpr; const ctx = canvas.getContext("2d"); if (ctx) ctx.scale(dpr, dpr); canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; lastHitsRef.current.clear(); } }); ro.observe(canvas.parentElement!); rafRef.current = requestAnimationFrame(draw); return () => { cancelAnimationFrame(rafRef.current); ro.disconnect(); }; }, [draw]); return (
); } export default PhosphorSweep; ``` ## Demo ```tsx "use client"; import React from "react"; import { PhosphorSweep } from "../mellow/phosphor-sweep"; export default function PhosphorSweepDemo() { return (

Rotating beam — phosphor persistence decay

Dots glow as the sweep passes, fade slowly

); } ``` --- # Flow Field Background (`flow-field`) > Curl noise vector field rendered as dense directional strokes — cursor parts the flow. - **Docs:** https://mellowui.com/components/flow-field - **Registry:** https://mellowui.com/r/flow-field.json - **Categories:** background, canvas, interactive, animation - **Dependencies:** none ## Install ```bash npx shadcn@latest add https://mellowui.com/r/flow-field.json ``` ## When to use Add a FlowField background component from the mellow library. Dense short strokes are oriented along a slowly-evolving curl noise vector field. Moving the cursor attracts nearby strokes like parting water. Key props: strokeColor, spacing, strokeLength, speed, influenceRadius. ## Props | Prop | Type | Default | Description | |---|---|---|---| | `strokeColor` | `string` | `"rgba(var(--ink-rgb),0.12)"` | Base color of the field strokes. | | `spacing` | `number` | `22` | Distance between stroke origins in pixels. | | `strokeLength` | `number` | `16` | Length of each stroke in pixels. | | `speed` | `number` | `1` | Field evolution speed multiplier. | | `influenceRadius` | `number` | `130` | Cursor attraction radius in pixels. | | `className` | `string` | — | Additional CSS classes. | | `children` | `React.ReactNode` | — | Content rendered above the canvas. | ## Design notes - Client component (`"use client"`) — required for animation and refs. - Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors. - Respects `prefers-reduced-motion: reduce` where applicable. - Self-contained — no shared utilities. Copy-paste, not a package. ## Source ```tsx "use client"; import React, { useEffect, useRef, useCallback, useState } from "react"; function useInkRgb(): string { const [rgb, setRgb] = useState("244, 241, 236"); useEffect(() => { const read = () => { const v = getComputedStyle(document.documentElement) .getPropertyValue("--ink-rgb") .trim(); if (v) setRgb(v); }; read(); const obs = new MutationObserver(read); obs.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme"], }); return () => obs.disconnect(); }, []); return rgb; } function valueNoise(x: number, y: number): number { const ix = Math.floor(x); const iy = Math.floor(y); const fx = x - ix; const fy = y - iy; const ux = fx * fx * (3 - 2 * fx); const uy = fy * fy * (3 - 2 * fy); function r(xi: number, yi: number): number { return Math.abs(Math.sin(xi * 127.1 + yi * 311.7) * 43758.5453) % 1; } const a = r(ix, iy); const b = r(ix + 1, iy); const c = r(ix, iy + 1); const d = r(ix + 1, iy + 1); return a + (b - a) * ux + (c - a) * uy + (a - b - c + d) * ux * uy; } function flowAngle(x: number, y: number, t: number): number { const s = 0.003; const n1 = valueNoise(x * s, y * s + t * 0.4); const n2 = valueNoise(x * s + 5.2, y * s + 1.3 + t * 0.3); return (n1 - 0.5) * Math.PI * 4 + (n2 - 0.5) * Math.PI * 2; } export interface FlowFieldProps { strokeColor?: string; spacing?: number; strokeLength?: number; speed?: number; influenceRadius?: number; style?: React.CSSProperties; className?: string; children?: React.ReactNode; } export function FlowField({ strokeColor, spacing = 22, strokeLength = 16, speed = 1, influenceRadius = 130, style, className, children, }: FlowFieldProps) { const inkRgb = useInkRgb(); const resolvedStrokeColor = strokeColor ?? `rgba(${inkRgb}, 0.12)`; const canvasRef = useRef(null); const rafRef = useRef(0); const timeRef = useRef(0); const mouseRef = useRef<{ x: number; y: number } | null>(null); const sizeRef = useRef({ w: 0, h: 0 }); const draw = useCallback( (timestamp: number) => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; const { w: W, h: H } = sizeRef.current; if (!W || !H) { rafRef.current = requestAnimationFrame(draw); return; } ctx.clearRect(0, 0, canvas.width, canvas.height); timeRef.current += 0.004 * speed; const t = timeRef.current; const mouse = mouseRef.current; const half = strokeLength / 2; const cols = Math.ceil(W / spacing) + 1; const rows = Math.ceil(H / spacing) + 1; const offX = (W % spacing) / 2; const offY = (H % spacing) / 2; ctx.beginPath(); ctx.strokeStyle = resolvedStrokeColor; ctx.lineWidth = 0.7; type ActiveStroke = { x: number; y: number; angle: number; inf: number }; const active: ActiveStroke[] = []; for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { const x = offX + col * spacing; const y = offY + row * spacing; let angle = flowAngle(x, y, t); let inf = 0; if (mouse) { const dx = x - mouse.x; const dy = y - mouse.y; const dist = Math.hypot(dx, dy); if (dist < influenceRadius) { inf = Math.pow(1 - dist / influenceRadius, 2); const attract = Math.atan2(mouse.y - y, mouse.x - x); let da = attract - angle; while (da > Math.PI) da -= 2 * Math.PI; while (da < -Math.PI) da += 2 * Math.PI; angle += da * inf * 0.75; } } const cx2 = Math.cos(angle); const cy2 = Math.sin(angle); if (inf > 0.02) { active.push({ x, y, angle, inf }); } else { ctx.moveTo(x - cx2 * half, y - cy2 * half); ctx.lineTo(x + cx2 * half, y + cy2 * half); } } } ctx.stroke(); for (const { x, y, angle, inf } of active) { const cx2 = Math.cos(angle); const cy2 = Math.sin(angle); ctx.beginPath(); ctx.moveTo(x - cx2 * half, y - cy2 * half); ctx.lineTo(x + cx2 * half, y + cy2 * half); ctx.strokeStyle = `rgba(${inkRgb},${(0.12 + inf * 0.65).toFixed(3)})`; ctx.lineWidth = 0.7 + inf * 1.1; ctx.stroke(); } rafRef.current = requestAnimationFrame(draw); }, [resolvedStrokeColor, inkRgb, spacing, strokeLength, speed, influenceRadius] ); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const reduced = window.matchMedia( "(prefers-reduced-motion: reduce)" ).matches; const ro = new ResizeObserver((entries) => { for (const entry of entries) { const { width, height } = entry.contentRect; const dpr = Math.min(window.devicePixelRatio, 2); sizeRef.current = { w: width, h: height }; canvas.width = width * dpr; canvas.height = height * dpr; const ctx = canvas.getContext("2d"); if (ctx) ctx.scale(dpr, dpr); canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; } }); ro.observe(canvas.parentElement!); const onMouseMove = (e: MouseEvent) => { if (reduced) return; const rect = canvas.getBoundingClientRect(); mouseRef.current = { x: e.clientX - rect.left, y: e.clientY - rect.top }; }; const onMouseLeave = () => { mouseRef.current = null; }; canvas.parentElement?.addEventListener("mousemove", onMouseMove); canvas.parentElement?.addEventListener("mouseleave", onMouseLeave); if (!reduced) { rafRef.current = requestAnimationFrame(draw); } return () => { cancelAnimationFrame(rafRef.current); ro.disconnect(); canvas.parentElement?.removeEventListener("mousemove", onMouseMove); canvas.parentElement?.removeEventListener("mouseleave", onMouseLeave); }; }, [draw]); return (
); } export default FlowField; ``` ## Demo ```tsx "use client"; import React from "react"; import { FlowField } from "../mellow/flow-field"; export default function FlowFieldDemo() { return (

Curl noise vector field — strokes follow the flow

Move cursor to part the field

); } ``` --- # Depth Button (`depth-button`) > A tactile raised button with a fixed depth base — lifts on hover, snaps down on press. - **Docs:** https://mellowui.com/components/depth-button - **Registry:** https://mellowui.com/r/depth-button.json - **Categories:** button, interactive, 3d - **Dependencies:** none ## Install ```bash npx shadcn@latest add https://mellowui.com/r/depth-button.json ``` ## When to use Add a DepthButton from the mellow library. It's a raised 3D button with a depth base that the face snaps into on press. Use variant='default' for neutral glass style or variant='accent' for purple. The depth prop (default 4) controls how tall the raised block looks in pixels. ## Props | Prop | Type | Default | Description | |---|---|---|---| | `children` | `React.ReactNode` | — | Button label content. | | `variant` | `"default" \| "accent"` | `"default"` | Color scheme — default is neutral glass, accent is purple. | | `depth` | `number` | `4` | Height of the depth base in pixels. | | `className` | `string` | — | Additional CSS classes. | ## Design notes - Client component (`"use client"`) — required for animation and refs. - Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors. - Respects `prefers-reduced-motion: reduce` where applicable. - Self-contained — no shared utilities. Copy-paste, not a package. ## Source ```tsx "use client"; import React, { useState, useRef, type ButtonHTMLAttributes } from "react"; export interface DepthButtonProps extends ButtonHTMLAttributes { children: React.ReactNode; variant?: "default" | "accent"; depth?: number; size?: "default" | "icon"; sound?: boolean; onTouchStart?: React.TouchEventHandler; onTouchEnd?: React.TouchEventHandler; onTouchCancel?: React.TouchEventHandler; } export function DepthButton({ children, variant = "default", depth = 4, size = "default", sound = true, className, style, disabled, onMouseDown, onMouseUp, onMouseEnter, onMouseLeave, onTouchStart, onTouchEnd, onTouchCancel, ...props }: DepthButtonProps) { const [pressed, setPressed] = useState(false); const [hovered, setHovered] = useState(false); const audioCtxRef = useRef(null); const releaseSoundArmedRef = useRef(false); function playMechanicalPhase(phase: "press" | "release") { if (!sound) return; try { const AudioCtx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; if (!audioCtxRef.current || audioCtxRef.current.state === "closed") { audioCtxRef.current = new AudioCtx(); } const ctx = audioCtxRef.current; const schedule = () => { const sampleRate = ctx.sampleRate; const duration = phase === "press" ? 0.04 : 0.026; const decayExp = phase === "press" ? 7 : 11; const noiseAmp = phase === "press" ? 0.72 : 0.48; const lpHz = phase === "press" ? 2800 : 5500; const gainLinear = phase === "press" ? 0.56 : 0.4; const buf = ctx.createBuffer(1, Math.floor(sampleRate * duration), sampleRate); const data = buf.getChannelData(0); for (let i = 0; i < data.length; i++) { const t = i / data.length; data[i] = (Math.random() * 2 - 1) * Math.pow(1 - t, decayExp) * noiseAmp; } const src = ctx.createBufferSource(); src.buffer = buf; const lp = ctx.createBiquadFilter(); lp.type = "lowpass"; lp.frequency.value = lpHz; const gain = ctx.createGain(); gain.gain.value = gainLinear; src.connect(lp); lp.connect(gain); gain.connect(ctx.destination); src.start(); }; if (ctx.state === "suspended") { ctx.resume().then(schedule); } else { schedule(); } } catch { } } const isAccent = variant === "accent"; const isIcon = size === "icon"; const faceTransform = pressed ? `translateY(${depth}px)` : hovered ? "translateY(-2px)" : "translateY(0px)"; const faceTransition = pressed ? "transform 55ms ease, box-shadow 55ms ease" : "transform 300ms cubic-bezier(0.34, 1.4, 0.64, 1), box-shadow 200ms ease"; return ( ); } export default DepthButton; ``` ## Demo ```tsx "use client"; import React from "react"; import { DepthButton } from "../mellow/depth-button"; export default function DepthButtonDemo() { return (
Get Started Browse Docs Deploy Now
Deep Press Shallow Disabled

Hover to lift · click to press

); } ``` --- # Magnetic Button (`magnetic-button`) > A button that translates toward the cursor with spring physics, with the label counter-drifting to create a sense of mass. - **Docs:** https://mellowui.com/components/magnetic-button - **Registry:** https://mellowui.com/r/magnetic-button.json - **Categories:** button, interactive, animation - **Dependencies:** motion ## Install ```bash npx shadcn@latest add https://mellowui.com/r/magnetic-button.json ``` ## When to use Add a MagneticButton from the mellow library. The button body springs toward the cursor position (translation, not rotation), while the label drifts slightly counter to the pull — creating a parallax sense of mass. On press it snaps to center and springs back. Use variant='default' or variant='accent' for purple. The strength prop (default 0.45) controls pull intensity. ## Props | Prop | Type | Default | Description | |---|---|---|---| | `children` | `React.ReactNode` | — | Button label content. | | `variant` | `"default" \| "accent"` | `"default"` | Color scheme — default is neutral glass, accent is purple. | | `strength` | `number` | `0.45` | Multiplier controlling how far the button pulls toward the cursor. | ## Design notes - Client component (`"use client"`) — required for animation and refs. - Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors. - Respects `prefers-reduced-motion: reduce` where applicable. - Self-contained — no shared utilities. Copy-paste, not a package. ## Source ```tsx "use client"; import React, { useRef, useCallback, type ButtonHTMLAttributes, } from "react"; import { motion, useMotionValue, useSpring, useTransform, } from "motion/react"; export interface MagneticButtonProps extends Omit< ButtonHTMLAttributes, | "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd" > { children: React.ReactNode; variant?: "default" | "accent"; strength?: number; } export function MagneticButton({ children, variant = "default", strength = 0.45, className, style, disabled, onMouseMove, onMouseLeave, onMouseDown, onMouseUp, ...props }: MagneticButtonProps) { const ref = useRef(null); const mx = useMotionValue(0); const my = useMotionValue(0); const sc = useMotionValue(1); const springCfg = { stiffness: 180, damping: 16, mass: 0.9 }; const springX = useSpring(mx, springCfg); const springY = useSpring(my, springCfg); const springScale = useSpring(sc, { stiffness: 380, damping: 26 }); const labelX = useTransform(springX, (v) => -v * 0.3); const labelY = useTransform(springY, (v) => -v * 0.3); const isAccent = variant === "accent"; const handleMouseMove = useCallback( (e: React.MouseEvent) => { if (!ref.current || disabled) return; const rect = ref.current.getBoundingClientRect(); mx.set((e.clientX - (rect.left + rect.width / 2)) * strength); my.set((e.clientY - (rect.top + rect.height / 2)) * strength); onMouseMove?.(e); }, [disabled, strength, mx, my, onMouseMove] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { mx.set(0); my.set(0); sc.set(1); onMouseLeave?.(e); }, [mx, my, sc, onMouseLeave] ); const handleMouseDown = useCallback( (e: React.MouseEvent) => { if (!disabled) { mx.set(0); my.set(0); sc.set(0.93); } onMouseDown?.(e); }, [disabled, mx, my, sc, onMouseDown] ); const handleMouseUp = useCallback( (e: React.MouseEvent) => { sc.set(1); onMouseUp?.(e); }, [sc, onMouseUp] ); return ( {children} ); } export default MagneticButton; ``` ## Demo ```tsx "use client"; import React from "react"; import { MagneticButton } from "../mellow/magnetic-button"; export default function MagneticButtonDemo() { return (
Explore Connect Attract
Strong Subtle Inert

Hover to feel the pull · press to snap back

); } ``` --- # Kinetic Wall (`kinetic-wall`) > A grid of miniature analog clocks whose hands choreograph to spell words — inspired by kinetic clock installations. - **Docs:** https://mellowui.com/components/kinetic-wall - **Registry:** https://mellowui.com/r/kinetic-wall.json - **Categories:** display, canvas, animation - **Dependencies:** none ## Install ```bash npx shadcn@latest add https://mellowui.com/r/kinetic-wall.json ``` ## When to use Add a KineticWall component from the mellow library. It renders a grid of tiny analog clock faces whose hands smoothly rotate to form letterforms, cycling through a words array. Key props: words (string[]), transitionDuration (ms), holdDuration (ms), clockSize (px), gap (px). ## Props | Prop | Type | Default | Description | |---|---|---|---| | `words` | `string[]` | `["HELLO", "WORLD"]` | Array of words to cycle through. | | `transitionDuration` | `number` | `5000` | Duration of the movement phase in milliseconds. | | `holdDuration` | `number` | `2000` | Duration to hold the formed word in milliseconds. | | `clockSize` | `number` | `36` | Diameter of each clock face in pixels. | | `gap` | `number` | `4` | Gap between clocks in pixels. | | `handColor` | `string` | — | Color of the clock hands. Defaults to themed ink color. | | `className` | `string` | — | Additional CSS classes. | ## Design notes - Client component (`"use client"`) — required for animation and refs. - Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors. - Respects `prefers-reduced-motion: reduce` where applicable. - Self-contained — no shared utilities. Copy-paste, not a package. ## Source ```tsx "use client"; import { useEffect, useRef, useState, useCallback } from "react"; function useInkRgb(): string { const [rgb, setRgb] = useState("244, 241, 236"); useEffect(() => { const read = () => { const v = getComputedStyle(document.documentElement) .getPropertyValue("--ink-rgb") .trim(); if (v) setRgb(v); }; read(); const obs = new MutationObserver(read); obs.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme"], }); return () => obs.disconnect(); }, []); return rgb; } export interface KineticWallProps { words?: string[]; transitionDuration?: number; holdDuration?: number; clockSize?: number; gap?: number; handColor?: string; style?: React.CSSProperties; className?: string; } const ANG_U = 0; const ANG_R = 90; const ANG_D = 180; const ANG_L = 270; const ANG_REST = 225; const CHAR_COLS = 3; const CHAR_ROWS = 5; type Edge = [number, number, number, number]; const ve = (r: number, c: number): Edge => [r, c, r + 1, c]; const he = (r: number, c: number): Edge => [r, c, r, c + 1]; function edgesToAngles(edges: Edge[]): [number, number][] { const cells: Map = new Map(); for (const [r1, c1, r2, c2] of edges) { const k1 = `${r1},${c1}`; const k2 = `${r2},${c2}`; if (!cells.has(k1)) cells.set(k1, []); if (!cells.has(k2)) cells.set(k2, []); if (r2 > r1) { cells.get(k1)!.push(ANG_D); cells.get(k2)!.push(ANG_U); } else if (r2 < r1) { cells.get(k1)!.push(ANG_U); cells.get(k2)!.push(ANG_D); } else if (c2 > c1) { cells.get(k1)!.push(ANG_R); cells.get(k2)!.push(ANG_L); } else { cells.get(k1)!.push(ANG_L); cells.get(k2)!.push(ANG_R); } } const grid: [number, number][] = []; for (let r = 0; r < CHAR_ROWS; r++) { for (let c = 0; c < CHAR_COLS; c++) { const dirs = cells.get(`${r},${c}`) || []; if (dirs.length >= 2) grid.push([dirs[0], dirs[1]]); else if (dirs.length === 1) grid.push([dirs[0], ANG_REST]); else grid.push([ANG_REST, ANG_REST]); } } return grid; } const FONT: Record = { A: edgesToAngles([ he(0, 0), he(0, 1), ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0), ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2), he(2, 0), he(2, 1), ]), B: edgesToAngles([ he(0, 0), he(0, 1), ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0), ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2), he(2, 0), he(2, 1), he(4, 0), he(4, 1), ]), C: edgesToAngles([ he(0, 0), he(0, 1), ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0), he(4, 0), he(4, 1), ]), D: edgesToAngles([ he(0, 0), he(0, 1), ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0), ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2), he(4, 0), he(4, 1), ]), E: edgesToAngles([ he(0, 0), he(0, 1), ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0), he(2, 0), he(2, 1), he(4, 0), he(4, 1), ]), F: edgesToAngles([ he(0, 0), he(0, 1), ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0), he(2, 0), he(2, 1), ]), G: edgesToAngles([ he(0, 0), he(0, 1), ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0), he(4, 0), he(4, 1), ve(2, 2), ve(3, 2), he(2, 1), ]), H: edgesToAngles([ ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0), ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2), he(2, 0), he(2, 1), ]), I: edgesToAngles([ he(0, 0), he(0, 1), ve(0, 1), ve(1, 1), ve(2, 1), ve(3, 1), he(4, 0), he(4, 1), ]), J: edgesToAngles([ ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2), he(4, 0), he(4, 1), ve(3, 0), ]), K: edgesToAngles([ ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0), he(2, 0), he(2, 1), ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2), ]), L: edgesToAngles([ ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0), he(4, 0), he(4, 1), ]), M: edgesToAngles([ ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0), ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2), he(0, 0), he(0, 1), ve(0, 1), ve(1, 1), ]), N: edgesToAngles([ ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0), ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2), he(0, 0), he(0, 1), ]), O: edgesToAngles([ he(0, 0), he(0, 1), ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0), ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2), he(4, 0), he(4, 1), ]), P: edgesToAngles([ he(0, 0), he(0, 1), ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0), ve(0, 2), ve(1, 2), he(2, 0), he(2, 1), ]), Q: edgesToAngles([ he(0, 0), he(0, 1), ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0), ve(0, 2), ve(1, 2), ve(2, 2), he(4, 0), he(4, 1), ve(3, 2), ]), R: edgesToAngles([ he(0, 0), he(0, 1), ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0), ve(0, 2), ve(1, 2), he(2, 0), he(2, 1), ve(2, 2), ve(3, 2), ]), S: edgesToAngles([ he(0, 0), he(0, 1), ve(0, 0), ve(1, 0), he(2, 0), he(2, 1), ve(2, 2), ve(3, 2), he(4, 0), he(4, 1), ]), T: edgesToAngles([ he(0, 0), he(0, 1), ve(0, 1), ve(1, 1), ve(2, 1), ve(3, 1), ]), U: edgesToAngles([ ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0), ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2), he(4, 0), he(4, 1), ]), V: edgesToAngles([ ve(0, 0), ve(1, 0), ve(2, 0), ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 1), he(3, 0), he(3, 1), ]), W: edgesToAngles([ ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0), ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2), he(4, 0), he(4, 1), ve(2, 1), ve(3, 1), ]), X: edgesToAngles([ ve(0, 0), ve(1, 0), ve(0, 2), ve(1, 2), he(2, 0), he(2, 1), ve(2, 0), ve(3, 0), ve(2, 2), ve(3, 2), ]), Y: edgesToAngles([ ve(0, 0), ve(1, 0), ve(0, 2), ve(1, 2), he(2, 0), he(2, 1), ve(2, 1), ve(3, 1), ]), Z: edgesToAngles([ he(0, 0), he(0, 1), ve(0, 2), ve(1, 2), he(2, 0), he(2, 1), ve(2, 0), ve(3, 0), he(4, 0), he(4, 1), ]), "0": edgesToAngles([ he(0, 0), he(0, 1), ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0), ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2), he(4, 0), he(4, 1), ]), "1": edgesToAngles([ ve(0, 1), ve(1, 1), ve(2, 1), ve(3, 1), he(0, 0), ]), "2": edgesToAngles([ he(0, 0), he(0, 1), ve(0, 2), ve(1, 2), he(2, 0), he(2, 1), ve(2, 0), ve(3, 0), he(4, 0), he(4, 1), ]), "3": edgesToAngles([ he(0, 0), he(0, 1), ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2), he(2, 0), he(2, 1), he(4, 0), he(4, 1), ]), "4": edgesToAngles([ ve(0, 0), ve(1, 0), ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2), he(2, 0), he(2, 1), ]), "5": edgesToAngles([ he(0, 0), he(0, 1), ve(0, 0), ve(1, 0), he(2, 0), he(2, 1), ve(2, 2), ve(3, 2), he(4, 0), he(4, 1), ]), "6": edgesToAngles([ he(0, 0), he(0, 1), ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0), he(2, 0), he(2, 1), ve(2, 2), ve(3, 2), he(4, 0), he(4, 1), ]), "7": edgesToAngles([ he(0, 0), he(0, 1), ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2), ]), "8": edgesToAngles([ he(0, 0), he(0, 1), ve(0, 0), ve(1, 0), ve(2, 0), ve(3, 0), ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2), he(2, 0), he(2, 1), he(4, 0), he(4, 1), ]), "9": edgesToAngles([ he(0, 0), he(0, 1), ve(0, 0), ve(1, 0), ve(0, 2), ve(1, 2), ve(2, 2), ve(3, 2), he(2, 0), he(2, 1), he(4, 0), he(4, 1), ]), " ": Array.from({ length: CHAR_COLS * CHAR_ROWS }, (): [number, number] => [ANG_REST, ANG_REST]), }; function wordToGrid( word: string ): { targets: [number, number][]; cols: number; rows: number } { const chars = word.toUpperCase().split(""); const gapCols = 1; const totalCols = chars.length * CHAR_COLS + (chars.length - 1) * gapCols; const totalRows = CHAR_ROWS; const targets: [number, number][] = Array.from( { length: totalCols * totalRows }, (): [number, number] => [ANG_REST, ANG_REST] ); let colOffset = 0; for (const ch of chars) { const glyph = FONT[ch] || FONT[" "]; for (let r = 0; r < CHAR_ROWS; r++) { for (let c = 0; c < CHAR_COLS; c++) { const srcIdx = r * CHAR_COLS + c; const dstIdx = r * totalCols + (colOffset + c); targets[dstIdx] = glyph[srcIdx]; } } colOffset += CHAR_COLS + gapCols; } return { targets, cols: totalCols, rows: totalRows }; } function lerpAngle(from: number, to: number, t: number): number { let diff = ((to - from + 540) % 360) - 180; return from + diff * t; } function easeInOutCubic(t: number): number { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; } export function KineticWall({ words = ["HELLO", "WORLD"], transitionDuration = 5000, holdDuration = 2000, clockSize = 36, gap = 4, handColor, style, className, }: KineticWallProps) { const inkRgb = useInkRgb(); const resolvedHandColor = handColor ?? `rgba(${inkRgb}, 0.92)`; const canvasRef = useRef(null); const wrapperRef = useRef(null); const wordIndexRef = useRef(0); const currentAnglesRef = useRef<[number, number][]>([]); const targetAnglesRef = useRef<[number, number][]>([]); const gridInfoRef = useRef({ cols: 0, rows: 0 }); const animStartRef = useRef(0); const phaseRef = useRef<"hold" | "transition">("hold"); const prevAnglesRef = useRef<[number, number][]>([]); const longestWord = words.reduce( (a, b) => (b.length > a.length ? b : a), "" ); const { cols: maxCols } = wordToGrid(longestWord); const [containerWidth, setContainerWidth] = useState(0); useEffect(() => { const wrapper = wrapperRef.current; if (!wrapper) return; const ro = new ResizeObserver((entries) => { for (const entry of entries) { setContainerWidth(entry.contentRect.width); } }); ro.observe(wrapper); return () => ro.disconnect(); }, []); const idealWidth = maxCols * (clockSize + gap) - gap; const effectiveClockSize = containerWidth > 0 && idealWidth > containerWidth ? Math.floor((containerWidth + gap) / maxCols - gap) : clockSize; const clampedClockSize = Math.max(effectiveClockSize, 8); const canvasW = maxCols * (clampedClockSize + gap) - gap; const canvasH = CHAR_ROWS * (clampedClockSize + gap) - gap; const initWord = useCallback(() => { const { targets, cols, rows } = wordToGrid(words[0] || ""); const padded = padToWidth(targets, cols, rows, maxCols); currentAnglesRef.current = padded.map(([a, b]) => [a, b]); targetAnglesRef.current = padded; prevAnglesRef.current = padded.map(([a, b]) => [a, b]); gridInfoRef.current = { cols: maxCols, rows }; wordIndexRef.current = 0; phaseRef.current = "hold"; animStartRef.current = performance.now(); }, [words, maxCols]); useEffect(initWord, [initWord]); useEffect(() => { const reduced = window.matchMedia( "(prefers-reduced-motion: reduce)" ).matches; if (!canvasRef.current) return; const canvas: HTMLCanvasElement = canvasRef.current; const ctxOrNull = canvas.getContext("2d"); if (!ctxOrNull) return; const ctx: CanvasRenderingContext2D = ctxOrNull; let rafId: number; function resize() { const dpr = Math.min(window.devicePixelRatio, 2); canvas.width = canvasW * dpr; canvas.height = canvasH * dpr; canvas.style.width = `${canvasW}px`; canvas.style.height = `${canvasH}px`; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } resize(); const ro = new ResizeObserver(resize); ro.observe(canvas); function draw(now: number) { const { cols, rows } = gridInfoRef.current; if (cols === 0) { rafId = requestAnimationFrame(draw); return; } if (phaseRef.current === "transition" && !reduced) { const elapsed = now - animStartRef.current; const totalCells = cols * rows; const maxStagger = 400 + 200; for (let i = 0; i < totalCells; i++) { const prev = prevAnglesRef.current[i] || [ANG_REST, ANG_REST]; const target = targetAnglesRef.current[i] || [ANG_REST, ANG_REST]; const col = i % cols; const row = Math.floor(i / cols); const staggerDelay = (col / cols) * 400 + (row / rows) * 200; const localElapsed = Math.max(0, elapsed - staggerDelay); const rawT = Math.min(1, localElapsed / transitionDuration); const t = rawT; currentAnglesRef.current[i] = [ lerpAngle(prev[0], target[0], t), lerpAngle(prev[1], target[1], t), ]; } if (elapsed > transitionDuration + maxStagger) { currentAnglesRef.current = targetAnglesRef.current.map( ([a, b]) => [a, b] ); phaseRef.current = "hold"; animStartRef.current = now; } } else if (phaseRef.current === "hold" && !reduced) { const elapsed = now - animStartRef.current; if (elapsed > holdDuration && words.length > 1) { wordIndexRef.current = (wordIndexRef.current + 1) % words.length; const nextWord = words[wordIndexRef.current]; const { targets, cols: wCols, rows: wRows } = wordToGrid(nextWord); const padded = padToWidth(targets, wCols, wRows, cols); prevAnglesRef.current = currentAnglesRef.current.map(([a, b]) => [a, b]); targetAnglesRef.current = padded; animStartRef.current = now; phaseRef.current = "transition"; } } else if (reduced) { currentAnglesRef.current = targetAnglesRef.current.map( ([a, b]) => [a, b] ); } ctx.clearRect(0, 0, canvasW, canvasH); const step = clampedClockSize + gap; const radius = clampedClockSize / 2; const handLen = radius * 0.82; for (let i = 0; i < cols * rows; i++) { const col = i % cols; const row = Math.floor(i / cols); const cx = col * step + radius; const cy = row * step + radius; const angles = currentAnglesRef.current[i] || [ANG_REST, ANG_REST]; ctx.beginPath(); ctx.arc(cx, cy, radius - 1, 0, Math.PI * 2); ctx.strokeStyle = `rgba(${inkRgb}, 0.06)`; ctx.lineWidth = 0.5; ctx.stroke(); ctx.beginPath(); ctx.arc(cx, cy, 1.5, 0, Math.PI * 2); ctx.fillStyle = `rgba(${inkRgb}, 0.15)`; ctx.fill(); const ha = ((angles[0] - 90) * Math.PI) / 180; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(ha) * handLen, cy + Math.sin(ha) * handLen); ctx.strokeStyle = resolvedHandColor; ctx.lineWidth = 1.8; ctx.lineCap = "round"; ctx.stroke(); const ma = ((angles[1] - 90) * Math.PI) / 180; ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(ma) * handLen, cy + Math.sin(ma) * handLen); ctx.strokeStyle = resolvedHandColor; ctx.lineWidth = 1.8; ctx.lineCap = "round"; ctx.stroke(); } rafId = requestAnimationFrame(draw); } rafId = requestAnimationFrame(draw); return () => { cancelAnimationFrame(rafId); ro.disconnect(); }; }, [canvasW, canvasH, clampedClockSize, gap, resolvedHandColor, inkRgb, transitionDuration, holdDuration, words]); return (
); } function padToWidth( targets: [number, number][], cols: number, rows: number, targetCols: number ): [number, number][] { if (cols >= targetCols) return targets; const padLeft = Math.floor((targetCols - cols) / 2); const out: [number, number][] = Array.from( { length: targetCols * rows }, (): [number, number] => [ANG_REST, ANG_REST] ); for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { out[r * targetCols + padLeft + c] = targets[r * cols + c]; } } return out; } export default KineticWall; ``` ## Demo ```tsx "use client"; import React from "react"; import { KineticWall } from "../mellow/kinetic-wall"; export default function KineticWallDemo() { return (

Clock hands align to form letterforms

); } ``` --- # Combination Text (`combination-text`) > A mechanical combination lock that spins through characters to form words. - **Docs:** https://mellowui.com/components/combination-text - **Registry:** https://mellowui.com/r/combination-text.json - **Categories:** text, animation - **Dependencies:** motion ## Install ```bash npx shadcn@latest add https://mellowui.com/r/combination-text.json ``` ## When to use Add a CombinationText component from the mellow library. It cycles through an array of strings like a mechanical combination lock, spinning individual characters into place. Key props: texts (string[]), interval (ms). ## Props | Prop | Type | Default | Description | |---|---|---|---| | `texts` | `string[]` | — | Array of strings to cycle through. | | `interval` | `number` | `4000` | Time between word changes in milliseconds. | | `className` | `string` | — | Additional CSS classes. | ## Design notes - Client component (`"use client"`) — required for animation and refs. - Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors. - Respects `prefers-reduced-motion: reduce` where applicable. - Self-contained — no shared utilities. Copy-paste, not a package. ## Source ```tsx "use client"; import { useEffect, useRef, useState } from "react"; import { motion, useMotionValue, useTransform, useVelocity, useSpring, useMotionTemplate, useReducedMotion, animate, } from "motion/react"; const ALPHABET = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,!?@#&()-_=+/'\"".split(""); const TUMBLER_REPEATS = 13; const N = ALPHABET.length; const MIN_ROW = 2 * N; const MAX_ROW = (TUMBLER_REPEATS - 2) * N - 1; const SEED_CENTER_BAND = 6; interface CombinationTextProps { texts: string[]; interval?: number; className?: string; } export function CombinationText({ texts, interval = 5000, className = "", }: CombinationTextProps) { const [textIndex, setTextIndex] = useState(0); useEffect(() => { if (texts.length <= 1) return; const timer = setInterval(() => { setTextIndex((prev) => (prev + 1) % texts.length); }, interval); return () => clearInterval(timer); }, [texts, interval]); const maxLength = Math.max(...texts.map((t) => t.length)); const currentText = texts[textIndex].padEnd(maxLength, " "); return (
{currentText.split("").map((char, i) => ( ))}
); } function CombinationSlot({ targetChar, index, total, }: { targetChar: string; index: number; total: number; }) { const reducedMotion = useReducedMotion(); const seededRef = useRef(false); const y = useMotionValue(0); const yEm = useTransform(y, (v) => `${v}em`); const yVelocity = useVelocity(y); const smoothVelocity = useSpring(yVelocity, { damping: 30, stiffness: 600, mass: 0.1, }); const blurEm = useTransform(smoothVelocity, (v) => Math.min(Math.abs(v) * 0.0015, 0.09) ); const blurFilter = useMotionTemplate`blur(${blurEm}em)`; const scaleY = useTransform(smoothVelocity, (v) => Math.max(1 - Math.min(Math.abs(v) * 0.0008, 0.04), 0.96) ); useEffect(() => { const targetIdx = ALPHABET.indexOf(targetChar.toUpperCase()); const finalTargetIdx = targetIdx === -1 ? 0 : targetIdx; if (reducedMotion) { y.jump(-(SEED_CENTER_BAND * N + finalTargetIdx)); return; } if (!seededRef.current) { y.jump(-(SEED_CENTER_BAND * N + 0)); seededRef.current = true; } let row = Math.round(-y.get()); const curMod = ((row % N) + N) % N; y.jump(-row); const tgt = finalTargetIdx; const scrambleDown = Math.random() < 0.5; let dFwd = (tgt - curMod + N) % N; if (dFwd === 0) dFwd = N; let dBack = (curMod - tgt + N) % N; if (dBack === 0) dBack = N; const extraSpins = (1 + Math.floor(Math.random() * 2)) * N; let rowEnd = scrambleDown ? row + dFwd + extraSpins : row - dBack - extraSpins; while (rowEnd < MIN_ROW) rowEnd += N; while (rowEnd > MAX_ROW) rowEnd -= N; const cascadeFactor = total > 1 ? index / (total - 1) : 0; const delay = index * 0.07; const duration = 1.6 + cascadeFactor * 0.7 + Math.random() * 0.18; animate(y, -rowEnd, { type: "spring", bounce: 0.22, duration, delay, }); }, [targetChar, index, total, y, reducedMotion]); const column = Array.from({ length: TUMBLER_REPEATS }).flatMap(() => ALPHABET); return (
{column.map((char, i) => ( {char} ))}
); } export default CombinationText; ``` ## Demo ```tsx "use client"; import { CombinationText } from "@/registry/mellow/combination-text"; export default function CombinationTextDemo() { return (
System Status:
); } ``` --- # Springy Text (`springy-text`) > Words fall into place with individual spring physics on scroll enter, drop away downward on exit. - **Docs:** https://mellowui.com/components/springy-text - **Registry:** https://mellowui.com/r/springy-text.json - **Categories:** text, scroll, animation - **Dependencies:** motion ## Install ```bash npx shadcn@latest add https://mellowui.com/r/springy-text.json ``` ## When to use Add a SpringyText component from the mellow library. Each letter in the text prop springs into place on scroll enter with staggered physics, reacts to cursor approach direction, and exits with directional stagger based on scroll direction. Key props: text, as, staggerDelay, enterFrom, exitTo, once, threshold. ## Props | Prop | Type | Default | Description | |---|---|---|---| | `text` | `string` | — | The text to animate. | | `as` | `"h1" \| "h2" \| "h3" \| "h4" \| "p"` | `"h2"` | Element type used for the wrapper. | | `wordClassName` | `string` | — | Class name applied to each word wrapper. | | `letterClassName` | `string` | — | Class name applied to each letter wrapper. | | `staggerDelay` | `number` | `15` | Stagger delay between letters in milliseconds. | | `enterFrom` | `"top" \| "bottom"` | `"top"` | Direction letters enter from. | | `exitTo` | `"bottom" \| "top"` | `"bottom"` | Direction letters exit to. | | `once` | `boolean` | `false` | If true, entrance animation only runs once. | | `threshold` | `number` | `0.3` | Viewport threshold for triggering in-view animation. | | `className` | `string` | — | Additional CSS classes applied to the wrapper element. | ## Design notes - Client component (`"use client"`) — required for animation and refs. - Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors. - Respects `prefers-reduced-motion: reduce` where applicable. - Self-contained — no shared utilities. Copy-paste, not a package. ## Source ```tsx "use client"; import React, { useRef } from "react"; import { motion, useInView, useReducedMotion } from "motion/react"; export interface SpringyTextProps { text: string; as?: "h1" | "h2" | "h3" | "h4" | "p"; className?: string; wordClassName?: string; letterClassName?: string; staggerDelay?: number; enterFrom?: "top" | "bottom"; exitTo?: "bottom" | "top"; once?: boolean; threshold?: number; } export function SpringyText({ text, as: Tag = "h2", className, wordClassName, letterClassName, staggerDelay = 15, enterFrom = "top", exitTo = "bottom", once = false, threshold = 0.3, }: SpringyTextProps) { const containerRef = useRef(null); const reduced = useReducedMotion(); const inView = useInView(containerRef as React.RefObject, { amount: threshold, once, }); const scrollDir = useRef<"down" | "up">("down"); React.useEffect(() => { let lastScrollY = window.scrollY; let ticking = false; const updateScrollDir = () => { const scrollY = window.scrollY; if (Math.abs(scrollY - lastScrollY) > 2) { scrollDir.current = scrollY > lastScrollY ? "down" : "up"; lastScrollY = scrollY > 0 ? scrollY : 0; } ticking = false; }; const onScroll = () => { if (!ticking) { window.requestAnimationFrame(updateScrollDir); ticking = true; } }; window.addEventListener("scroll", onScroll, { passive: true }); return () => window.removeEventListener("scroll", onScroll); }, []); const words = text.split(" "); let charIndex = 0; const wordsWithChars = words.map((word) => { return word.split("").map((char) => { return { char, index: charIndex++ }; }); }); const totalChars = charIndex; const enterY = enterFrom === "top" ? -80 : 80; const exitY = exitTo === "bottom" ? 80 : -80; return React.createElement( Tag as React.ElementType, { ref: containerRef, className, "aria-label": text, }, wordsWithChars.map((wordChars, wordIdx) => ( {wordIdx < wordsWithChars.length - 1 && " "} )) ); } function WordCluster({ wordChars, wordClassName, letterClassName, reduced, inView, enterY, exitY, scrollDirection, totalChars, staggerDelay, }: { wordChars: Array<{ char: string; index: number }>; wordClassName?: string; letterClassName?: string; reduced: boolean | null; inView: boolean; enterY: number; exitY: number; scrollDirection: "down" | "up"; totalChars: number; staggerDelay: number; }) { const [activeLetterIndex, setActiveLetterIndex] = React.useState(null); const [hoverOffsetPx, setHoverOffsetPx] = React.useState(0); return ( {wordChars.map(({ char, index }, localIndex) => { const stiffness = 200 + (index % 3) * 40; const damping = index % 4 === 0 ? 13 : 10 + (index % 4) * 2; const mass = 0.6 + (index % 5) * 0.08; const isForward = scrollDirection === "down"; const staggerIndex = isForward ? index : totalChars - index - 1; const delay = inView ? (staggerIndex * staggerDelay) / 1000 : (staggerIndex * staggerDelay * 0.5) / 1000; return ( ); })} ); } function Letter({ char, localIndex, activeIndex, hoverOffsetPx, setActiveIndex, setHoverOffsetPx, letterClassName, reduced, inView, enterY, exitY, stiffness, damping, mass, baseDelay, }: { char: string; localIndex: number; activeIndex: number | null; hoverOffsetPx: number; setActiveIndex: React.Dispatch>; setHoverOffsetPx: React.Dispatch>; letterClassName?: string; reduced: boolean | null; inView: boolean; enterY: number; exitY: number; stiffness: number; damping: number; mass: number; baseDelay: number; }) { const [activeDelay, setActiveDelay] = React.useState(baseDelay); React.useEffect(() => { setActiveDelay(baseDelay); }, [baseDelay]); const handlePointerEnter = (e: React.PointerEvent) => { if (reduced || !inView) return; setActiveDelay(0); const rect = e.currentTarget.getBoundingClientRect(); const centerY = rect.top + rect.height / 2; const offset = e.clientY < centerY ? rect.height * 0.28 : -rect.height * 0.28; setActiveIndex(localIndex); setHoverOffsetPx(offset); }; const handlePointerLeave = () => { if (reduced || !inView) return; setActiveDelay(0); setActiveIndex(null); setHoverOffsetPx(0); }; let interactiveOffset = 0; if (activeIndex === localIndex) { interactiveOffset = hoverOffsetPx; } else if (activeIndex !== null && Math.abs(activeIndex - localIndex) === 1) { interactiveOffset = hoverOffsetPx / 2; } else if (activeIndex !== null && Math.abs(activeIndex - localIndex) === 2) { interactiveOffset = hoverOffsetPx / 4; } else if (activeIndex !== null && Math.abs(activeIndex - localIndex) === 3) { interactiveOffset = hoverOffsetPx / 8; } return ( {char} ); } export default SpringyText; ``` ## Demo ```tsx "use client"; import React from "react"; import { SpringyText } from "../mellow/springy-text"; export default function SpringyTextDemo() { return (

Scroll to see enter and exit animations

); } ``` --- # Matrix Hero (`matrix-hero`) > Full-width hero section with cascading matrix rain and centered green-phosphor glow text. - **Docs:** https://mellowui.com/components/matrix-hero - **Registry:** https://mellowui.com/r/matrix-hero.json - **Categories:** display, canvas, animation - **Dependencies:** none ## Install ```bash npx shadcn@latest add https://mellowui.com/r/matrix-hero.json ``` ## When to use Add a MatrixHero component from the mellow library. It renders a full-width hero section with cascading half-width katakana matrix rain as the background and centered green-phosphor glow text on top. Pass text='...' for a single headline, or children for custom content. Key props: text, speed, paused, charset, glowIntensity. ## Props | Prop | Type | Default | Description | |---|---|---|---| | `text` | `string` | `"Welcome to the Matrix, Neo."` | Hero headline displayed centered over the rain. Ignored when children is provided. | | `speed` | `number` | `1` | Rain fall speed multiplier. | | `paused` | `boolean` | `false` | Freeze the rain animation. | | `charset` | `string` | — | Characters used in the rain columns. Defaults to half-width katakana + digits. | | `glowIntensity` | `number` | `1` | Multiplier for the text glow radius. 0 disables glow. | | `className` | `string` | — | Additional CSS classes for the outer wrapper. | | `children` | `React.ReactNode` | — | Custom content rendered centered over the rain, replacing the default text. | ## Design notes - Client component (`"use client"`) — required for animation and refs. - Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors. - Respects `prefers-reduced-motion: reduce` where applicable. - Self-contained — no shared utilities. Copy-paste, not a package. ## Source ```tsx "use client"; import React, { useEffect, useRef, useState } from "react"; const DEFAULT_CHARSET = "ヲァィゥェォャュョッアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン01234567"; const TRAIL = 10; const FONT_SIZE = 14; const CHAR_DELAY = 60; const LINE_PAUSE = 150; const BLINK_MS = 530; function parseLines(text: string | string[]): string[] { if (Array.isArray(text)) return text.filter(Boolean); return text.split("\n").filter(Boolean); } export interface MatrixHeroProps { text?: string | string[]; speed?: number; paused?: boolean; charset?: string; glowIntensity?: number; className?: string; style?: React.CSSProperties; children?: React.ReactNode; } export function MatrixHero({ text = ["Welcome to the Matrix,", "Neo."], speed = 1, paused = false, charset = DEFAULT_CHARSET, glowIntensity = 1, className, style, children, }: MatrixHeroProps) { useEffect(() => { if (document.getElementById("vt323-font")) return; const link = document.createElement("link"); link.id = "vt323-font"; link.rel = "stylesheet"; link.href = "https://fonts.googleapis.com/css2?family=VT323&display=swap"; document.head.appendChild(link); }, []); const canvasRef = useRef(null); const sizeRef = useRef({ w: 0, h: 0 }); const rainRef = useRef<{ head: number; spd: number }[]>([]); const frameRef = useRef(0); const pausedRef = useRef(paused); useEffect(() => { pausedRef.current = paused; }, [paused]); useEffect(() => { const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; if (!canvasRef.current) return; const canvas: HTMLCanvasElement = canvasRef.current; const ctxOrNull = canvas.getContext("2d"); if (!ctxOrNull) return; const ctx: CanvasRenderingContext2D = ctxOrNull; function resize() { const dpr = Math.min(window.devicePixelRatio, 2); const rect = canvas.getBoundingClientRect(); sizeRef.current = { w: rect.width, h: rect.height }; canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; ctx.scale(dpr, dpr); } const ro = new ResizeObserver(resize); ro.observe(canvas); resize(); function draw() { const { w, h } = sizeRef.current; if (w === 0 || h === 0) { frameRef.current = requestAnimationFrame(draw); return; } ctx.font = `${FONT_SIZE}px monospace`; const charW = ctx.measureText("M").width; const charH = FONT_SIZE * 1.2; const cols = Math.floor(w / charW); const rows = Math.floor(h / charH); if (rainRef.current.length !== cols) { rainRef.current = Array.from({ length: cols }, (_, ci) => ({ head: (Math.sin(ci * 7.3) * 0.5 + 0.5) * rows, spd: 0.08 + ((ci * 13) % 17) / 17 * 0.25, })); } if (!reduced && !pausedRef.current) { ctx.fillStyle = "#000000"; ctx.fillRect(0, 0, w, h); ctx.font = `${FONT_SIZE}px monospace`; ctx.textBaseline = "top"; for (let ci = 0; ci < cols; ci++) { const r = rainRef.current[ci]; if (!r) continue; r.head += r.spd * speed; if (r.head - TRAIL > rows) { r.head = -(Math.random() * rows * 0.3); r.spd = 0.08 + Math.random() * 0.25; } } for (let ci = 0; ci < cols; ci++) { const rain = rainRef.current[ci]; if (!rain) continue; for (let ri = 0; ri < rows; ri++) { const dist = rain.head - ri; if (dist < 0 || dist > TRAIL) continue; const t = 1 - dist / TRAIL; const isHead = dist < 1.2; const chIdx = Math.abs(ci * 31 + ri * 17 + Math.floor(rain.head * 2.5)) % charset.length; const ch = charset[chIdx] ?? "0"; ctx.fillStyle = isHead ? `rgba(190,255,190,${(t * 0.85).toFixed(2)})` : `rgba(0,${Math.floor(55 + t * 130)},0,${(t * 0.5).toFixed(2)})`; ctx.fillText(ch, ci * charW, ri * charH); } } } frameRef.current = requestAnimationFrame(draw); } frameRef.current = requestAnimationFrame(draw); return () => { cancelAnimationFrame(frameRef.current); ro.disconnect(); }; }, [speed, charset]); const lines = parseLines(Array.isArray(text) ? text : (text ?? "")); const [displayed, setDisplayed] = useState(() => lines.map(() => "")); const [activeLine, setActiveLine] = useState(0); const [typingDone, setTypingDone] = useState(false); const [cursorOn, setCursorOn] = useState(true); const textKey = typeof text === "string" ? text : JSON.stringify(text); useEffect(() => { const ls = parseLines(Array.isArray(text) ? text : (text ?? "")); setDisplayed(ls.map(() => "")); setActiveLine(0); setTypingDone(false); setCursorOn(true); let lineIdx = 0; let charIdx = 0; let timer: ReturnType; function typeNext() { if (pausedRef.current) { timer = setTimeout(typeNext, 50); return; } const target = ls[lineIdx]; if (!target) { setTypingDone(true); return; } if (charIdx < target.length) { charIdx++; const snap = charIdx; const snap_l = lineIdx; setDisplayed(prev => { const next = [...prev]; next[snap_l] = target.slice(0, snap); return next; }); timer = setTimeout(typeNext, CHAR_DELAY); } else if (lineIdx < ls.length - 1) { lineIdx++; charIdx = 0; setActiveLine(lineIdx); timer = setTimeout(typeNext, LINE_PAUSE); } else { setTypingDone(true); } } timer = setTimeout(typeNext, CHAR_DELAY); return () => clearTimeout(timer); }, [textKey]); useEffect(() => { const id = setInterval(() => setCursorOn(v => !v), BLINK_MS); return () => clearInterval(id); }, []); const g = glowIntensity; const textShadow = g > 0 ? `0 0 ${7 * g}px #00FF41, 0 0 ${16 * g}px #00FF41, 0 0 ${32 * g}px rgba(0,255,65,0.45)` : "none"; return (
{children ?? (
{lines.map((_, i) => { const showCursor = i === activeLine && !typingDone ? true : i === lines.length - 1 && typingDone; return (
{displayed[i] ?? ""} {showCursor && ( )}
); })}
)}
); } export default MatrixHero; ``` ## Demo ```tsx "use client"; import MatrixHero from "@/registry/mellow/matrix-hero"; export default function MatrixHeroDemo() { return (
); } ``` --- # Mechanical Keyboard (`mechanical-keyboard`) > Full Mac keyboard with tactile depth-button keycaps — lifts on hover, snaps on press, plays synthesised mechanical click sounds via WebAudio. - **Docs:** https://mellowui.com/components/mechanical-keyboard - **Registry:** https://mellowui.com/r/mechanical-keyboard.json - **Categories:** interactive, 3d, audio - **Dependencies:** none ## Install ```bash npx shadcn@latest add https://mellowui.com/r/mechanical-keyboard.json ``` ## When to use Add a MechanicalKeyboard component from the mellow library. It renders a full Mac keyboard where every key has a raised 3D face that lifts on hover and snaps down on press, with synthesised mechanical click sounds. Props: enableSound (bool, default true), showPreview (bool — floating keystroke labels above the board), variant ('default'|'accent'), keyDepth (px, default 3), onKeyPress (code: string) => void. Wrap in overflow-x:auto for mobile. ## Props | Prop | Type | Default | Description | |---|---|---|---| | `enableSound` | `boolean` | `true` | Play WebAudio mechanical click sounds on press and release. | | `showPreview` | `boolean` | `false` | Float key-legend ghosts above the board as keys are pressed. | | `variant` | `"default" \| "accent"` | `"default"` | default = themed ink glass. accent = purple keycaps. | | `keyDepth` | `number` | `3` | Height of the raised keycap block in pixels. | | `onKeyPress` | `(code: string) => void` | — | Callback fired on each key press with the KeyboardEvent.code value. | | `className` | `string` | — | Additional CSS classes for the outer wrapper. | ## Design notes - Client component (`"use client"`) — required for animation and refs. - Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors. - Respects `prefers-reduced-motion: reduce` where applicable. - Self-contained — no shared utilities. Copy-paste, not a package. ## Source ```tsx "use client"; import React, { useState, useRef, useEffect, useCallback } from "react"; type KeySoundType = "alpha" | "modifier" | "wide" | "fn"; type SoundPhase = "press" | "release"; type SoundRow = [number, number, number, number, number]; const SOUND_PARAMS: Record = { alpha: [[0.040, 7, 0.72, 2800, 0.56], [0.026, 11, 0.48, 5500, 0.40]], modifier: [[0.035, 8, 0.52, 2000, 0.44], [0.024, 12, 0.36, 4000, 0.30]], wide: [[0.060, 5, 0.85, 1000, 0.65], [0.040, 8, 0.60, 2000, 0.45]], fn: [[0.032, 8, 0.60, 3500, 0.48], [0.022, 12, 0.42, 6000, 0.32]], }; function playKeySound(ctx: AudioContext, phase: SoundPhase, type: KeySoundType) { const [dur, decay, amp, lpHz, gain] = SOUND_PARAMS[type][phase === "press" ? 0 : 1]; const len = Math.floor(ctx.sampleRate * dur); const buf = ctx.createBuffer(1, len, ctx.sampleRate); const data = buf.getChannelData(0); for (let i = 0; i < len; i++) { data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay) * amp; } const src = ctx.createBufferSource(); src.buffer = buf; const lp = ctx.createBiquadFilter(); lp.type = "lowpass"; lp.frequency.value = lpHz; const g = ctx.createGain(); g.gain.value = gain; src.connect(lp); lp.connect(g); g.connect(ctx.destination); src.start(); } interface KeyDef { code: string; label: string; sub?: string; w?: number; sound?: KeySoundType; } const KB_W = 680; const KEY_H = 42; const FN_H = 30; const GAP = 4; const ARROW_H = Math.floor((KEY_H - GAP) / 2); const FN_ROW: KeyDef[] = [ { code: "Escape", label: "esc", w: 1.5, sound: "modifier" }, { code: "F1", label: "F1", sound: "fn" }, { code: "F2", label: "F2", sound: "fn" }, { code: "F3", label: "F3", sound: "fn" }, { code: "F4", label: "F4", sound: "fn" }, { code: "F5", label: "F5", sound: "fn" }, { code: "F6", label: "F6", sound: "fn" }, { code: "F7", label: "F7", sound: "fn" }, { code: "F8", label: "F8", sound: "fn" }, { code: "F9", label: "F9", sound: "fn" }, { code: "F10", label: "F10", sound: "fn" }, { code: "F11", label: "F11", sound: "fn" }, { code: "F12", label: "F12", sound: "fn" }, ]; const MAIN_ROWS: KeyDef[][] = [ [ { code: "Backquote", label: "`", sub: "~" }, { code: "Digit1", label: "1", sub: "!" }, { code: "Digit2", label: "2", sub: "@" }, { code: "Digit3", label: "3", sub: "#" }, { code: "Digit4", label: "4", sub: "$" }, { code: "Digit5", label: "5", sub: "%" }, { code: "Digit6", label: "6", sub: "^" }, { code: "Digit7", label: "7", sub: "&" }, { code: "Digit8", label: "8", sub: "*" }, { code: "Digit9", label: "9", sub: "(" }, { code: "Digit0", label: "0", sub: ")" }, { code: "Minus", label: "-", sub: "_" }, { code: "Equal", label: "=", sub: "+" }, { code: "Backspace", label: "delete", w: 2, sound: "wide" }, ], [ { code: "Tab", label: "tab", w: 1.5, sound: "modifier" }, { code: "KeyQ", label: "Q" }, { code: "KeyW", label: "W" }, { code: "KeyE", label: "E" }, { code: "KeyR", label: "R" }, { code: "KeyT", label: "T" }, { code: "KeyY", label: "Y" }, { code: "KeyU", label: "U" }, { code: "KeyI", label: "I" }, { code: "KeyO", label: "O" }, { code: "KeyP", label: "P" }, { code: "BracketLeft", label: "[", sub: "{" }, { code: "BracketRight", label: "]", sub: "}" }, { code: "Backslash", label: "\\", sub: "|", w: 1.5 }, ], [ { code: "CapsLock", label: "caps", w: 1.75, sound: "modifier" }, { code: "KeyA", label: "A" }, { code: "KeyS", label: "S" }, { code: "KeyD", label: "D" }, { code: "KeyF", label: "F" }, { code: "KeyG", label: "G" }, { code: "KeyH", label: "H" }, { code: "KeyJ", label: "J" }, { code: "KeyK", label: "K" }, { code: "KeyL", label: "L" }, { code: "Semicolon", label: ";", sub: ":" }, { code: "Quote", label: "'", sub: '"' }, { code: "Enter", label: "return", w: 2.25, sound: "wide" }, ], [ { code: "ShiftLeft", label: "shift", w: 2.25, sound: "modifier" }, { code: "KeyZ", label: "Z" }, { code: "KeyX", label: "X" }, { code: "KeyC", label: "C" }, { code: "KeyV", label: "V" }, { code: "KeyB", label: "B" }, { code: "KeyN", label: "N" }, { code: "KeyM", label: "M" }, { code: "Comma", label: ",", sub: "<" }, { code: "Period", label: ".", sub: ">" }, { code: "Slash", label: "/", sub: "?" }, { code: "ShiftRight", label: "shift", w: 2.75, sound: "modifier" }, ], ]; const BOTTOM_ROW: KeyDef[] = [ { code: "Fn", label: "fn", w: 1.25, sound: "modifier" }, { code: "ControlLeft", label: "ctrl", w: 1.25, sound: "modifier" }, { code: "AltLeft", label: "opt", w: 1.25, sound: "modifier" }, { code: "MetaLeft", label: "cmd", w: 1.5, sound: "modifier" }, { code: "Space", label: "", w: 6.25, sound: "wide" }, { code: "MetaRight", label: "cmd", w: 1.5, sound: "modifier" }, { code: "AltRight", label: "opt", w: 1.25, sound: "modifier" }, ]; const ARROW_UP_DEF: KeyDef = { code: "ArrowUp", label: "↑", sound: "modifier" }; const ARROW_ROW_DEFS: KeyDef[] = [ { code: "ArrowLeft", label: "←", sound: "modifier" }, { code: "ArrowDown", label: "↓", sound: "modifier" }, { code: "ArrowRight", label: "→", sound: "modifier" }, ]; const ALL_DEFS: KeyDef[] = [ ...FN_ROW, ...MAIN_ROWS.flat(), ...BOTTOM_ROW, ARROW_UP_DEF, ...ARROW_ROW_DEFS, ]; const ALL_CODES = new Set(ALL_DEFS.map(d => d.code)); interface SingleKeyProps { def: KeyDef; active: boolean; depth: number; isAccent: boolean; height: number; onPress: () => void; onRelease: () => void; } function SingleKey({ def, active, depth, isAccent, height, onPress, onRelease, }: SingleKeyProps) { const [hovered, setHovered] = useState(false); const [mouseDown, setMouseDown] = useState(false); const pressed = active || mouseDown; const isModLike = def.sound === "modifier" || def.sound === "fn"; const hasSub = Boolean(def.sub); const isWide = (def.w ?? 1) > 1.2; const labelSize = isModLike ? "8px" : hasSub ? "10px" : "11px"; return (
setHovered(true)} onMouseLeave={() => { setHovered(false); setMouseDown(false); }} onMouseDown={e => { e.preventDefault(); setMouseDown(true); onPress(); }} onMouseUp={() => { setMouseDown(false); onRelease(); }} > {hasSub && ( {def.sub} )} {def.label}
); } export interface MechanicalKeyboardProps { className?: string; enableSound?: boolean; showPreview?: boolean; variant?: "default" | "accent"; keyDepth?: number; onKeyPress?: (code: string) => void; } const KB_H_BASE = 12 + FN_H + 5 * GAP + 5 * KEY_H + 16; export function MechanicalKeyboard({ className, enableSound = true, showPreview = false, variant = "default", keyDepth = 3, onKeyPress, }: MechanicalKeyboardProps) { const [active, setActive] = useState>(new Set()); const [word, setWord] = useState(""); const hoveredRef = useRef(false); const audioCtxRef = useRef(null); const pendingRef = useRef(new Map()); const containerRef = useRef(null); const outerRef = useRef(null); const bodyRef = useRef(null); const isAccent = variant === "accent"; const [scale, setScale] = useState(1); const [bodyH, setBodyH] = useState(KB_H_BASE); useEffect(() => { const outer = outerRef.current; const body = bodyRef.current; if (!outer || !body) return; const ro = new ResizeObserver(() => { const availW = outer.getBoundingClientRect().width; const bH = body.offsetHeight; if (availW > 0) setScale(Math.min(1, availW / KB_W)); if (bH > 0) setBodyH(bH); }); ro.observe(outer); ro.observe(body); return () => ro.disconnect(); }, []); const getCtx = useCallback((): AudioContext | null => { if (!enableSound) return null; try { const AC = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }) .webkitAudioContext; if (!audioCtxRef.current || audioCtxRef.current.state === "closed") { audioCtxRef.current = new AC(); } return audioCtxRef.current; } catch { return null; } }, [enableSound]); const sound = useCallback( (phase: SoundPhase, type: KeySoundType = "alpha") => { const ctx = getCtx(); if (!ctx) return; const go = () => playKeySound(ctx, phase, type); ctx.state === "suspended" ? ctx.resume().then(go) : go(); }, [getCtx], ); const handlePreviewKey = useCallback( (def: KeyDef, physicalKey?: string) => { if (!showPreview) return; if (def.code === "Space") { setWord(""); return; } if (def.code === "Backspace") { setWord(w => w.slice(0, -1)); return; } if (def.sound === "modifier" || def.sound === "fn") return; if (def.code === "Enter") return; let ch: string; if (physicalKey && physicalKey.length === 1) { ch = physicalKey; } else if (def.label.length === 1) { ch = def.label.toLowerCase(); } else { return; } setWord(w => w + ch); }, [showPreview], ); const pressKey = useCallback( (def: KeyDef, physicalKey?: string) => { sound("press", def.sound ?? "alpha"); pendingRef.current.set(def.code, def.sound ?? "alpha"); handlePreviewKey(def, physicalKey); onKeyPress?.(def.code); }, [sound, handlePreviewKey, onKeyPress], ); const releaseKey = useCallback( (def: KeyDef) => { const type = pendingRef.current.get(def.code); if (type !== undefined) { sound("release", type); pendingRef.current.delete(def.code); } }, [sound], ); useEffect(() => { const down = (e: KeyboardEvent) => { if (!hoveredRef.current || e.repeat || !ALL_CODES.has(e.code)) return; setActive(prev => new Set([...prev, e.code])); const def = ALL_DEFS.find(d => d.code === e.code); if (def) pressKey(def, e.key); }; const up = (e: KeyboardEvent) => { if (!ALL_CODES.has(e.code)) return; setActive(prev => { const next = new Set(prev); next.delete(e.code); return next; }); const def = ALL_DEFS.find(d => d.code === e.code); if (def) releaseKey(def); }; document.addEventListener("keydown", down); document.addEventListener("keyup", up); return () => { document.removeEventListener("keydown", down); document.removeEventListener("keyup", up); }; }, [pressKey, releaseKey]); const rowProps = (def: KeyDef) => ({ def, active: active.has(def.code), depth: keyDepth, isAccent, onPress: () => pressKey(def), onRelease: () => releaseKey(def), }); return (
{ hoveredRef.current = true; }} onMouseLeave={() => { hoveredRef.current = false; }} >
{showPreview && (
{word || " "}
)}
{FN_ROW.map(def => ( ))}
{MAIN_ROWS.map((row, ri) => (
{row.map(def => ( ))}
))}
{BOTTOM_ROW.map(def => ( ))}
{ARROW_ROW_DEFS.map(def => ( ))}
); } export default MechanicalKeyboard; ``` ## Demo ```tsx "use client"; import React, { useState } from "react"; import { MechanicalKeyboard } from "../mellow/mechanical-keyboard"; export default function MechanicalKeyboardDemo() { const [sound, setSound] = useState(true); const [preview, setPreview] = useState(false); const [accent, setAccent] = useState(false); return (
{/* Controls */}
{( [ { label: "Sound", active: sound, toggle: () => setSound(v => !v) }, { label: "Preview", active: preview, toggle: () => setPreview(v => !v) }, { label: "Accent", active: accent, toggle: () => setAccent(v => !v) }, ] as const ).map(({ label, active, toggle }) => ( ))}
{/* Keyboard */}

hover keyboard · then type

); } ```