Charts
Sparklines and labelled bar charts drawn with block elements.
Requests / min
Usage
Controls
pnpm dlx shadcn@latest add @asciicn/ascii-chartNo engine needed: this one is plain text and CSS. The @asciicn namespace needs one line in components.json, see Installation.
Copy this into components/ascii/ascii-chart.tsx:
"use client"
import { animate as tween, motion, useInView, useReducedMotion } from "motion/react"
import { useEffect, useRef, useState, type ComponentProps } from "react"
import { cn } from "@/lib/utils"
const VERTICAL = " ▁▂▃▄▅▆▇█"
const HORIZONTAL = " ▏▎▍▌▋▊▉█"
const EASE_OUT = [0.23, 1, 0.32, 1] as const
const range = (data: number[], min?: number, max?: number) => {
const lo = min ?? Math.min(...data)
const hi = max ?? Math.max(...data)
return [lo, hi - lo || 1] as const
}
/** One character per value, eight steps tall. */
export function sparkline(data: number[], min?: number, max?: number) {
const [lo, span] = range(data, min, max)
return data.map((v) => VERTICAL[1 + Math.round(((v - lo) / span) * 7)]).join("")
}
/** Rows of a vertical bar chart, top first, `height` characters tall with eighth-block precision. */
export function columns(data: number[], height: number, max?: number) {
const [, span] = range(data, 0, max)
const levels = data.map((v) => (Math.max(0, v) / span) * height * 8)
return Array.from({ length: height }, (_, row) => {
const base = (height - 1 - row) * 8
return levels.map((l) => VERTICAL[Math.round(Math.min(8, Math.max(0, l - base)))]).join("")
})
}
export type AsciiSparklineProps = Omit<ComponentProps<"span">, "children"> & {
data: number[]
min?: number
max?: number
/** Rise in from the left the first time the sparkline scrolls into view. Off under reduced motion. */
animate?: boolean
label: string
}
export function AsciiSparkline({ data, min, max, animate = true, label, className, ...props }: AsciiSparklineProps) {
const root = useRef<HTMLSpanElement>(null)
const inView = useInView(root, { once: true, amount: 0.5 })
const reduce = useReducedMotion()
const play = animate && !reduce
const step = Math.min(0.04, 0.6 / data.length)
return (
<span ref={root} role="img" aria-label={label} className={cn("font-mono whitespace-pre", className)} {...props}>
{Array.from(sparkline(data, min, max), (glyph, i) => (
<motion.span
// Keyed by glyph too, so a value that changes rises in again.
key={`${i}${glyph}`}
aria-hidden
className="inline-block"
initial={play ? { opacity: 0, y: "40%" } : false}
animate={!play || inView ? { opacity: 1, y: "0%" } : { opacity: 0, y: "40%" }}
transition={{ duration: 0.3, ease: EASE_OUT, delay: i * step }}
>
{glyph}
</motion.span>
))}
</span>
)
}
export type AsciiBarChartProps = Omit<ComponentProps<"div">, "children"> & {
data: { label: string; value: number }[]
/** Bar width in characters for the largest value. */
width?: number
max?: number
format?: (value: number) => string
/** Grow the bars from the left, one row after another, the first time the chart scrolls into view. Off under reduced motion. */
animate?: boolean
}
const STAGGER = 0.08
const GROW = 0.7
/** Horizontal bars with labels, the kind you'd find in a terminal dashboard. */
export function AsciiBarChart({
data,
width = 32,
max,
format = String,
animate = true,
className,
...props
}: AsciiBarChartProps) {
const root = useRef<HTMLDivElement>(null)
const inView = useInView(root, { once: true, amount: 0.4 })
const reduce = useReducedMotion()
const play = animate && !reduce
// Seconds since the bars started growing; each row eases in on its own slot of this clock.
const [time, setTime] = useState(0)
const total = GROW + STAGGER * (data.length - 1)
useEffect(() => {
if (!play || !inView) return
const controls = tween(0, total, { duration: total, ease: "linear", onUpdate: setTime })
return () => controls.stop()
}, [play, inView, total])
const grown = (i: number) => {
if (!play) return 1
const t = Math.min(1, Math.max(0, (time - i * STAGGER) / GROW))
return 1 - (1 - t) ** 3
}
const [, span] = range(
data.map((d) => d.value),
0,
max,
)
const labelWidth = Math.max(...data.map((d) => d.label.length))
return (
<div ref={root} role="table" className={cn("font-mono whitespace-pre tabular-nums", className)} {...props}>
{data.map((d, i) => {
const v = (Math.max(0, d.value) / span) * grown(i) * width
const full = Math.floor(v)
const bar = "█".repeat(full) + (full < width ? HORIZONTAL[Math.round((v - full) * 8)] : "")
return (
<div role="row" key={d.label} className="flex gap-[1ch]">
<span role="rowheader" className="text-muted-foreground">
{d.label.padEnd(labelWidth)}
</span>
<span role="cell" aria-hidden className="inline-block" style={{ width: `${width + 1}ch` }}>
{bar}
</span>
<motion.span
role="cell"
initial={play ? { opacity: 0 } : false}
animate={{ opacity: !play || inView ? 1 : 0 }}
transition={{ duration: 0.3, delay: GROW * 0.5 + i * STAGGER }}
>
{format(d.value)}
</motion.span>
</div>
)
})}
</div>
)
}
import { AsciiBarChart, AsciiSparkline } from "@/components/ascii/ascii-chart"
<AsciiSparkline data={[3, 5, 4, 8, 12]} label="Signups, last 5 days" />
<AsciiBarChart
data={[{ label: "cpu", value: 62 }, { label: "mem", value: 31 }]}
format={(v) => `${v}%`}
/>One character per value means a sparkline fits in a table cell and lines up across rows. Pass the same min and max to compare rows fairly.
| api | 119ms | |
| auth | 80ms | |
| search | 240ms |
| Prop | Type | Default | Description |
|---|---|---|---|
| data | number[] | { label, value }[] | — | Sparkline takes numbers, bar chart takes labelled rows. |
| label | string | — | Sparkline only. Describe the trend for screen readers. |
| min / max | number | data range | Fix the scale. |
| width | number | 32 | Bar chart: characters for the largest bar. |
| format | (v) => string | String | Bar chart value label. |
| animate | boolean | true | Sparklines rise in from the left and bars grow row by row the first time they scroll into view. |
role="img" with your label. Bar charts are a role="table" with row headers, so values are read as data.