shadimage
23rd
uiascii-logo-sveltesource: stale · stale

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-svelte.json"
free

This source snapshot is older than our freshness window and couldn't be re-verified just now — it may be outdated.

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, 9:27:49 AM

registry/ascii-logo/ascii-logo.svelte
<script module lang="ts">
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. One is picked at random per cell. */
  charset?: string
  /**
   * Brightness (0–1) a sample must clear 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 (hex). Default follows theme. */
  color?: string
  /** Stage color (hex). Pass `"transparent"` to skip the fill. */
  backgroundColor?: string
  /** 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. Default `auto` follows shadcn / next-themes
   * (`html.dark` class).
   */
  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
  offsetX: number
  offsetY: number
  scatterX: number
  scatterY: number
  fallSpeed: number
  wait: number
}

export const DEFAULT_CHARSET =
  "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@#$%&*"

const LIGHT = { ink: "#3f3f46", paper: "#fafafa" }
const DARK = { ink: "#a1a1aa", paper: "#09090b" }

const TEXT_FONT =
  '900 1px "Arial Black", Impact, Arial, ui-sans-serif, system-ui, sans-serif'

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
}

function resolveDark(theme: AsciiLogoTheme): boolean {
  if (theme === "dark") return true
  if (theme === "light") return false
  return isDarkTheme()
}

function pickChar(charset: string) {
  const pool = charset.length > 0 ? charset : DEFAULT_CHARSET
  return pool[Math.floor(Math.random() * pool.length)] ?? "#"
}

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
  }
}

/**
 * Interactive ASCII wordmark — glyphs shove away from the cursor, then
 * click-cycle through scatter, gravity drop, and reassemble. Zero deps.
 */
