Components
A GPU halftone that rebuilds a photo or video out of little marks. The frame is pixelated into cells; each cell's luminance is bucketed into one of four brightness bands; and that band's symbol glyph (tiled once per cell) is stamped, tinted with the band's colour over a white ground — so the image is reassembled as tiny symbols. Runs live on video through a WebGL (three.js) shader. Fully configurable: cell size, source zoom, background colour, four band colours, four band symbols from a built-in glyph set (dot, ring, frame, star, heart, wave…), the three luminance edges between bands, and a paused flag to freeze the frame. Ships a self-contained glyph set and shaders; the only dependency is three.
npx @21st-dev/cli@beta add arlanoska/symbols-effectLoading preview...
"use client"
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"
import { SymbolsEffect, GLYPHS } from "@/components/ui/symbols-effect"
/* Default look — the "Cobalt" preset from the original playground. */
const VIDEO = "https://www.arlan.me/videos-glyph/Confident Life Notes for Busy Days.mp4"
const COLORS = ["#241452", "#6d3bf5", "#a9c2ff", "#ffffff"]
const GLYPH_SET = [4, 2, 1, 0] // frame, ring, dot, empty
const DEFAULT_STOPS = [0, 0.25, 0.5, 0.75, 1]
const GLYPH_OPTIONS = GLYPHS.map((g, i) => ({ id: String(i), label: g.name }))
const SWATCHES = ["#ffffff", "#d8d8d8", "#9b9b9b", "#1b1b1b", "#0b1733", "#e88f00", "#2ee06a", "#3b82f6", "#a855f7", "#f7d8e3"]
const STYLE = `
.sandbox-demo{--text-primary:#1b1b1b;--text-secondary:#6b6b6b;--text-tertiary:#b5b5b5;--text-body:#2f2f2f;--border-ring:rgba(0,0,0,0.1);--border-line:#f0f0f0;--bg-page:#fcfcfc;--bg-hover:#f7f7f7;--bg-surface:#fff;--ease-out:cubic-bezier(.215,.61,.355,1);--ease-expo:cubic-bezier(.16,1,.3,1)}
@keyframes swirl-pop{0%{opacity:0;transform:translateY(-4px) scale(.98)}to{opacity:1;transform:translateY(0) scale(1)}}
.hue-track::before{content:"";position:absolute;inset:0;border-radius:9999px;background:linear-gradient(90deg,red 0%,#ff0 17%,#0f0 33%,#0ff 50%,#00f 67%,#f0f 83%,red 100%)}
.swirl-range::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;background:transparent;border:none;width:16px;height:16px}
.swirl-range::-moz-range-thumb{background:transparent;border:none;width:16px;height:16px}
.swirl-range::-webkit-slider-runnable-track{background:transparent}
.swirl-range::-moz-range-track{background:transparent}
`
/* ------------------------------ Slider ---------------------------------- */
function Slider({ label, value, min, max, step = 0.01, format, onChange }: {
label: string
value: number
min: number
max: number
step?: number
format?: (v: number) => string
onChange: (v: number) => void
}) {
const track = useRef<HTMLDivElement>(null)
const [active, setActive] = useState(false)
const drag = useRef<{ x: number; moved: boolean } | null>(null)
const pct = max === min ? 0 : ((value - min) / (max - min)) * 100
const quant = (v: number) => parseFloat(Math.min(Math.max(Math.round(v / step) * step, min), max).toPrecision(12))
const fromX = (clientX: number) => {
const r = track.current!.getBoundingClientRect()
return quant(min + ((clientX - r.left) / r.width) * (max - min))
}
const release = (e: ReactPointerEvent) => {
if (!drag.current) return
if (!drag.current.moved) onChange(fromX(e.clientX))
drag.current = null
setActive(false)
}
return (
<label className="flex min-w-[9rem] flex-1 flex-col">
<div
ref={track}
role="slider"
tabIndex={0}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-label={label}
onPointerDown={(e) => {
;(e.target as Element).setPointerCapture?.(e.pointerId)
drag.current = { x: e.clientX, moved: false }
setActive(true)
}}
onPointerMove={(e) => {
if (!active || !drag.current) return
if (Math.abs(e.clientX - drag.current.x) > 3) drag.current.moved = true
onChange(fromX(e.clientX))
}}
onPointerUp={release}
onPointerCancel={release}
onKeyDown={(e) => {
if (e.key === "ArrowLeft" || e.key === "ArrowDown") { e.preventDefault(); onChange(quant(value - step)) }
else if (e.key === "ArrowRight" || e.key === "ArrowUp") { e.preventDefault(); onChange(quant(value + step)) }
}}
className="relative flex h-8 w-full cursor-pointer touch-none select-none items-center overflow-hidden rounded-lg border border-[var(--border-line)] bg-[var(--bg-page)] outline-none ring-[var(--border-ring)] focus-visible:ring-1"
>
<span className="pointer-events-none absolute inset-y-0 left-0 bg-[var(--bg-hover)]" style={{ width: `${pct}%` }} />
<span
className={`pointer-events-none absolute top-1/2 h-4 w-[3px] -translate-y-1/2 rounded-full bg-[var(--text-primary)] transition-opacity duration-150 ${active ? "opacity-90" : "opacity-40"}`}
style={{ left: `max(3px, calc(${pct}% - 1.5px))` }}
/>
<span className="pointer-events-none relative z-10 pl-3 text-[12px] text-[var(--text-secondary)]">{label}</span>
<span className="pointer-events-none relative z-10 ml-auto mr-3 text-[12px] tabular-nums text-[var(--text-secondary)]">
{format ? format(value) : value.toFixed(2)}
</span>
</div>
</label>
)
}
/* --------------------------- ColorControl ------------------------------- */
function hexToHsl(hex: string): [number, number, number] {
let t = hex.replace("#", "").trim()
if (t.length === 3) t = t.split("").map((c) => c + c).join("")
const r = parseInt(t.slice(0, 2), 16) / 255
const g = parseInt(t.slice(2, 4), 16) / 255
const b = parseInt(t.slice(4, 6), 16) / 255
const mx = Math.max(r, g, b)
const mn = Math.min(r, g, b)
const l = (mx + mn) / 2
let s = 0
let h = 0
if (mx !== mn) {
const d = mx - mn
s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn)
h = (mx === r ? (g - b) / d + 6 * Number(g < b) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4) * 60
}
return [Math.round(h), Math.round(100 * s), Math.round(100 * l)]
}
function hslToHex(h: number, s: number, l: number) {
s /= 100
l /= 100
const k = (n: number) => (n + h / 30) % 12
const a = s * Math.min(l, 1 - l)
const f = (n: number) =>
Math.round(255 * (l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)))))
.toString(16)
.padStart(2, "0")
return `#${f(0)}${f(8)}${f(4)}`
}
function ColorControl({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
const [open, setOpen] = useState(false)
const root = useRef<HTMLDivElement>(null)
const [h, s, l] = hexToHsl(value)
const [hex, setHex] = useState(value)
useEffect(() => setHex(value), [value])
useEffect(() => {
if (!open) return
const onDown = (e: MouseEvent) => { if (root.current && !root.current.contains(e.target as Node)) setOpen(false) }
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false)
document.addEventListener("mousedown", onDown)
document.addEventListener("keydown", onKey)
return () => {
document.removeEventListener("mousedown", onDown)
document.removeEventListener("keydown", onKey)
}
}, [open])
return (
<div ref={root} className="relative flex w-full min-w-0">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-label={`${label}: ${value}`}
aria-expanded={open}
className={`group flex h-8 w-full min-w-0 items-center gap-1.5 rounded-lg border border-[var(--border-line)] bg-[var(--bg-page)] px-2.5 transition-colors duration-150 ease-[var(--ease-out)] hover:border-[var(--border-ring)] ${label ? "" : "justify-center"}`}
>
{label && <span className="text-[12px] text-[var(--text-tertiary)] truncate">{label}</span>}
<span
aria-hidden="true"
className={`h-4 w-4 shrink-0 rounded-[5px] border border-[var(--border-ring)] transition-transform duration-150 ease-[var(--ease-out)] group-active:scale-[0.96] ${label ? "ml-auto" : ""}`}
style={{ backgroundColor: value }}
/>
</button>
{open && (
<div
className="absolute left-0 top-full z-50 mt-2 flex w-56 flex-col gap-3 rounded-xl border border-[var(--border-ring)] bg-[var(--bg-surface)] p-3 shadow-[0_1px_2px_rgba(17,24,39,0.06),0_8px_24px_rgba(17,24,39,0.08)]"
style={{ animation: "swirl-pop 0.16s var(--ease-expo)" }}
>
<span className="hue-track relative flex h-4 items-center">
<span
className="pointer-events-none absolute h-4 w-4 -translate-x-1/2 rounded-full border-2 border-white shadow-[0_0_0_1px_rgba(0,0,0,0.15)]"
style={{ left: `${(h / 360) * 100}%`, backgroundColor: value }}
/>
<input
type="range"
min={0}
max={360}
step={1}
value={h}
onChange={(e) => onChange(hslToHex(+e.target.value, s || 70, l || 50))}
aria-label={`${label} hue`}
className="swirl-range relative z-10 h-4 w-full cursor-pointer appearance-none bg-transparent"
/>
</span>
<input
type="text"
value={hex}
spellCheck={false}
onChange={(e) => {
setHex(e.target.value)
const t = e.target.value.trim()
if (/^#?[0-9a-fA-F]{6}$/.test(t)) onChange(`#${t.replace(/^#?/, "").toLowerCase()}`)
}}
onBlur={() => setHex(value)}
aria-label={`${label} hex`}
className="w-full rounded-md border border-[var(--border-line)] bg-[var(--bg-page)] px-2 py-1 font-mono text-[12px] lowercase text-[var(--text-body)] outline-none transition-colors duration-150 focus:border-[var(--border-ring)]"
/>
<div className="grid grid-cols-5 gap-1.5">
{SWATCHES.map((c) => (
<button
key={c}
type="button"
onClick={() => onChange(c)}
aria-label={c}
className={`h-6 w-full rounded-md border transition-transform duration-150 ease-[var(--ease-out)] active:scale-[0.96] ${c.toLowerCase() === value.toLowerCase() ? "border-[var(--text-primary)]" : "border-[var(--border-ring)]"}`}
style={{ backgroundColor: c }}
/>
))}
</div>
</div>
)}
</div>
)
}
/* ------------------------------ Select ----------------------------------- */
function Select({ options, activeId, onPick, ariaLabel }: {
options: { id: string; label: string }[]
activeId: string
onPick: (id: string) => void
ariaLabel: string
}) {
const [open, setOpen] = useState(false)
const root = useRef<HTMLDivElement>(null)
const current = options.find((o) => o.id === activeId)
useEffect(() => {
if (!open) return
const onDown = (e: MouseEvent) => { if (root.current && !root.current.contains(e.target as Node)) setOpen(false) }
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false)
document.addEventListener("mousedown", onDown)
document.addEventListener("keydown", onKey)
return () => {
document.removeEventListener("mousedown", onDown)
document.removeEventListener("keydown", onKey)
}
}, [open])
return (
<div ref={root} className="relative flex shrink-0">
<button
type="button"
role="combobox"
aria-expanded={open}
aria-label={ariaLabel}
onClick={() => setOpen((o) => !o)}
className="flex h-8 w-full items-center justify-between gap-1.5 rounded-lg border border-[var(--border-line)] bg-[var(--bg-page)] pl-3 pr-2.5 text-[12px] text-[var(--text-primary)] transition-colors duration-150 ease-[var(--ease-out)] hover:border-[var(--border-ring)]"
>
<span className="whitespace-nowrap">{current?.label ?? "Select"}</span>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
aria-hidden="true"
className={`shrink-0 text-[var(--text-tertiary)] transition-transform duration-150 ease-[var(--ease-out)] ${open ? "rotate-180" : ""}`}
>
<path d="M2 3.5 5 6.5 8 3.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{open && (
<div
role="listbox"
className="absolute right-0 top-full z-50 mt-2 flex max-h-64 w-40 flex-col gap-0.5 overflow-y-auto rounded-xl border border-[var(--border-ring)] bg-[var(--bg-surface)] p-1 shadow-[0_1px_2px_rgba(17,24,39,0.06),0_8px_24px_rgba(17,24,39,0.08)]"
style={{ animation: "swirl-pop 0.16s var(--ease-expo)" }}
>
{options.map((o) => {
const selected = o.id === activeId
return (
<button
key={o.id}
type="button"
role="option"
aria-selected={selected}
onClick={() => { onPick(o.id); setOpen(false) }}
className={`rounded-md px-2.5 py-1.5 text-left text-[12px] transition-colors duration-150 ease-[var(--ease-out)] ${selected ? "bg-[var(--bg-page)] text-[var(--text-primary)]" : "text-[var(--text-secondary)] hover:bg-[var(--bg-page)] hover:text-[var(--text-primary)]"}`}
>
{o.label}
</button>
)
})}
</div>
)}
</div>
)
}
/* ---------------------------- GhostButton -------------------------------- */
function GhostButton({ onClick, children }: { onClick: () => void; children: ReactNode }) {
return (
<button
type="button"
onClick={onClick}
className="inline-flex h-7 shrink-0 items-center self-start rounded-lg border border-[var(--border-line)] bg-[var(--bg-surface)] px-3 text-[12px] font-medium text-[var(--text-secondary)] transition-colors duration-150 ease-[var(--ease-out)] hover:bg-[var(--bg-hover)] hover:border-[var(--border-ring)] hover:text-[var(--text-primary)] active:scale-[0.98]"
>
{children}
</button>
)
}
/* ------------------------------ Playground ------------------------------- */
export default function Default() {
const canvasRef = useRef<HTMLCanvasElement>(null)
const recRef = useRef<MediaRecorder | null>(null)
const [cell, setCell] = useState(8)
const [colors, setColors] = useState(COLORS)
const [stops, setStops] = useState(DEFAULT_STOPS)
const [glyphs, setGlyphs] = useState(GLYPH_SET)
const [zoom, setZoom] = useState(1)
const [bg, setBg] = useState("#ffffff")
const [playing, setPlaying] = useState(true)
const [recording, setRecording] = useState(false)
const download = (blob: Blob, name: string) => {
const a = document.createElement("a")
a.href = URL.createObjectURL(blob)
a.download = name
a.click()
setTimeout(() => URL.revokeObjectURL(a.href), 2000)
}
const savePNG = () => {
canvasRef.current?.toBlob((b) => b && download(b, `sandbox-${Date.now()}.png`), "image/png")
}
const toggleRecord = () => {
if (recRef.current && recRef.current.state === "recording") {
recRef.current.stop()
recRef.current = null
setRecording(false)
return
}
const c = canvasRef.current
if (!c) return
const stream = c.captureStream(30)
const mime = MediaRecorder.isTypeSupported("video/webm;codecs=vp9") ? "video/webm;codecs=vp9" : "video/webm"
const chunks: BlobPart[] = []
const rec = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: 8e6 })
rec.ondataavailable = (e) => e.data.size && chunks.push(e.data)
rec.onstop = () => download(new Blob(chunks, { type: "video/webm" }), `sandbox-${Date.now()}.webm`)
recRef.current = rec
rec.start()
setRecording(true)
}
return (
<>
<style>{STYLE}</style>
<div className="sandbox-demo flex min-h-screen w-full justify-center bg-[#fcfcfc] px-8 py-10 font-sans text-[var(--text-primary)]">
<section className="flex w-full max-w-[656px] min-w-0 flex-col gap-3 self-start">
<div className="flex flex-col">
<div className="relative z-10 flex aspect-video items-center justify-center overflow-hidden rounded-xl border border-[var(--border-line)] bg-white">
<SymbolsEffect
ref={canvasRef}
src={VIDEO}
cell={cell}
zoom={zoom}
bg={bg}
bandColors={colors}
bandStops={stops}
bandGlyphs={glyphs}
paused={!playing}
className="h-full w-full"
/>
{recording && (
<span className="absolute right-3 top-3 flex items-center gap-1.5 rounded-full bg-black/55 px-2 py-1 text-[11px] font-medium text-white">
<span className="h-2 w-2 animate-pulse rounded-full bg-red-500" />
REC
</span>
)}
</div>
<div className="-mt-5 flex flex-col gap-4 rounded-b-xl border border-t-0 border-[var(--border-line)] bg-[var(--bg-surface)] p-4 pt-8">
<Slider label="Cell size" value={cell} min={2} max={40} step={1} format={(v) => `${v}px`} onChange={setCell} />
<Slider label="Zoom" value={zoom} min={0.4} max={2.5} step={0.01} format={(v) => `${v.toFixed(2)}×`} onChange={setZoom} />
<ColorControl label="Background" value={bg} onChange={setBg} />
<div className="flex flex-col gap-2.5">
<span className="text-[12px] text-[var(--text-tertiary)]">Bands (dark → light)</span>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-1.5">
<span className="w-[18px] shrink-0 text-[12px] tabular-nums text-[var(--text-secondary)]">{i + 1}</span>
<span className="w-9 shrink-0">
<ColorControl
label=""
value={colors[i]}
onChange={(v) => setColors((c) => c.map((x, j) => (j === i ? v : x)))}
/>
</span>
<span className="min-w-0 flex-1">
<Select
options={GLYPH_OPTIONS}
activeId={String(glyphs[i])}
onPick={(id) => setGlyphs((g) => g.map((x, j) => (j === i ? +id : x)))}
ariaLabel={`Band ${i + 1} symbol`}
/>
</span>
<span className="w-[52px] shrink-0 text-right text-[11px] tabular-nums text-[var(--text-tertiary)]">
{Math.round(100 * stops[i])}–{Math.round(100 * stops[i + 1])}
</span>
</div>
))}
</div>
<div className="flex flex-col gap-2 border-t border-[var(--border-line)] pt-4">
<span className="text-[12px] text-[var(--text-tertiary)]">Thresholds</span>
{[1, 2, 3].map((i) => (
<Slider
key={i}
label={`Edge ${i}`}
value={stops[i]}
min={0}
max={1}
step={0.01}
format={(v) => `${Math.round(100 * v)}%`}
onChange={(v) => setStops((s) => s.map((x, j) => (j === i ? v : x)))}
/>
))}
</div>
<div className="flex flex-wrap items-center gap-2 border-t border-[var(--border-line)] pt-4">
<GhostButton onClick={() => setPlaying((p) => !p)}>{playing ? "Pause" : "Play"}</GhostButton>
<GhostButton onClick={savePNG}>Save PNG</GhostButton>
<GhostButton onClick={toggleRecord}>{recording ? "Stop" : "Record"}</GhostButton>
</div>
</div>
</div>
</section>
</div>
</>
)
}