Sparkline ASCII

Chart

A production-usable sparkline chart drawn entirely in characters: block-fraction bars, keyboard-navigable column by column.

Install
npx shadcn add https://design.helpmarq.com/r/sparkline-ascii.json
Source
registry/core/sparkline-ascii/component.tsx
"use client";

import { useLayoutEffect, useRef, useState } from "react";

// 8 sub-row block-fraction glyphs, 1/8 full to 8/8 full
const BLOCKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
const ROWS = 8;
const COL_W = 1; // ch, one glyph per column
const COL_GAP = 0.6; // ch, whitespace between columns

export interface RampTraceProps {
  /** the series to plot; needs at least one value */
  data: number[];
  /** accessible name for each column, e.g. "Revenue"; used in per-option labels */
  label?: string;
  /** formats a raw value for the in-grid readout and accessible names */
  valueFormat?: (value: number) => string;
  className?: string;
}

export function RampTrace({
  data,
  label = "value",
  valueFormat = (v) => v.toLocaleString(),
  className = "",
}: RampTraceProps) {
  const series = data.length ? data : [0];
  const min = Math.min(...series);
  const max = Math.max(...series);
  const range = max - min;
  const norm = series.map((v) => (range === 0 ? 0.5 : (v - min) / range));

  const [hoverIdx, setHoverIdx] = useState<number | null>(null);
  const [focusIdx, setFocusIdx] = useState<number | null>(null);
  const activeIdx = hoverIdx ?? focusIdx;
  const colRefs = useRef<(HTMLDivElement | null)[]>([]);
  const containerRef = useRef<HTMLDivElement | null>(null);
  // the readout is centered on the active column's own measured position,
  // not a parallel `ch`-based pitch calculation: the readout row and the
  // bar row can differ in font-size (e.g. text-xs vs the ambient size),
  // which makes `ch` resolve to different px in each row and drift
  // linearly with column index. Measuring the real element sidesteps
  // that entirely.
  const [readoutCenter, setReadoutCenter] = useState<number | null>(null);

  useLayoutEffect(() => {
    if (activeIdx === null) {
      setReadoutCenter(null);
      return;
    }
    const update = () => {
      const col = colRefs.current[activeIdx];
      const container = containerRef.current;
      if (!col || !container) return;
      const colRect = col.getBoundingClientRect();
      const containerRect = container.getBoundingClientRect();
      setReadoutCenter(colRect.left - containerRect.left + colRect.width / 2);
    };
    update();
    window.addEventListener("resize", update);
    return () => window.removeEventListener("resize", update);
  }, [activeIdx]);

  const moveFocus = (i: number) => {
    const clamped = Math.min(series.length - 1, Math.max(0, i));
    colRefs.current[clamped]?.focus();
  };

  const onKeyDown = (e: React.KeyboardEvent) => {
    const current = focusIdx ?? 0;
    switch (e.key) {
      case "ArrowLeft":
        e.preventDefault();
        moveFocus(current - 1);
        break;
      case "ArrowRight":
        e.preventDefault();
        moveFocus(current + 1);
        break;
      case "Home":
        e.preventDefault();
        moveFocus(0);
        break;
      case "End":
        e.preventDefault();
        moveFocus(series.length - 1);
        break;
      default:
        break;
    }
  };

  const glyphFor = (i: number) => {
    const units = norm[i] * ROWS * 8;
    let fullRows = Math.floor(units / 8);
    let remainder = Math.round(units - fullRows * 8);
    if (remainder === 8) {
      fullRows = Math.min(ROWS, fullRows + 1);
      remainder = 0;
    }
    const rows: { glyph: string; kind: "bar" | "empty" }[] = [];
    for (let r = ROWS - 1; r >= 0; r--) {
      if (r < fullRows) {
        rows.push({ glyph: "█", kind: "bar" });
      } else if (r === fullRows && remainder > 0) {
        rows.push({ glyph: BLOCKS[remainder - 1], kind: "bar" });
      } else {
        rows.push({ glyph: " ", kind: "empty" });
      }
    }
    return rows;
  };

  return (
    <div ref={containerRef} className={`font-mono ${className}`}>
      <div className="relative h-[1.1em] text-xs" style={{ lineHeight: "1.1em" }}>
        {activeIdx !== null && readoutCenter !== null ? (
          <span
            aria-hidden
            className="absolute bottom-0 -translate-x-1/2 whitespace-nowrap text-accent"
            style={{ left: `${readoutCenter}px` }}
          >
            {valueFormat(series[activeIdx])}
          </span>
        ) : null}
      </div>
      <div
        role="listbox"
        aria-label={label}
        aria-orientation="horizontal"
        className="flex items-end outline-none"
        style={{ gap: `${COL_GAP}ch` }}
        onKeyDown={onKeyDown}
        onBlur={(e) => {
          if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocusIdx(null);
        }}
      >
        {series.map((v, i) => {
          // inversion follows hover-or-focus, but `aria-selected` and the focus
          // ring follow DOM focus alone: hovering column 3 while column 0 is
          // focused must not move the announced selection off column 0, nor
          // leave the genuinely focused column with no visible indicator.
          const selected = activeIdx === i;
          const focused = focusIdx === i;
          const rows = glyphFor(i);
          return (
            <div
              key={i}
              ref={(el) => {
                colRefs.current[i] = el;
              }}
              role="option"
              aria-selected={focused}
              aria-label={`${label} ${i + 1} of ${series.length}: ${valueFormat(v)}`}
              tabIndex={i === (focusIdx ?? 0) ? 0 : -1}
              onFocus={() => setFocusIdx(i)}
              onPointerEnter={() => setHoverIdx(i)}
              onPointerLeave={() => setHoverIdx(null)}
              onClick={(e) => e.currentTarget.focus()}
              className={`flex cursor-pointer flex-col text-center leading-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent ${
                selected ? "bg-foreground" : ""
              }`}
              style={{ width: `${COL_W}ch` }}
            >
              {rows.map((row, r) => (
                <span
                  key={r}
                  aria-hidden
                  style={{ lineHeight: "1.2em" }}
                  className={
                    // painting bg-foreground on every row, not just the
                    // column div, guarantees a fully solid fill top to
                    // bottom even though the column div and its rows don't
                    // share a background layer once each row is its own
                    // flex-blockified box.
                    selected
                      ? "bg-foreground"
                      : row.kind === "bar"
                        ? "text-foreground"
                        : "text-muted/30"
                  }
                >
                  {selected
                    ? // "█" is a fully-inked glyph: recoloring it to the
                      // background token (as the rest of this knock-out
                      // relies on) paints the *entire* cell in that dark
                      // color, which just blends back into the page and
                      // makes a tall bar's own rows disappear instead of
                      // highlighting them. A blank cell has no ink to fight
                      // the fill, so every row renders as blank and the
                      // row's own solid background does all the work — a
                      // uniform, unmistakable bar of color the full height
                      // of the column, bar rows and empty rows alike. A
                      // literal " " won't do it: a whitespace-only block box
                      // collapses its line box to zero height, which is why
                      // this needs a non-breaking space instead.
                      " "
                    : row.kind === "empty"
                      ? "·"
                      : row.glyph}
                </span>
              ))}
            </div>
          );
        })}
      </div>
    </div>
  );
}
Build spec