export function createAsciiLogo(
  root: HTMLElement,
  canvas: HTMLCanvasElement,
  initial: AsciiLogoOptions = {}
): AsciiLogoInstance | null {
  let options: Required<
    Pick<
      AsciiLogoOptions,
      | "text"
      | "fit"
      | "cellSize"
      | "cellGap"
      | "charset"
      | "threshold"
      | "hoverRadius"
      | "hoverPush"
      | "hoverEase"
      | "scatterRange"
      | "scatterEase"
      | "gravity"
      | "bounce"
      | "resetEase"
      | "staggerFrames"
      | "interactive"
      | "theme"
    >
  > &
    AsciiLogoOptions = {
    text: "23rd",
    fit: 0.82,
    cellSize: 11,
    cellGap: 2,
    charset: DEFAULT_CHARSET,
    threshold: 0.2,
    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 gridRows = 0
  let cells: AsciiCell[] = []
  let phase: AsciiLogoPhase = "logo"
  let lastKey = ""
  let lastW = -1
  let lastH = -1
  let lastDpr = -1
  let loadId = 0
  let reduce = false
  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 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 }
  }

  const sampleSource = (
    sampler: CanvasRenderingContext2D,
    cols: number,
    rows: number,
    image: HTMLImageElement | null,
    p: typeof options
  ) => {
    sampler.fillStyle = "#000"
    sampler.fillRect(0, 0, cols, rows)

    const cover = Math.min(1, Math.max(0.2, p.fit))
    if (image && image.width > 0 && image.height > 0) {
      const maxW = cols * cover
      const maxH = rows * cover
      const scale = Math.min(maxW / image.width, maxH / image.height)
      const dw = image.width * scale
      const dh = image.height * scale
      sampler.drawImage(image, (cols - dw) / 2, (rows - dh) / 2, dw, dh)
      return
    }

    const word = p.text.trim()
    if (!word) return
    sampler.fillStyle = "#fff"
    sampler.textAlign = "center"
    sampler.textBaseline = "middle"
    let fontSize = rows * 0.52
    sampler.font = TEXT_FONT.replace("1px", `${fontSize}px`)
    const width = sampler.measureText(word).width
    const maxW = cols * cover
    if (width > maxW && width > 0) {
      fontSize *= maxW / width
      sampler.font = TEXT_FONT.replace("1px", `${fontSize}px`)
    }
    sampler.fillText(word, cols / 2, rows / 2 + fontSize * 0.04)
  }

  const buildFromImageData = (
    data: ImageData,
    cols: number,
    rows: number,
    p: typeof options
  ) => {
    const shouldInvert = p.invert ?? Boolean(p.src)
    const lit = new Set<string>()
    const pixels = data.data
    for (let row = 0; row < rows; row++) {
      for (let col = 0; col < cols; col++) {
        const i = (row * cols + col) * 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
        const value = (shouldInvert ? 1 - luma : luma) * a
        if (value < p.threshold) continue
        lit.add(`${col},${row}`)
        if (col + 1 < cols) lit.add(`${col + 1},${row}`)
      }
    }

    const next: AsciiCell[] = []
    for (const key of lit) {
      const [colStr, rowStr] = key.split(",")
      const col = Number(colStr)
      const row = Number(rowStr)
      next.push({
        col,
        row,
        char: pickChar(p.charset),
        offsetX: 0,
        offsetY: 0,
        scatterX: 0,
        scatterY: 0,
        fallSpeed: 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.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 sampler = document.createElement("canvas")
    sampler.width = cols
    sampler.height = rows
    const samplerCtx = sampler.getContext("2d", { willReadFrequently: true })
    if (!samplerCtx) return
    const snapshot = {
      ...options,
      src: image ? options.src : undefined,
    }
    sampleSource(samplerCtx, cols, rows, image, snapshot)
    let data: ImageData | null = null
    try {
      data = samplerCtx.getImageData(0, 0, cols, rows)
    } catch {
      data = null
    }
    if (!data && image) {
      sampleSource(samplerCtx, cols, rows, null, {
        ...snapshot,
        src: undefined,
      })
      try {
        data = samplerCtx.getImageData(0, 0, cols, rows)
      } catch {
        lastKey = ""
        return
      }
    }
    if (!data) {
      lastKey = ""
      return
    }
    cells = buildFromImageData(data, cols, rows, snapshot)
    gridRows = rows
    setPhase("logo")
    cursor.x = -999
    cursor.y = -999
  }

  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
      }
      staggerCells(cells, p.staggerFrames)
      setPhase("scattered")
      return
    }
    if (phase === "scattered") {
      for (const cell of cells) cell.fallSpeed = 0
      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.wait > 0) {
        cell.wait -= frames
        if (phase === "returning") everyoneHome = false
        continue
      }

      if (reduced || !p.interactive) {
        cell.offsetX = 0
        cell.offsetY = 0
        continue
      }

      if (phase === "scattered") {
        easeToward(
          cell,
          cell.scatterX,
          cell.scatterY,
          frameEase(p.scatterEase, frames)
        )
        continue
      }

      if (phase === "fallen") {
        const floor = Math.max(0, gridRows - 1 - cell.row)
        cell.fallSpeed += p.gravity * frames
        cell.offsetY += cell.fallSpeed * 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
        }
        continue
      }

      if (phase === "returning") {
        easeToward(cell, 0, 0, frameEase(p.resetEase, frames))
        if (Math.abs(cell.offsetX) > 0.04 || Math.abs(cell.offsetY) > 0.04) {
          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 = pickChar(p.charset)
        }
      } else {
        easeToward(cell, 0, 0, frameEase(p.hoverEase, frames))
      }
    }

    if (everyoneHome) setPhase("logo")
  }

  const draw = () => {
    const p = options
    const w = root.clientWidth
    const h = root.clientHeight
    const dark = resolveDark(p.theme)
    const ink = p.color ?? (dark ? DARK.ink : LIGHT.ink)
    const paper = p.backgroundColor ?? (dark ? DARK.paper : LIGHT.paper)
    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 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`
    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
      ctx.fillText(cell.char, x, y)
    }
  }

  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()
  }

  void rebuild()
  raf = requestAnimationFrame(tick)

  const ro = new ResizeObserver(() => {
    lastW = -1
    lastH = -1
    lastKey = ""
    void rebuild()
  })
  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)
      }
    },
    destroy() {
      running = false
      loadId += 1
      cancelAnimationFrame(raf)
      ro.disconnect()
      mqReduce.removeEventListener("change", onReduce)
      window.removeEventListener("pointermove", onPointerMove)
      root.removeEventListener("pointerleave", onPointerLeave)
      root.removeEventListener("pointerdown", onPointerDown)
      root.removeEventListener("keydown", onKeyDown)
    },
  }
}

</script>

<script lang="ts">
  import { onMount } from "svelte"

  function cn(...parts: Array<string | false | null | undefined>) {
    return parts.filter(Boolean).join(" ")
  }

  interface Props extends AsciiLogoOptions {
    class?: string
    /** Accessible name. Default is `text` or `"ASCII logo"`. */
    label?: string
  }

  let {
    class: className = "",
    text = "23rd",
    src,
    fit = 0.82,
    cellSize = 11,
    cellGap = 2,
    charset = DEFAULT_CHARSET,
    threshold = 0.2,
    invert,
    color,
    backgroundColor,
    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,
  }: Props = $props()

  let root: HTMLDivElement | undefined = $state()
  let canvas: HTMLCanvasElement | undefined = $state()
  let instance: AsciiLogoInstance | null = null

  onMount(() => {
    if (!root || !canvas) return
    instance = createAsciiLogo(root, canvas, {
      text,
      src,
      fit,
      cellSize,
      cellGap,
      charset,
      threshold,
      invert,
      color,
      backgroundColor,
      hoverRadius,
      hoverPush,
      hoverEase,
      scatterRange,
      scatterEase,
      gravity,
      bounce,
      resetEase,
      staggerFrames,
      interactive,
      theme,
      onPhaseChange,
    })
    return () => {
      instance?.destroy()
      instance = null
    }
  })

  $effect(() => {
    instance?.setOptions({
      text,
      src,
      fit,
      cellSize,
      cellGap,
      charset,
      threshold,
      invert,
      color,
      backgroundColor,
      hoverRadius,
      hoverPush,
      hoverEase,
      scatterRange,
      scatterEase,
      gravity,
      bounce,
      resetEase,
      staggerFrames,
      interactive,
      theme,
      onPhaseChange,
    })
  })

  const aria = $derived(label ?? (src ? "ASCII logo" : clampAsciiLogoText(text)))
</script>

<div
  bind:this={root}
  data-slot="ascii-logo"
  role="img"
  aria-label={aria}
  tabindex={interactive ? 0 : undefined}
  class={cn(
    "relative size-full overflow-hidden",
    interactive &&
      "cursor-pointer focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none",
    className
  )}
>
  <canvas
    bind:this={canvas}
    aria-hidden="true"
    class="absolute inset-0 size-full touch-none"
  ></canvas>
</div>

More from 23rd

Browse all components in this library →