23rd
uiascii-logosource: ok · refreshed
ASCII Logo
Interactive ASCII wordmark — glyphs shove away from the cursor, then click-cycle through scatter, gravity drop, and reassemble. Zero deps.
npx shadcn add "https://23rd.dev/r/ascii-logo.json"free
Interaction
cursor-follow
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Files1 · source cached 10/8/2026, 6:18:18 AM
registry/ascii-logo/ascii-logo.tsx
"use client"
import { useEffect, useRef } from "react"
import { cn } from "@/lib/utils"
export type AsciiLogoPhase = "logo" | "scattered" | "fallen" | "returning"
export type AsciiLogoTheme = "light" | "dark" | "auto"
/** Wordmark `text` is capped at this many characters. */
export const MAX_TEXT_LETTERS = 5
/** Keep at most `maxLetters` characters; extra input is dropped. */
export function clampAsciiLogoText(
text: string,
maxLetters = MAX_TEXT_LETTERS
): string {
return [...text].slice(0, maxLetters).join("")
}
export type AsciiLogoOptions = {
/**
* Wordmark sampled into the ASCII grid. Ignored when `src` is set.
* Capped at `MAX_TEXT_LETTERS` (5). Default `"23rd"`
*/
text?: string
/** Image URL to sample instead of `text` (any raster or same-origin SVG). */
src?: string
/**
* How much of the stage the source covers (0–1). Default `0.82`
*/
fit?: number
/** Glyph cell size in CSS pixels. Default `11` */
cellSize?: number
/** Gap between cells in CSS pixels. Default `2` */
cellGap?: number
/** Pool of glyphs for cells inside the shape. One is picked per cell. */
charset?: string
/**
* Pool of light glyphs for cells on the edge of the shape, where the
* source only partly covers the cell. Default `EDGE_CHARSET`
*/
edgeCharset?: string
/**
* Coverage (0–1) a cell must reach to become a glyph.
* Default `0.2`
*/
threshold?: number
/**
* Treat dark pixels as solid. Default `true` when `src` is set,
* `false` for text (white ink on a black sampler).
*/
invert?: boolean
/** Glyph color. Default follows theme. */
color?: string
/**
* Stage fill. Default `"transparent"`: the parent paints the background,
* so the logo sits on any surface in both themes.
*/
backgroundColor?: string
/**
* Glyph font family. Default: the root element's computed font, so a
* `font-mono` class on it is enough; falls back to the system mono stack.
*/
font?: string
/** Glyphs fly in from off stage when the logo (re)builds. Default `true` */
assemble?: boolean
/** Cursor repulsion radius in grid cells. Default `7` */
hoverRadius?: number
/** How far glyphs shove away from the cursor. Default `2.6` */
hoverPush?: number
/** Hover ease (0–1). Default `0.18` */
hoverEase?: number
/** Max scatter offset in grid cells. Default `16` */
scatterRange?: number
/** Scatter ease (0–1). Default `0.055` */
scatterEase?: number
/** Fall acceleration in cells / frame @ 60fps. Default `0.14` */
gravity?: number
/** Bounce restitution (0–1). Default `0.28` */
bounce?: number
/** Reassemble ease (0–1). Default `0.08` */
resetEase?: number
/** Max frames a glyph waits before moving. Default `18` */
staggerFrames?: number
/** Pointer hover + click cycle. Default `true` */
interactive?: boolean
/**
* Palette mode. `auto` (the default) reads `html.dark` / `html.light`,
* then `data-theme`, then `prefers-color-scheme`, and keeps up with
* changes.
*/
theme?: AsciiLogoTheme
/** Fires after each phase change (including the auto-return to `logo`). */
onPhaseChange?: (phase: AsciiLogoPhase) => void
}
export type AsciiLogoInstance = {
setOptions: (options: Partial<AsciiLogoOptions>) => void
destroy: () => void
}
type AsciiCell = {
col: number
row: number
char: string
/** How much of the cell the source covers (0–1); drives ink weight. */
weight: number
offsetX: number
offsetY: number
scatterX: number
scatterY: number
fallSpeed: number
/** Row offset where the glyph lands in the `fallen` phase. */
landing: number
/** Radians; glyphs tumble while they fall and settle flat again. */
spin: number
spinSpeed: number
/** Brief brightening after a glyph swap under the cursor (0–1). */
flash: number
wait: number
}
export const DEFAULT_CHARSET =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%&*"
/** Light marks for the rim of the shape. */
export const EDGE_CHARSET = ".:;-=+*~'`,"
/** Stock ink per theme; a set `color` replaces it. */
export const LIGHT_INK = "#2b2c2f"
export const DARK_INK = "#cfcfca"
/** Sampler pixels per cell edge; coverage averages a 4x4 block. */
const SUPERSAMPLE = 4
/** Below this coverage a cell takes a light edge glyph. */
const EDGE_WEIGHT = 0.5
const TEXT_FONT =
'900 1px "Arial Black", Impact, Arial, ui-sans-serif, system-ui, sans-serif'
const MONO_FALLBACK =
'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace'
export function isDarkTheme(): boolean {
if (typeof document === "undefined") return false
const root = document.documentElement
if (root.classList.contains("dark")) return true
if (root.classList.contains("light")) return false
const dataTheme = root.getAttribute("data-theme")
if (dataTheme === "dark") return true
if (dataTheme === "light") return false
return window.matchMedia("(prefers-color-scheme: dark)").matches
}
export function resolveDark(theme: AsciiLogoTheme = "auto"): boolean {
if (theme === "dark") return true
if (theme === "light") return false
return isDarkTheme()
}
/**
* Calls `onChange` whenever the page theme may have changed (the `html`
* class, `data-theme` or `style`, or the system preference). Returns the
* unsubscribe.
*/
export function watchTheme(onChange: () => void): () => void {
if (typeof document === "undefined") return () => {}
const mo = new MutationObserver(onChange)
mo.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "data-theme", "style"],
})
const mq = window.matchMedia("(prefers-color-scheme: dark)")
mq.addEventListener("change", onChange)
return () => {
mo.disconnect()
mq.removeEventListener("change", onChange)
}
}
function pickChar(pool: string, fallback: string) {
const chars = pool.length > 0 ? pool : fallback
return chars[Math.floor(Math.random() * chars.length)] ?? "#"
}
function charFor(weight: number, charset: string, edgeCharset: string) {
if (weight < EDGE_WEIGHT) return pickChar(edgeCharset, EDGE_CHARSET)
return pickChar(charset, DEFAULT_CHARSET)
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.decoding = "async"
if (/^https?:/i.test(src) && !src.startsWith(window.location.origin)) {
img.crossOrigin = "anonymous"
}
img.onload = () => resolve(img)
img.onerror = () => reject(new Error("AsciiLogo: failed to load image"))
img.src = src
})
}
function easeToward(
cell: AsciiCell,
targetX: number,
targetY: number,
ease: number
) {
cell.offsetX += (targetX - cell.offsetX) * ease
cell.offsetY += (targetY - cell.offsetY) * ease
}
function frameEase(ease: number, frames: number) {
const e = Math.min(1, Math.max(0, ease))
if (frames <= 0) return e
return 1 - Math.pow(1 - e, frames)
}
function staggerCells(cells: AsciiCell[], staggerFrames: number) {
const max = Math.max(0, staggerFrames)
for (const cell of cells) {
cell.wait = Math.random() * max
}
}
/** Glyphs start off stage, spread around the frame, and ease home. */
function flingOffStage(cells: AsciiCell[], cols: number, rows: number) {
for (const cell of cells) {
const side = Math.random()
if (side < 0.5) {
// Above or below the frame, anywhere across.
cell.offsetX = (Math.random() * 2 - 1) * cols * 0.6
cell.offsetY =
side < 0.25
? -(cell.row + Math.random() * rows)
: rows + Math.random() * rows * 0.5
} else {
// Left or right of the frame.
cell.offsetX =
side < 0.75
? -(cell.col + Math.random() * cols)
: cols + Math.random() * cols * 0.5
cell.offsetY = (Math.random() * 2 - 1) * rows * 0.6
}
cell.spin = (Math.random() * 2 - 1) * Math.PI
cell.spinSpeed = 0
cell.fallSpeed = 0
}
}
/**
* Interactive ASCII wordmark: glyphs assemble on load, shove away from the
* cursor, then click-cycle through scatter, gravity drop, and reassemble.
* Transparent, so it sits on any surface in light and dark. Zero deps.
*/
export function createAsciiLogo(
root: HTMLElement,
canvas: HTMLCanvasElement,
initial: AsciiLogoOptions = {}
): AsciiLogoInstance | null {
let options: Required<
Pick<
AsciiLogoOptions,
| "text"
| "fit"
| "cellSize"
| "cellGap"
| "charset"
| "edgeCharset"
| "threshold"
| "assemble"
| "hoverRadius"
| "hoverPush"
| "hoverEase"
| "scatterRange"
| "scatterEase"
| "gravity"
| "bounce"
| "resetEase"
| "staggerFrames"
| "interactive"
| "theme"
>
> &
AsciiLogoOptions = {
text: "23rd",
fit: 0.82,
cellSize: 11,
cellGap: 2,
charset: DEFAULT_CHARSET,
edgeCharset: EDGE_CHARSET,
threshold: 0.2,
assemble: true,
hoverRadius: 7,
hoverPush: 2.6,
hoverEase: 0.18,
scatterRange: 16,
scatterEase: 0.055,
gravity: 0.14,
bounce: 0.28,
resetEase: 0.08,
staggerFrames: 18,
interactive: true,
theme: "auto",
...initial,
}
options.text = clampAsciiLogoText(options.text)
const ctx = canvas.getContext("2d")
if (!ctx) return null
let raf = 0
let running = true
let last = performance.now()
let gridCols = 0
let gridRows = 0
let cells: AsciiCell[] = []
let phase: AsciiLogoPhase = "logo"
let lastKey = ""
// The assemble flight plays when the source changes, not on a resize.
let lastSource: string | null = null
let lastW = -1
let lastH = -1
let lastDpr = -1
let loadId = 0
let reduce = false
let dark = resolveDark(options.theme)
let fontFamily = ""
const cursor = { x: -999, y: -999 }
const mqReduce = window.matchMedia("(prefers-reduced-motion: reduce)")
const onReduce = () => {
reduce = mqReduce.matches
}
onReduce()
mqReduce.addEventListener("change", onReduce)
const syncTheme = () => {
dark = resolveDark(options.theme)
}
const unwatchTheme = watchTheme(syncTheme)
const readFont = () => {
if (options.font) {
fontFamily = options.font
return
}
const computed = getComputedStyle(root).fontFamily
fontFamily = computed && computed !== "" ? computed : MONO_FALLBACK
}
const setPhase = (next: AsciiLogoPhase) => {
if (phase === next) return
phase = next
options.onPhaseChange?.(next)
}
const sizeCanvas = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const w = root.clientWidth
const h = root.clientHeight
if (w <= 0 || h <= 0) return { w: 0, h: 0 }
if (w === lastW && h === lastH && dpr === lastDpr) return { w, h }
lastW = w
lastH = h
lastDpr = dpr
canvas.width = Math.max(1, Math.floor(w * dpr))
canvas.height = Math.max(1, Math.floor(h * dpr))
canvas.style.width = `${w}px`
canvas.style.height = `${h}px`
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
return { w, h }
}
/** Paints the source onto a sampler `SUPERSAMPLE` times finer than the grid. */
const sampleSource = (
sampler: CanvasRenderingContext2D,
sw: number,
sh: number,
image: HTMLImageElement | null,
p: typeof options
) => {
sampler.fillStyle = "#000"
sampler.fillRect(0, 0, sw, sh)
const cover = Math.min(1, Math.max(0.2, p.fit))
if (image && image.width > 0 && image.height > 0) {
const maxW = sw * cover
const maxH = sh * cover
const scale = Math.min(maxW / image.width, maxH / image.height)
const dw = image.width * scale
const dh = image.height * scale
sampler.drawImage(image, (sw - dw) / 2, (sh - dh) / 2, dw, dh)
return
}
const word = p.text.trim()
if (!word) return
sampler.fillStyle = "#fff"
sampler.textAlign = "center"
sampler.textBaseline = "middle"
let fontSize = sh * 0.52
sampler.font = TEXT_FONT.replace("1px", `${fontSize}px`)
const width = sampler.measureText(word).width
const maxW = sw * cover
if (width > maxW && width > 0) {
fontSize *= maxW / width
sampler.font = TEXT_FONT.replace("1px", `${fontSize}px`)
}
const x = sw / 2
const y = sh / 2 + fontSize * 0.04
sampler.fillText(word, x, y)
// Fatten the strokes by about a cell so thin counters still read.
sampler.strokeStyle = "#fff"
sampler.lineJoin = "round"
sampler.lineWidth = SUPERSAMPLE * 0.9
sampler.strokeText(word, x, y)
}
/** Averages each cell's block of sampler pixels into a coverage weight. */
const buildFromImageData = (
data: ImageData,
cols: number,
rows: number,
p: typeof options
) => {
const shouldInvert = p.invert ?? Boolean(p.src)
const pixels = data.data
const sw = cols * SUPERSAMPLE
const next: AsciiCell[] = []
const block = SUPERSAMPLE * SUPERSAMPLE
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
let sum = 0
for (let sy = 0; sy < SUPERSAMPLE; sy++) {
const y = row * SUPERSAMPLE + sy
for (let sx = 0; sx < SUPERSAMPLE; sx++) {
const x = col * SUPERSAMPLE + sx
const i = (y * sw + x) * 4
const r = pixels[i] ?? 0
const g = pixels[i + 1] ?? 0
const b = pixels[i + 2] ?? 0
const a = (pixels[i + 3] ?? 0) / 255
const luma = (r * 0.299 + g * 0.587 + b * 0.114) / 255
sum += (shouldInvert ? 1 - luma : luma) * a
}
}
const weight = sum / block
if (weight < p.threshold) continue
next.push({
col,
row,
char: charFor(weight, p.charset, p.edgeCharset),
weight,
offsetX: 0,
offsetY: 0,
scatterX: 0,
scatterY: 0,
fallSpeed: 0,
landing: 0,
spin: 0,
spinSpeed: 0,
flash: 0,
wait: 0,
})
}
}
return next
}
const gridKey = () => {
const p = options
const w = root.clientWidth
const h = root.clientHeight
if (w <= 0 || h <= 0) return ""
const step = Math.max(4, p.cellSize + p.cellGap)
const cols = Math.max(1, Math.floor(w / step))
const rows = Math.max(1, Math.floor(h / step))
return [
p.src ?? "",
p.text,
p.fit,
p.cellSize,
p.cellGap,
p.charset,
p.edgeCharset,
p.threshold,
String(p.invert ?? ""),
cols,
rows,
].join("|")
}
const rebuild = async () => {
const p = options
const { w, h } = sizeCanvas()
if (w <= 0 || h <= 0) return
const step = Math.max(4, p.cellSize + p.cellGap)
const cols = Math.max(1, Math.floor(w / step))
const rows = Math.max(1, Math.floor(h / step))
const key = gridKey()
if (!key || key === lastKey) return
lastKey = key
const id = ++loadId
let image: HTMLImageElement | null = null
if (p.src) {
try {
image = await loadImage(p.src)
} catch {
image = null
}
if (id !== loadId || !running) return
}
const sw = cols * SUPERSAMPLE
const sh = rows * SUPERSAMPLE
const sampler = document.createElement("canvas")
sampler.width = sw
sampler.height = sh
const samplerCtx = sampler.getContext("2d", { willReadFrequently: true })
if (!samplerCtx) return
const snapshot = {
...options,
src: image ? options.src : undefined,
}
sampleSource(samplerCtx, sw, sh, image, snapshot)
let data: ImageData | null = null
try {
data = samplerCtx.getImageData(0, 0, sw, sh)
} catch {
data = null
}
if (!data && image) {
// A tainted cross-origin image blocks reads; fall back to the text.
sampleSource(samplerCtx, sw, sh, null, { ...snapshot, src: undefined })
try {
data = samplerCtx.getImageData(0, 0, sw, sh)
} catch {
lastKey = ""
return
}
}
if (!data) {
lastKey = ""
return
}
cells = buildFromImageData(data, cols, rows, snapshot)
gridCols = cols
gridRows = rows
cursor.x = -999
cursor.y = -999
readFont()
const source = `${snapshot.src ?? ""}|${snapshot.text}`
const fresh = source !== lastSource
lastSource = source
if (fresh && p.assemble && p.interactive && !reduce && cells.length > 0) {
flingOffStage(cells, cols, rows)
staggerCells(cells, p.staggerFrames * 2)
phase = "logo"
setPhase("returning")
} else {
setPhase("logo")
}
}
const cyclePhase = () => {
const p = options
if (!p.interactive || reduce || cells.length === 0) return
if (phase === "logo") {
const range = Math.max(0, p.scatterRange)
for (const cell of cells) {
const floor = Math.max(0, gridRows - 1 - cell.row)
cell.scatterX = (Math.random() * 2 - 1) * range
cell.scatterY = Math.min((Math.random() * 2 - 1) * range, floor * 0.72)
cell.fallSpeed = 0
cell.spinSpeed = (Math.random() * 2 - 1) * 0.05
}
staggerCells(cells, p.staggerFrames)
setPhase("scattered")
return
}
if (phase === "scattered") {
// Glyphs heap up column by column, lowest ones landing first, so the
// drop ends in drifts rather than one flat line.
const pile = new Array<number>(Math.max(1, gridCols)).fill(0)
const order = [...cells].sort(
(a, b) => b.row + b.offsetY - (a.row + a.offsetY)
)
for (const cell of order) {
const col = Math.min(
gridCols - 1,
Math.max(0, Math.round(cell.col + cell.offsetX))
)
const stack = pile[col]!++
cell.landing = Math.max(-cell.row, gridRows - 1 - stack - cell.row)
cell.fallSpeed = 0
cell.spinSpeed = (Math.random() * 2 - 1) * 0.22
}
setPhase("fallen")
return
}
if (phase === "fallen") {
staggerCells(cells, p.staggerFrames)
setPhase("returning")
}
}
const update = (frames: number) => {
const p = options
const reduced = reduce
let everyoneHome = phase === "returning"
for (const cell of cells) {
if (cell.flash > 0) cell.flash = Math.max(0, cell.flash - 0.06 * frames)
if (cell.wait > 0) {
cell.wait -= frames
if (phase === "returning") everyoneHome = false
continue
}
if (reduced || !p.interactive) {
cell.offsetX = 0
cell.offsetY = 0
cell.spin = 0
continue
}
if (phase === "scattered") {
easeToward(
cell,
cell.scatterX,
cell.scatterY,
frameEase(p.scatterEase, frames)
)
cell.spin += cell.spinSpeed * frames
cell.spinSpeed *= Math.pow(0.97, frames)
continue
}
if (phase === "fallen") {
const floor = cell.landing
cell.fallSpeed += p.gravity * frames
cell.offsetY += cell.fallSpeed * frames
cell.spin += cell.spinSpeed * frames
if (cell.offsetY >= floor) {
cell.offsetY = floor
cell.fallSpeed *= -Math.min(0.95, Math.max(0, p.bounce))
if (Math.abs(cell.fallSpeed) < 0.12) cell.fallSpeed = 0
// Each landing bleeds off most of the tumble; glyphs come to rest
// on a flat side rather than balancing on a corner.
cell.spinSpeed *= 0.35
if (cell.fallSpeed === 0) {
cell.spinSpeed = 0
const rest = Math.round(cell.spin / (Math.PI / 2)) * (Math.PI / 2)
cell.spin += (rest - cell.spin) * frameEase(0.3, frames)
}
}
continue
}
if (phase === "returning") {
easeToward(cell, 0, 0, frameEase(p.resetEase, frames))
cell.spin += (0 - cell.spin) * frameEase(p.resetEase, frames)
if (
Math.abs(cell.offsetX) > 0.04 ||
Math.abs(cell.offsetY) > 0.04 ||
Math.abs(cell.spin) > 0.01
) {
everyoneHome = false
}
continue
}
const dx = cell.col - cursor.x
const dy = cell.row - cursor.y
const dist = Math.hypot(dx, dy)
const radius = Math.max(0.01, p.hoverRadius)
if (dist < radius) {
const push = (1 - dist / radius) * p.hoverPush
if (dist < 0.0001) {
easeToward(cell, push, 0, frameEase(p.hoverEase, frames))
} else {
easeToward(
cell,
(dx / dist) * push,
(dy / dist) * push,
frameEase(p.hoverEase, frames)
)
}
if (Math.random() < 0.06 * frames) {
cell.char = charFor(cell.weight, p.charset, p.edgeCharset)
cell.flash = 1
}
} else {
easeToward(cell, 0, 0, frameEase(p.hoverEase, frames))
}
}
if (everyoneHome) {
for (const cell of cells) {
cell.offsetX = 0
cell.offsetY = 0
cell.spin = 0
}
setPhase("logo")
}
}
const draw = () => {
const p = options
const w = root.clientWidth
const h = root.clientHeight
const ink = p.color ?? (dark ? DARK_INK : LIGHT_INK)
const paper = p.backgroundColor ?? "transparent"
const step = Math.max(4, p.cellSize + p.cellGap)
if (paper === "transparent") {
ctx.clearRect(0, 0, w, h)
} else {
ctx.fillStyle = paper
ctx.fillRect(0, 0, w, h)
}
ctx.font = `${Math.max(6, p.cellSize)}px ${fontFamily || MONO_FALLBACK}`
ctx.textAlign = "center"
ctx.textBaseline = "middle"
ctx.fillStyle = ink
for (const cell of cells) {
const x = (cell.col + cell.offsetX) * step + step * 0.5
const y = (cell.row + cell.offsetY) * step + step * 0.5
// Full ink inside the shape, lighter toward its rim, and a brief
// brightening on a swapped glyph.
const base = 0.4 + 0.6 * Math.min(1, cell.weight / 0.85)
ctx.globalAlpha = Math.min(1, base + cell.flash * 0.6)
if (Math.abs(cell.spin) > 0.001) {
ctx.save()
ctx.translate(x, y)
ctx.rotate(cell.spin)
ctx.fillText(cell.char, 0, 0)
ctx.restore()
} else {
ctx.fillText(cell.char, x, y)
}
}
ctx.globalAlpha = 1
}
const tick = (now: number) => {
if (!running) return
const dt = Math.min((now - last) / 1000, 0.05)
last = now
const frames = dt * 60
if (gridKey() !== lastKey) void rebuild()
update(reduce ? 0 : frames)
draw()
raf = requestAnimationFrame(tick)
}
const onPointerMove = (event: PointerEvent) => {
if (!options.interactive) return
const rect = root.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return
const step = Math.max(4, options.cellSize + options.cellGap)
const x = (event.clientX - rect.left) / step
const y = (event.clientY - rect.top) / step
const inside =
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom
if (inside) {
cursor.x = x
cursor.y = y
} else {
cursor.x = -999
cursor.y = -999
}
}
const onPointerLeave = () => {
cursor.x = -999
cursor.y = -999
}
const onPointerDown = (event: PointerEvent) => {
if (event.button !== 0) return
cyclePhase()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
cyclePhase()
}
readFont()
void rebuild()
raf = requestAnimationFrame(tick)
// The observer also fires once right after `observe`; only a real size
// change rebuilds, so it doesn't cut the first assemble flight short.
const ro = new ResizeObserver(() => {
if (root.clientWidth === lastW && root.clientHeight === lastH) return
lastKey = ""
void rebuild()
// Resizing cleared the canvas; draw now, or it shows blank for a frame
// until the next tick.
if (running) draw()
})
ro.observe(root)
window.addEventListener("pointermove", onPointerMove, { passive: true })
root.addEventListener("pointerleave", onPointerLeave, { passive: true })
root.addEventListener("pointerdown", onPointerDown)
root.addEventListener("keydown", onKeyDown)
return {
setOptions(next) {
options = { ...options, ...next }
if (typeof next.text === "string") {
options.text = clampAsciiLogoText(next.text)
}
dark = resolveDark(options.theme)
if ("font" in next) readFont()
},
destroy() {
running = false
loadId += 1
cancelAnimationFrame(raf)
ro.disconnect()
mqReduce.removeEventListener("change", onReduce)
unwatchTheme()
window.removeEventListener("pointermove", onPointerMove)
root.removeEventListener("pointerleave", onPointerLeave)
root.removeEventListener("pointerdown", onPointerDown)
root.removeEventListener("keydown", onKeyDown)
},
}
}
export type AsciiLogoProps = AsciiLogoOptions & {
className?: string
/** Accessible name. Default is `text` or `"ASCII logo"`. */
label?: string
}
/**
* Interactive ASCII wordmark: glyphs assemble on load, shove away from the
* cursor, then click-cycle through scatter, gravity drop, and reassemble.
* Transparent, so it sits on any surface in light and dark. Zero deps.
*/
export function AsciiLogo({
className,
text = "23rd",
src,
fit = 0.82,
cellSize = 11,
cellGap = 2,
charset = DEFAULT_CHARSET,
edgeCharset = EDGE_CHARSET,
threshold = 0.2,
invert,
color,
backgroundColor,
font,
assemble = true,
hoverRadius = 7,
hoverPush = 2.6,
hoverEase = 0.18,
scatterRange = 16,
scatterEase = 0.055,
gravity = 0.14,
bounce = 0.28,
resetEase = 0.08,
staggerFrames = 18,
interactive = true,
theme = "auto",
label,
onPhaseChange,
}: AsciiLogoProps) {
const rootRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const instanceRef = useRef<AsciiLogoInstance | null>(null)
useEffect(() => {
const root = rootRef.current
const canvas = canvasRef.current
if (!root || !canvas) return
instanceRef.current = createAsciiLogo(root, canvas, {
text,
src,
fit,
cellSize,
cellGap,
charset,
edgeCharset,
threshold,
invert,
color,
backgroundColor,
font,
assemble,
hoverRadius,
hoverPush,
hoverEase,
scatterRange,
scatterEase,
gravity,
bounce,
resetEase,
staggerFrames,
interactive,
theme,
onPhaseChange,
})
return () => {
instanceRef.current?.destroy()
instanceRef.current = null
}
// Engine reads live options via setOptions; mount once.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
instanceRef.current?.setOptions({
text,
src,
fit,
cellSize,
cellGap,
charset,
edgeCharset,
threshold,
invert,
color,
backgroundColor,
font,
assemble,
hoverRadius,
hoverPush,
hoverEase,
scatterRange,
scatterEase,
gravity,
bounce,
resetEase,
staggerFrames,
interactive,
theme,
onPhaseChange,
})
}, [
text,
src,
fit,
cellSize,
cellGap,
charset,
edgeCharset,
threshold,
invert,
color,
backgroundColor,
font,
assemble,
hoverRadius,
hoverPush,
hoverEase,
scatterRange,
scatterEase,
gravity,
bounce,
resetEase,
staggerFrames,
interactive,
theme,
onPhaseChange,
])
const aria = label ?? (src ? "ASCII logo" : clampAsciiLogoText(text))
return (
<div
ref={rootRef}
data-slot="ascii-logo"
role="img"
aria-label={aria}
tabIndex={interactive ? 0 : undefined}
className={cn(
"relative size-full overflow-hidden font-mono",
interactive &&
"cursor-pointer focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none",
className
)}
>
<canvas
ref={canvasRef}
aria-hidden
className="absolute inset-0 size-full touch-none"
/>
</div>
)
}