A numeric series renders as a monospace character grid: each column stacks the block-fraction glyphs '▁▂▃▄▅▆▇█' bottom-up to the value's normalized height across 8 rows (min/max normalized per-series; a flat series with zero range falls back to a constant mid-height rather than collapsing to empty). A dotted ground grid fills the negative space above each bar so the plot area reads as a full grid rather than bars floating on blank space. An earlier revision also overlaid a box-drawing trend line ('╱╲─') on top of the bars; it was removed because box-drawing characters and block characters have different vertical metrics in monospace fonts (the block glyphs sit flush at the cell's baseline, the box-drawing glyphs sit centered in the em box), so the line could never land flush on the bar tops it was meant to trace — it always read as a disconnected diagonal floating above them, independent of the row math driving it. A dedicated in-grid row above the bars — same monospace flow, positioned in ch units on the same pitch as the columns — prints the active column's formatted value immediately above it; nothing else on the page moves to show it. The chart is a real role=listbox/role=option widget: hovering or focusing a column selects it, arrow keys (plus Home/End) move a roving tabindex between columns and re-focus the corresponding DOM node, and the selected column's entire cell stack (including empty rows) inverts to a solid background-fill block spanning the full column height, matching how a terminal selection highlights a run of cells regardless of what character is under it. `aria-selected` and the focus-visible ring follow DOM focus alone, independent of the hover-driven inversion/readout, so hovering one column never steals the announced selection or the visible focus indicator from another. Column pitch is fixed in ch units (1ch glyph + 0.6ch gap) so both the bars and the readout row share exact character alignment without measuring anything at runtime. Every option carries its own accessible name (series label, index, formatted value); this is a real chart, not a decorative sparkline, so it renders no static fallback under prefers-reduced-motion because it never animates on its own — everything here is user-driven.

Tags
chartasciidatakeyboardsparkline