Studio Pieces & Glyph Artv0.4.0New
Pixel Bloom Select
A select whose list grows out of colored pixels and dissolves back into them.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/pixel-bloom-select.jsonManual
Install the dependencies
npm install clsx lucide-react motion radix-ui tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/color.tsexport type Vec3 = [r: number, g: number, b: number]; /** * Parses #rgb, #rrggbb or #rrggbbaa (alpha is ignored) into 0–255 channels. * Anything else falls back to black, so a typo never crashes a shader. */ export function hexToRgb(hex: string): Vec3 { let value = hex.trim().replace(/^#/, ""); if (value.length === 3 || value.length === 4) value = [...value.slice(0, 3)].map((c) => c + c).join(""); if (!/^[\da-f]{6}([\da-f]{2})?$/i.test(value)) return [0, 0, 0]; const n = Number.parseInt(value.slice(0, 6), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } /** Hex color as a normalized vec3 (each channel 0–1) for shader uniforms. */ export function hexToVec3(hex: string): Vec3 { const [r, g, b] = hexToRgb(hex); return [r / 255, g / 255, b / 255]; } /** * Any CSS color the browser understands (hex, rgb(), oklch(), color-mix() or a variable such as * `var(--primary)`) as 0–255 channels, computed in the context of `element` so theme variables * resolve. Hex takes a fast path; everything else is computed once per call, so cache the result * instead of calling this every frame. Browser only; unreadable values fall back to black. */ export function cssColorToRgb(color: string, element: Element): Vec3 { const value = color.trim(); if (/^#[\da-f]{3,8}$/i.test(value)) return hexToRgb(value); // Children of a canvas still get computed styles, but a regular parent is the safer host. const host = element instanceof HTMLCanvasElement ? (element.parentElement ?? document.body) : element; const probe = document.createElement("span"); probe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;visibility:hidden"; probe.style.color = value; host.appendChild(probe); const computed = getComputedStyle(probe).color; probe.remove(); // A 1×1 canvas turns any computed color space (oklch, color(srgb …)) into sRGB bytes. const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true }); if (!ctx) return [0, 0, 0]; ctx.fillStyle = "#000"; ctx.fillStyle = computed; ctx.fillRect(0, 0, 1, 1); const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data; return [r, g, b]; } /** * Calls `onChange` when the page theme may have changed: a class or data-theme change on <html> or * <body> (shadcn's `.dark`, next-themes, a data-theme switch) or a change of the OS color scheme. * Resolved colors are cached, so re-resolve them here. Browser only; returns the unsubscribe. */ export function observeTheme(onChange: () => void): () => void { const observer = new MutationObserver(onChange); const options = { attributes: true, attributeFilter: ["class", "data-theme"] }; observer.observe(document.documentElement, options); if (document.body) observer.observe(document.body, options); const scheme = window.matchMedia("(prefers-color-scheme: dark)"); scheme.addEventListener("change", onChange); return () => { observer.disconnect(); scheme.removeEventListener("change", onChange); }; } /** `cssColorToRgb` as a normalized vec3 (each channel 0–1) for shader uniforms. */ export function cssColorToVec3(color: string, element: Element): Vec3 { const [r, g, b] = cssColorToRgb(color, element); return [r / 255, g / 255, b / 255]; } const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055); /** 0–255 sRGB to OKLCH: lightness 0–1, chroma, hue in radians. */ export function rgbToOklch([r, g, b]: Vec3): Vec3 { const [lr, lg, lb] = [r, g, b].map((v) => toLinear(v / 255)); const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb); const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb); const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb); const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s; const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s; const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s; return [L, Math.hypot(A, B), Math.atan2(B, A)]; } /** OKLCH back to 0–255 sRGB, clamped into gamut. */ export function oklchToRgb([L, C, h]: Vec3): Vec3 { const A = C * Math.cos(h); const B = C * Math.sin(h); const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3; const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3; const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3; const rgb = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s]; return rgb.map((v) => Math.round(Math.min(1, Math.max(0, toGamma(Math.min(1, Math.max(0, v))))) * 255)) as Vec3; } /** * Mixes two 0–255 colors in OKLCH, taking the short way around the hue circle, so midpoints keep * their chroma instead of turning muddy the way an RGB mix does. */ export function mixOklch(a: Vec3, b: Vec3, t: number): Vec3 { const [L1, C1, h1] = rgbToOklch(a); const [L2, C2, h2] = rgbToOklch(b); // A near-gray end has no meaningful hue: borrow the other end's. const ha = C1 < 0.02 ? h2 : h1; const hb = C2 < 0.02 ? h1 : h2; let dh = hb - ha; if (dh > Math.PI) dh -= Math.PI * 2; if (dh < -Math.PI) dh += Math.PI * 2; return oklchToRgb([L1 + (L2 - L1) * t, C1 + (C2 - C1) * t, ha + dh * t]); }lib/glyph/bayer.ts/** * Ordered (Bayer) dithering on the CPU. Shaders use the GLSL version, `GLSL_BAYER` in * `@/lib/webgl/noise.glsl`, which orders the thresholds the same way. */ export type BayerSize = 2 | 4 | 8; /** Builds an n×n Bayer index matrix (values 0 to n²−1), row by row. */ function bayerIndices(size: number): number[] { if (size === 1) return [0]; const half = size / 2; const inner = bayerIndices(half); const out = new Array<number>(size * size); const offsets = [0, 2, 3, 1]; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { const quadrant = (y >= half ? 2 : 0) + (x >= half ? 1 : 0); out[y * size + x] = 4 * inner[(y % half) * half + (x % half)] + offsets[quadrant]; } } return out; } /** Thresholds in (0, 1), row by row, centered in each step so 0 and 1 are never hit exactly. */ const thresholds = (size: number) => Float32Array.from(bayerIndices(size), (v) => (v + 0.5) / (size * size)); /** 2×2 Bayer thresholds in (0, 1), row by row. */ export const BAYER_2 = thresholds(2); /** 4×4 Bayer thresholds in (0, 1), row by row. */ export const BAYER_4 = thresholds(4); /** 8×8 Bayer thresholds in (0, 1), row by row. */ export const BAYER_8 = thresholds(8); const MATRICES: Record<BayerSize, Float32Array> = { 2: BAYER_2, 4: BAYER_4, 8: BAYER_8 }; /** The Bayer threshold (0–1) for a pixel. Coordinates wrap, and negative ones are fine. */ export function bayerThreshold(x: number, y: number, size: BayerSize = 4): number { const mask = size - 1; return MATRICES[size][((y | 0) & mask) * size + ((x | 0) & mask)]; } /** * Ordered-dithers a value in [0, 1] to one of `levels` steps (0 to levels − 1). Between two * steps, the pixel's Bayer threshold decides whether it rounds up, so flat areas become an * even pattern of the two nearest steps instead of a hard band. */ export function orderedDither(value: number, x: number, y: number, levels: number, size: BayerSize = 4): number { const top = Math.max(1, Math.round(levels) - 1); const scaled = Math.min(1, Math.max(0, value)) * top; const base = Math.floor(scaled); if (base >= top) return top; return scaled - base > bayerThreshold(x, y, size) ? base + 1 : base; }lib/glyph/canvas-loop.ts/** * The render loop every glyph and dither canvas shares: sizes the canvas to its host at a capped * pixel ratio, runs only while the host is on screen, the tab is visible, nothing is paused and * the user hasn't asked for reduced motion, and otherwise keeps one up-to-date still frame. * Framework-free: start it in an effect and dispose it in the cleanup. Browser only. */ export interface CanvasLoopFrame { /** Seconds since the previous frame (capped at 0.05), or 0 for a redraw without advancing. */ dt: number; /** Seconds the loop has been running. */ time: number; /** True under reduced motion: draw a calm, finished-looking frame. */ still: boolean; /** Host size in CSS pixels. */ width: number; height: number; /** Device pixel ratio of the canvas (capped). */ dpr: number; } export interface CanvasLoopOptions { /** Element observed for size and visibility. */ host: HTMLElement; /** Canvas sized to the host times the pixel ratio. */ canvas: HTMLCanvasElement; /** Advance by `frame.dt` and draw. Called every animation frame while running, and with dt 0 after resizes and redraws. */ render: (frame: CanvasLoopFrame) => void; /** Called after the canvas is resized, before it is redrawn. */ resize?: (frame: CanvasLoopFrame) => void; /** Read before every frame; while it returns true the loop stops and the last frame stays. */ isPaused?: () => boolean; /** Highest device pixel ratio used. @default 2 */ maxDpr?: number; /** Margin around the viewport that still counts as on screen. @default "120px" */ rootMargin?: string; } export interface CanvasLoop { /** Re-checks paused, visibility and reduced motion, and starts or stops the loop. Call it when props change. */ wake(): void; /** Draws one frame without advancing (no-op while the loop is running, since the next frame will). */ redraw(): void; /** Stops the loop and removes every observer and listener. */ dispose(): void; } export function runCanvasLoop(options: CanvasLoopOptions): CanvasLoop { const { host, canvas, rootMargin = "120px" } = options; const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); let width = 0; let height = 0; let dpr = 1; let raf = 0; let last = 0; let time = 0; let inView = false; let disposed = false; const frame = (dt: number): CanvasLoopFrame => ({ dt, time, still: reducedMotion.matches, width, height, dpr }); const shouldRun = () => !disposed && inView && !document.hidden && !reducedMotion.matches && !options.isPaused?.(); const tick = (now: number) => { raf = 0; if (!shouldRun()) return; const dt = last ? Math.min(0.05, (now - last) / 1000) : 0; last = now; time += dt; options.render(frame(dt)); raf = requestAnimationFrame(tick); }; const wake = () => { if (disposed) return; if (shouldRun()) { if (!raf) { last = 0; raf = requestAnimationFrame(tick); } return; } if (raf) cancelAnimationFrame(raf); raf = 0; }; const redraw = () => { if (!disposed && !raf && width > 0) options.render(frame(0)); }; const resize = () => { width = Math.max(1, host.clientWidth); height = Math.max(1, host.clientHeight); dpr = Math.min(window.devicePixelRatio || 1, options.maxDpr ?? 2); const w = Math.max(1, Math.round(width * dpr)); const h = Math.max(1, Math.round(height * dpr)); if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; } options.resize?.(frame(0)); // Resizing clears the canvas, so repaint right away even while running. options.render(frame(0)); }; const resizeObserver = new ResizeObserver(resize); const intersection = new IntersectionObserver( ([entry]) => { inView = entry?.isIntersecting ?? true; wake(); }, { rootMargin }, ); const onVisibility = () => wake(); const onMotionChange = () => { wake(); redraw(); }; resize(); resizeObserver.observe(host); intersection.observe(host); document.addEventListener("visibilitychange", onVisibility); reducedMotion.addEventListener("change", onMotionChange); return { wake, redraw, dispose() { disposed = true; if (raf) cancelAnimationFrame(raf); raf = 0; resizeObserver.disconnect(); intersection.disconnect(); document.removeEventListener("visibilitychange", onVisibility); reducedMotion.removeEventListener("change", onMotionChange); }, }; }lib/glyph/palettes.tsimport { hexToRgb, type Vec3 } from "@/lib/color"; /** * Named palettes for glyph and dither pieces. Each is 2–8 hex colors ordered from dark to light, * so a value of 0 maps to the first color and 1 to the last. */ export const GLYPH_PALETTES = { midnight: ["#070b1f", "#141d45", "#24347a", "#3d57b8", "#7896ec", "#dfe7ff"], peach: ["#3b1611", "#83362a", "#cf6448", "#f19774", "#ffc9a9", "#fff3ea"], ember: ["#12070a", "#45100e", "#9a240f", "#e2531a", "#ffa03a", "#ffe28c"], moss: ["#0d1a12", "#1e3a26", "#3a6a3e", "#6c994d", "#b6ca7c", "#f0f3d8"], lilac: ["#1b1430", "#3d2b6b", "#6b54ae", "#a38de0", "#d6caf6", "#f7f3ff"], mono: ["#09090b", "#3f3f46", "#a1a1aa", "#fafafa"], /** Built around the library accent (`--primary`, #2563eb). */ accent: ["#081536", "#11307f", "#2563eb", "#7aa3f8", "#dce8ff"], } as const satisfies Record<string, readonly string[]>; export type GlyphPaletteName = keyof typeof GLYPH_PALETTES; /** A palette preset name, or 2–8 hex colors ordered dark to light. */ export type GlyphPaletteInput = GlyphPaletteName | readonly string[]; /** Display names for the presets, in the order demos list them. */ export const GLYPH_PALETTE_LABELS: Record<GlyphPaletteName, string> = { midnight: "Midnight", peach: "Peach", ember: "Ember", moss: "Moss", lilac: "Lilac", mono: "Mono", accent: "Accent", }; export const MIN_PALETTE_COLORS = 2; export const MAX_PALETTE_COLORS = 8; const HEX = /^#(?:[\da-f]{3}|[\da-f]{6})$/i; export const isGlyphPaletteName = (value: unknown): value is GlyphPaletteName => typeof value === "string" && value in GLYPH_PALETTES; /** * Resolves a preset name or a color list to 2–8 hex colors. Invalid entries are dropped and the * list is cut to 8; fewer than 2 valid colors fall back to `mono`. */ export function resolvePalette(input: GlyphPaletteInput | undefined, fallback: GlyphPaletteName = "mono"): string[] { if (input === undefined) return [...GLYPH_PALETTES[fallback]]; if (typeof input === "string") return [...(isGlyphPaletteName(input) ? GLYPH_PALETTES[input] : GLYPH_PALETTES[fallback])]; const colors = input.map((c) => c.trim()).filter((c) => HEX.test(c)).slice(0, MAX_PALETTE_COLORS); return colors.length >= MIN_PALETTE_COLORS ? colors : [...GLYPH_PALETTES[fallback]]; } /** A palette's colors as 0–255 RGB triples. */ export function paletteRgb(palette: readonly string[]): Vec3[] { return palette.map((c) => hexToRgb(c)); } /** Linear blend of two RGB triples. */ export function mixRgb(a: Vec3, b: Vec3, t: number): Vec3 { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; } /** The color at `t` (0 = first, 1 = last) along a palette, blending between neighbors. */ export function samplePalette(rgb: readonly Vec3[], t: number): Vec3 { if (rgb.length === 0) return [0, 0, 0]; if (rgb.length === 1) return rgb[0]; const x = Math.min(1, Math.max(0, t)) * (rgb.length - 1); const i = Math.min(rgb.length - 2, Math.floor(x)); return mixRgb(rgb[i], rgb[i + 1], x - i); } /** An RGB triple as a CSS color. */ export const rgbCss = ([r, g, b]: Vec3, alpha = 1) => alpha >= 1 ? `rgb(${Math.round(r)} ${Math.round(g)} ${Math.round(b)})` : `rgb(${Math.round(r)} ${Math.round(g)} ${Math.round(b)} / ${alpha})`; /** A CSS gradient through every palette color, for static fallbacks. */ export function paletteGradient(palette: readonly string[], direction = "to top") { return `linear-gradient(${direction}, ${palette.join(", ")})`; }lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Copy the source
components/ui/pixel-bloom-select/pixel-bloom-select.tsx"use client"; import { useEffect, useId, useRef, useState, type ComponentProps, type KeyboardEvent } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { Popover } from "radix-ui"; import { Check, ChevronDown } from "lucide-react"; import { cn } from "@/lib/utils"; import { duration, ease } from "@/lib/motion"; import { bayerThreshold, orderedDither } from "@/lib/glyph/bayer"; import { runCanvasLoop, type CanvasLoop } from "@/lib/glyph/canvas-loop"; import { GLYPH_PALETTES, paletteRgb, resolvePalette } from "@/lib/glyph/palettes"; export interface PixelBloomOption { value: string; label: string; /** 2–8 hex colors, dark to light, that the pixels bloom in. @default a preset by position */ palette?: string[]; } export interface PixelBloomSelectProps extends Omit<ComponentProps<"button">, "value" | "defaultValue" | "onChange"> { /** Label shown in the trigger before the value. @default "Vibe" */ label?: string; /** Options; each can bring its own palette. @default six sample vibes */ options?: PixelBloomOption[]; /** Controlled value. */ value?: string; /** Initial value when uncontrolled. @default the first option */ defaultValue?: string; /** Called with the new value. */ onValueChange?: (value: string) => void; /** Size of one pixel in CSS pixels, 2–10. @default 4 */ pixelSize?: number; /** Form field name; adds a hidden input with the value. */ name?: string; } const SAMPLE: PixelBloomOption[] = [ { value: "verdant-sigil", label: "Verdant Sigil", palette: [...GLYPH_PALETTES.moss] }, { value: "midnight-ledger", label: "Midnight Ledger", palette: [...GLYPH_PALETTES.midnight] }, { value: "peach-static", label: "Peach Static", palette: [...GLYPH_PALETTES.peach] }, { value: "ember-choir", label: "Ember Choir", palette: [...GLYPH_PALETTES.ember] }, { value: "lilac-engine", label: "Lilac Engine", palette: [...GLYPH_PALETTES.lilac] }, { value: "mono-drift", label: "Mono Drift", palette: [...GLYPH_PALETTES.mono] }, ]; const PRESET_ORDER = ["moss", "midnight", "peach", "ember", "lilac", "mono", "accent"] as const; const OPEN_MS = 560; const CLOSE_MS = 460; type Phase = "opening" | "open" | "closing"; const paletteOf = (option: PixelBloomOption | undefined, index: number) => resolvePalette(option?.palette ?? PRESET_ORDER[Math.max(0, index) % PRESET_ORDER.length], "moss"); /** * A select whose list grows out of colored pixels: on open, a field of square pixels in the * selected option's palette resolves top to bottom into the option list as an ordered-dither * threshold sweeps across it; on close the options solidify back into pixels that fall away. * Hovering or arrowing to an option tints the field toward that option's palette. The pixels are a * canvas behind real DOM options, so text stays crisp and the list keeps full listbox semantics. */ export function PixelBloomSelect({ label = "Vibe", options = SAMPLE, value, defaultValue, onValueChange, pixelSize = 4, name, disabled, className, ...props }: PixelBloomSelectProps) { const reduceMotion = useReducedMotion(); const [internal, setInternal] = useState(defaultValue ?? options[0]?.value); const selectedValue = value ?? internal; const selectedIndex = Math.max(0, options.findIndex((o) => o.value === selectedValue)); const selected = options[selectedIndex]; const [open, setOpen] = useState(false); const [closing, setClosing] = useState(false); const [active, setActive] = useState(selectedIndex); const listRef = useRef<HTMLDivElement>(null); // Radix mounts portal content a render after `open` flips, so track the panel element in state. const [panel, setPanel] = useState<HTMLDivElement | null>(null); const canvasRef = useRef<HTMLCanvasElement>(null); const typeahead = useRef({ text: "", timer: 0 }); const anim = useRef({ phase: "opening" as Phase, start: 0, tint: selectedIndex, tintFrom: selectedIndex, tintStart: -1, loop: null as CanvasLoop | null }); const listId = useId(); const labelId = useId(); const cell = Math.min(10, Math.max(2, Math.round(pixelSize))); function startOpen() { setActive(selectedIndex); setOpen(true); setClosing(false); } function startClose() { if (!open) return; setOpen(false); // Keep the panel mounted while its options fall away as pixels. setClosing(true); const a = anim.current; a.phase = "closing"; a.start = -1; a.loop?.wake(); } function choose(index: number) { const option = options[index]; if (!option) return; if (value === undefined) setInternal(option.value); onValueChange?.(option.value); startClose(); } function move(to: number) { if (!options.length) return; const index = (to + options.length) % options.length; setActive(index); document.getElementById(`${listId}-${index}`)?.scrollIntoView({ block: "nearest" }); } function onListKey(event: KeyboardEvent<HTMLElement>) { const keys: Record<string, () => void> = { ArrowDown: () => move(active + 1), ArrowUp: () => move(active - 1), Home: () => move(0), End: () => move(options.length - 1), Enter: () => choose(active), " ": () => choose(active), }; const action = keys[event.key]; if (action) { event.preventDefault(); action(); return; } if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) { window.clearTimeout(typeahead.current.timer); typeahead.current.text += event.key.toLowerCase(); typeahead.current.timer = window.setTimeout(() => { typeahead.current.text = ""; }, 500); const match = options.findIndex((o) => o.label.toLowerCase().startsWith(typeahead.current.text)); if (match !== -1) move(match); } } // The highlighted option tints the field. useEffect(() => { const a = anim.current; if (a.tint === active) return; a.tintFrom = a.tint; a.tint = active; a.tintStart = -1; a.loop?.wake(); }, [active]); // The pixel field lives while the panel is mounted. const mounted = open || closing; useEffect(() => { const canvas = canvasRef.current; const list = listRef.current; const ctx = canvas?.getContext("2d"); if (!mounted || !panel || !canvas || !list || !ctx) return; const a = anim.current; a.phase = "opening"; a.start = -1; a.tint = active; a.tintFrom = active; a.tintStart = -2; let cols = 0; let rows = 0; let dpr = 1; let finished = false; const palettes = options.map((o, i) => paletteRgb(paletteOf(o, i))); const setEdge = (edge: number) => list.style.setProperty("--edge", `${(edge * 100).toFixed(1)}%`); const draw = (time: number, still: boolean) => { ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); if (a.start < 0) a.start = time; if (a.tintStart === -1) a.tintStart = time; const t = time - a.start; const tintMix = a.tintStart < 0 ? 1 : Math.min(1, (time - a.tintStart) / 0.25); const from = palettes[a.tintFrom] ?? palettes[0]; const to = palettes[a.tint] ?? palettes[0]; let p = 1; let q = 0; if (!still && a.phase === "opening") { p = Math.min(1, t / (OPEN_MS / 1000)); if (p >= 1) a.phase = "open"; } if (a.phase === "closing") q = still ? 1 : Math.min(1, t / (CLOSE_MS / 1000)); // DOM options follow the same sweep through a mask. setEdge(a.phase === "closing" ? 1.15 - q * 2.5 : p * 1.3 - 0.15); const h = rows * cell; for (let y = 0; y < rows; y++) { const fy = y / Math.max(1, rows - 1); for (let x = 0; x < cols; x++) { const th = bayerThreshold(x, y, 8); let alpha: number; let dy = 0; if (a.phase === "closing") { // Solidify from the bottom up, then fall away. const solid = Math.min(1, Math.max(0, (q * 2.5 - (1 - fy)) / 0.3)); if (th >= solid) continue; const f = Math.min(1, Math.max(0, (q - 0.25 - th * 0.25 - (1 - fy) * 0.1) / 0.5)); dy = f * f * h * 0.9; alpha = 1 - f; } else { // Unrevealed cells are solid; revealed ones leave a faint, sparse tint behind the text. const reveal = Math.min(1, Math.max(0, (p * 1.3 - fy) / 0.3)); if (th >= reveal) alpha = 1; else if (th < 0.22) alpha = 0.14; else continue; } const useTo = bayerThreshold(y, x, 8) < tintMix; const pal = useTo ? to : from; const v = 0.5 + 0.25 * Math.sin(x * 0.31 + time * 1.4) + 0.25 * Math.cos(y * 0.43 - time * 1.1 + x * 0.07); const [r, g, b] = pal[orderedDither(v, x, y, pal.length, 8)]; ctx.globalAlpha = alpha; ctx.fillStyle = `rgb(${r} ${g} ${b})`; ctx.fillRect(x * cell, y * cell + dy, cell, cell); } } ctx.globalAlpha = 1; // Under reduced motion the panel's fade ends the close instead. if (a.phase === "closing" && q >= 1 && !finished && !still) { finished = true; setClosing(false); } }; const loop = runCanvasLoop({ host: panel, canvas, rootMargin: "0px", resize: (frame) => { dpr = frame.dpr; cols = Math.ceil(frame.width / cell); rows = Math.ceil(frame.height / cell); }, render: (frame) => draw(frame.time, frame.still), }); a.loop = loop; return () => { a.loop = null; loop.dispose(); }; // `active` seeds the tint once per mount (later changes go through the tint effect), and closing // only changes the phase of this same loop. // eslint-disable-next-line react-hooks/exhaustive-deps }, [mounted, panel, cell, options]); return ( <Popover.Root open={mounted} onOpenChange={(next) => { if (next) startOpen(); else startClose(); }} > <Popover.Trigger disabled={disabled} role="combobox" aria-haspopup="listbox" aria-controls={open ? listId : undefined} aria-labelledby={labelId} className={cn( "group inline-flex h-11 max-w-full cursor-pointer items-center gap-3 rounded-full border border-border bg-background py-1 pr-1.5 pl-4 text-left text-sm shadow-xs outline-none", "transition-[border-color,box-shadow] duration-150 hover:bg-surface focus-visible:ring-3 focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-50", className, )} {...props} > <span className="flex min-w-0 items-baseline gap-2" id={labelId}> <span className="shrink-0 text-[12.5px] text-muted-foreground">{label}</span> <span className="relative grid min-w-0 *:[grid-area:1/1]"> <AnimatePresence mode="popLayout" initial={false}> <motion.span key={selected?.value} initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6, filter: "blur(3px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -6, filter: "blur(3px)" }} transition={{ duration: duration.quick, ease: ease.out }} className="truncate font-medium text-foreground" > {selected?.label} </motion.span> </AnimatePresence> </span> </span> <span aria-hidden="true" className="flex h-3 shrink-0 overflow-hidden rounded-sm"> {paletteOf(selected, selectedIndex).map((c, i) => ( <span key={`${c}-${i}`} className="h-full w-1.5" style={{ background: c }} /> ))} </span> <span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-surface-2 text-foreground"> <ChevronDown aria-hidden="true" className="size-4 transition-transform duration-300 group-data-[state=open]:rotate-180 motion-reduce:transition-none" /> </span> </Popover.Trigger> {name ? <input type="hidden" name={name} value={selectedValue ?? ""} /> : null} {mounted ? ( <Popover.Portal forceMount> <Popover.Content forceMount asChild align="start" sideOffset={8} collisionPadding={12} onOpenAutoFocus={(event) => { event.preventDefault(); listRef.current?.focus(); }} onKeyDown={closing ? undefined : onListKey} > <motion.div ref={setPanel} initial={reduceMotion ? { opacity: 0 } : false} animate={{ opacity: reduceMotion && closing ? 0 : 1 }} transition={{ duration: duration.base }} onAnimationComplete={() => { // Reduced motion: the fade out is the whole close. if (reduceMotion && closing) setClosing(false); }} className={cn( "relative z-50 w-[max(var(--radix-popover-trigger-width),16rem)] overflow-hidden rounded-2xl border border-border bg-popover text-popover-foreground shadow-lg outline-none", closing && "pointer-events-none", )} > <canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none absolute inset-0 size-full [image-rendering:pixelated]" /> <div ref={listRef} role="listbox" id={listId} tabIndex={-1} aria-labelledby={labelId} aria-activedescendant={`${listId}-${active}`} className="relative max-h-80 overflow-y-auto p-1.5 outline-none [mask-image:linear-gradient(to_bottom,#000_calc(var(--edge,0%)-12%),transparent_calc(var(--edge,0%)+4%))] motion-reduce:[mask-image:none]" > {options.map((option, i) => { const isSelected = option.value === selectedValue; return ( <div key={option.value} id={`${listId}-${i}`} role="option" aria-selected={isSelected} onPointerMove={() => { if (active !== i) setActive(i); }} onClick={() => choose(i)} className={cn( "flex h-10 cursor-pointer items-center gap-3 rounded-xl px-3 text-[14px] text-foreground transition-colors", i === active ? "bg-background/85 shadow-xs ring-1 ring-border" : "hover:bg-background/60", )} > <span aria-hidden="true" className="flex h-3.5 shrink-0 overflow-hidden rounded-[3px]"> {paletteOf(option, i).map((c, k) => ( <span key={`${c}-${k}`} className="h-full w-1.5" style={{ background: c }} /> ))} </span> <span className="min-w-0 flex-1 truncate font-medium">{option.label}</span> {isSelected ? <Check aria-hidden="true" className="size-4 shrink-0 text-foreground" /> : null} </div> ); })} </div> </motion.div> </Popover.Content> </Popover.Portal> ) : null} </Popover.Root> ); }
Usage
import { PixelBloomSelect } from "@/components/ui/pixel-bloom-select/pixel-bloom-select";
<PixelBloomSelect
label="Vibe"
defaultValue="verdant-sigil"
options={[
{ value: "verdant-sigil", label: "Verdant Sigil", palette: ["#0d1a12", "#3a6a3e", "#b6ca7c", "#f0f3d8"] },
{ value: "midnight-ledger", label: "Midnight Ledger", palette: ["#070b1f", "#24347a", "#7896ec", "#dfe7ff"] },
]}
onValueChange={setVibe}
/>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | "Vibe" | Label shown in the trigger before the value. |
options | PixelBloomOption[] | six sample vibes | Options; each can bring its own palette. |
value | string | – | Controlled value. |
defaultValue | string | the first option | Initial value when uncontrolled. |
onValueChange | (value: string) => void | – | Called with the new value. |
pixelSize | number | 4 | Size of one pixel in CSS pixels, 2–10. |
name | string | – | Form field name; adds a hidden input with the value. |
Also accepts every native <button> attribute, including ref and className.
- Each cell of the pixel field has an 8×8 Bayer threshold. Opening sweeps a reveal edge from top to bottom: cells whose threshold is above the edge stay solid, the rest clear and leave a faint tint behind the text. The DOM list follows the same edge through a CSS mask.
- Closing solidifies the cells from the bottom up, then lets them fall with a little gravity and fade. The panel stays mounted (and inert to the pointer) until they have fallen.
- Colors come from each option's `palette` (2–8 hex colors, dark to light) or a preset by position. The highlighted option's palette dithers in over the current one, so each option previews its colors.
Accessibility
- Select-only combobox pattern: the trigger is a combobox named by the label and current value; the list is a listbox of options with aria-selected and aria-activedescendant.
- Arrow keys, Home and End move the highlight, typing jumps to a matching label, Enter or Space chooses, Escape closes, and focus returns to the trigger.
- Option text is real DOM above the canvas, so it stays crisp and readable by screen readers. Under reduced motion the list simply fades in and out.