Credits dot numberDesign system
VisualSite

Credits dot number

A dot-grid number that transitions to Doto text and back.

npx context-ds add credits-dot-number
On the site

Usage

Use it for

The free credits figure in the closing call to action (value="1,000") and step numbers (value="1" morphTo="PLAN" sideColumns={0}). It is decorative: keep the figure in text beside it.

Avoid

  • A dot number as the only copy of a figure
  • Long numbers
  • Digits it does not draw (it draws 0 to 3 and the comma)

Example

examples/credits-dot-number.tsx
import { CreditsDotNumber } from "@/components/visuals/credits-dot-number";export function Site() {  return (    <div className="mx-auto flex w-full max-w-md flex-col items-center gap-4 rounded-card bg-surface p-8 text-center shadow-[0_0_0_1px_var(--ds-color-border-hairline),0_0_0_var(--ds-space-2)_var(--ds-color-ring-tint)]">      <CreditsDotNumber value="1,000" className="w-full max-w-80" />      <p className="text-body text-fg-muted">1,000 free credits every month. No card needed.</p>    </div>  );}

Source

components/visuals/credits-dot-number.tsx
"use client";import * as React from "react";import { cn } from "@/lib/utils";// Strokes two dots thick, so the number reads as a heading rather than as more of the ring pattern.const GLYPHS: Record<string, string[]> = {  "1": [".##.", "###.", ".##.", ".##.", ".##.", ".##.", "####"],  "0": [".####.", "##..##", "##..##", "##..##", "##..##", "##..##", ".####."],  "2": [".####.", "##..##", "....##", "...##.", "..##..", ".##...", "######"],  "3": ["######", "...##.", "..##..", "...##.", "....##", "##..##", ".####."],  ",": ["..", "..", "..", "..", "##", "##", "#."],};// The same characters as Doto draws them, read from the font. Its last row hangs below the baseline. Only these// characters can be shown: the digits 0 to 3, the comma, and the letters listed.const DOTO_GLYPHS: Record<string, string[]> = {  "1": ["..#..", ".##..", "#.#..", "..#..", "..#..", "..#..", "#####", "....."],  "0": ["..#..", ".#.#.", "#...#", "#...#", "#...#", ".#.#.", "..#..", "....."],  "2": [".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####", "....."],  "3": ["#####", "...#.", "..#..", "...#.", "....#", "#...#", ".###.", "....."],  A: [".###.", "#...#", "#...#", "#####", "#...#", "#...#", "#...#", "....."],  D: ["####.", "#...#", "#...#", "#...#", "#...#", "#...#", "####.", "....."],  I: ["#####", "..#..", "..#..", "..#..", "..#..", "..#..", "#####", "....."],  K: ["#...#", "#..#.", "#.#..", "##...", "#.#..", "#..#.", "#...#", "....."],  L: ["#....", "#....", "#....", "#....", "#....", "#....", "#####", "....."],  P: ["####.", "#...#", "#...#", "####.", "#....", "#....", "#....", "....."],  R: ["####.", "#...#", "#...#", "####.", "#.#..", "#..#.", "#...#", "....."],  S: [".####", "#....", "#....", ".###.", "....#", "....#", "####.", "....."],  U: ["#...#", "#...#", "#...#", "#...#", "#...#", "#...#", ".###.", "....."],  ",": [".....", ".....", ".....", ".....", ".....", "..##.", "..#..", ".#..."],};const DOTO_ADVANCE = 6;const DOTO_SIDE_BEARING = 1;const GLYPH_ROWS = 7;const GLYPH_GAP = 1;const SIDE_COLUMNS = 7;const PITCH = 7;const DOT_RADIUS = 2.5;// Doto's bold dots are squares four fifths of its pitch.const DOTO_DOT_SIDE = PITCH * 0.8;const RING_STROKE = 0.8;const RING_OPACITY = 0.4;const FADED_RING_OPACITY = 0.08;const HOLD_MS = 2200;const MORPH_MS = 1300;const CYCLE_MS = (HOLD_MS + MORPH_MS) * 2;// How much of the morph the left-to-right sweep takes up; the rest is each dot's own travel.const SWEEP = 0.45;const RING_HASTE = 1.8;// A background tab hands back one huge frame gap, which would skip the morph.const LONGEST_FRAME_MS = 50;interface Point {  x: number;  y: number;}interface MorphingDot {  pattern: Point;  doto: Point;}interface Ring extends Point {  opacity: number;}const clamp01 = (value: number) => Math.min(1, Math.max(0, value));const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);const mix = (from: number, to: number, share: number) => from + (to - from) * share;const center = (index: number) => (index + 0.5) * PITCH;function glyphDots(rows: string[], left: number): Point[] {  return rows.flatMap((line, row) => [...line].flatMap((mark, column) => (mark === "#" ? [{ x: center(left + column), y: center(row) }] : [])));}// Dots are matched by where they sit within their own glyph, so a glyph reshapes on its way across instead of collapsing// onto its nearest edge.function placeWithinGlyph(dots: Point[]) {  const xs = dots.map((dot) => dot.x);  const ys = dots.map((dot) => dot.y);  const left = Math.min(...xs);  const top = Math.min(...ys);  const width = Math.max(...xs) - left || 1;  const height = Math.max(...ys) - top || 1;  return dots.map((dot) => ({ x: (dot.x - left) / width, y: (dot.y - top) / height }));}function nearest(to: Point, candidates: number[], places: Point[]) {  return candidates.reduce((best, candidate) =>    Math.hypot(places[candidate].x - to.x, places[candidate].y - to.y) < Math.hypot(places[best].x - to.x, places[best].y - to.y) ? candidate : best,  );}function pairGlyphDots(pattern: Point[], doto: Point[]): MorphingDot[] {  const patternPlaces = placeWithinGlyph(pattern);  const dotoPlaces = placeWithinGlyph(doto);  const everyPatternDot = pattern.map((_, index) => index);  const unclaimed = new Set(everyPatternDot);  const pairs = doto.map((target, index) => {    const source = nearest(dotoPlaces[index], unclaimed.size ? [...unclaimed] : everyPatternDot, patternPlaces);    unclaimed.delete(source);    return { pattern: pattern[source], doto: target };  });  const everyDotoDot = doto.map((_, index) => index);  for (const source of unclaimed) pairs.push({ pattern: pattern[source], doto: doto[nearest(patternPlaces[source], everyDotoDot, dotoPlaces)] });  return pairs;}function numberGeometry(text: string, dotoText: string, sideColumns: number) {  // Characters the grids cannot draw are skipped rather than breaking the picture.  const characters = [...text].filter((character) => character in GLYPHS);  const dotoCharacters = [...dotoText.toUpperCase()].filter((character) => character in DOTO_GLYPHS);  const patternColumns = characters.reduce((total, character) => total + GLYPHS[character][0].length + GLYPH_GAP, -GLYPH_GAP);  const dotoColumns = dotoCharacters.length * DOTO_ADVANCE - DOTO_SIDE_BEARING;  const numberColumns = Math.max(patternColumns, dotoColumns);  const columns = numberColumns + sideColumns * 2;  const patternLeft = sideColumns + Math.floor((numberColumns - patternColumns) / 2);  const dotoLeft = sideColumns + (numberColumns - dotoColumns) / 2;  let left = patternLeft;  const patternGlyphs = characters.map((character) => {    const glyph = glyphDots(GLYPHS[character], left);    left += GLYPHS[character][0].length + GLYPH_GAP;    return glyph;  });  const dotoGlyphs = dotoCharacters.map((character, index) => glyphDots(DOTO_GLYPHS[character], dotoLeft + index * DOTO_ADVANCE));  // Glyph by glyph when the number turns into itself; a number turning into a word of another length is matched whole.  const dots: MorphingDot[] =    characters.length === dotoCharacters.length      ? patternGlyphs.flatMap((glyph, index) => pairGlyphDots(glyph, dotoGlyphs[index]))      : pairGlyphDots(patternGlyphs.flat(), dotoGlyphs.flat());  const lit = new Set(dots.map(({ pattern }) => `${pattern.x}:${pattern.y}`));  const ringOpacity = (column: number) => {    const reach = column < sideColumns ? sideColumns - column : column - (sideColumns + numberColumns - 1);    return reach <= 0 ? RING_OPACITY : RING_OPACITY - ((RING_OPACITY - FADED_RING_OPACITY) * reach) / sideColumns;  };  const rings: Ring[] = Array.from({ length: columns * GLYPH_ROWS }, (_, index) => ({ column: index % columns, row: Math.floor(index / columns) }))    .map(({ column, row }) => ({ x: center(column), y: center(row), opacity: ringOpacity(column) }))    .filter((ring) => !lit.has(`${ring.x}:${ring.y}`));  return { width: columns * PITCH, height: GLYPH_ROWS * PITCH, dots, rings };}function morphAt(elapsed: number) {  const time = elapsed % CYCLE_MS;  if (time < HOLD_MS) return 0;  if (time < HOLD_MS + MORPH_MS) return (time - HOLD_MS) / MORPH_MS;  if (time < HOLD_MS * 2 + MORPH_MS) return 1;  return 1 - (time - HOLD_MS * 2 - MORPH_MS) / MORPH_MS;}/** * A number drawn in the brand dot grid: solid dots for the digits on a field of rings, with the rings carrying on * either side and fading out toward the edges. While on screen it keeps morphing into the same number set in Doto, * or into `morphTo`, and back: the rings drain away and the dots gather into Doto's square ones. */export function CreditsDotNumber({  value,  morphTo = value,  sideColumns = SIDE_COLUMNS,  className,}: {  /** The number in the pattern face: digits 0 to 3 and commas. */  value: string;  /** What the number turns into in Doto; by default the number itself. */  morphTo?: string;  /** Columns of fading rings either side of the number. */  sideColumns?: number;  className?: string;}) {  const { width, height, dots, rings } = React.useMemo(() => numberGeometry(value, morphTo, sideColumns), [value, morphTo, sideColumns]);  const svgRef = React.useRef<SVGSVGElement>(null);  const ringsRef = React.useRef<SVGGElement>(null);  const dotsRef = React.useRef<SVGGElement>(null);  React.useEffect(() => {    const svg = svgRef.current;    if (!svg || !ringsRef.current || !dotsRef.current) return;    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;    const ringElements = [...ringsRef.current.children];    const dotElements = [...dotsRef.current.children];    const progressAt = (morph: number, x: number) => clamp01((morph - (x / width) * SWEEP) / (1 - SWEEP));    const paint = (morph: number) => {      rings.forEach((ring, index) => {        const drained = easeInOut(clamp01(progressAt(morph, ring.x) * RING_HASTE));        ringElements[index].setAttribute("r", String((DOT_RADIUS - RING_STROKE / 2) * (1 - drained)));        ringElements[index].setAttribute("stroke-width", String(RING_STROKE * (1 - drained)));      });      dots.forEach(({ pattern, doto }, index) => {        const arrived = easeInOut(progressAt(morph, pattern.x));        const side = mix(DOT_RADIUS * 2, DOTO_DOT_SIDE, arrived);        const dot = dotElements[index];        dot.setAttribute("x", String(mix(pattern.x, doto.x, arrived) - side / 2));        dot.setAttribute("y", String(mix(pattern.y, doto.y, arrived) - side / 2));        dot.setAttribute("width", String(side));        dot.setAttribute("height", String(side));        dot.setAttribute("rx", String(mix(DOT_RADIUS, 0, arrived)));      });    };    let frame = 0;    let lastFrameAt = 0;    let elapsed = 0;    let paintedMorph = 0;    let isOnScreen = false;    const tick = (now: number) => {      elapsed += Math.min(LONGEST_FRAME_MS, Math.max(0, now - lastFrameAt));      lastFrameAt = now;      const morph = morphAt(elapsed);      if (morph !== paintedMorph) {        paint(morph);        paintedMorph = morph;      }      frame = requestAnimationFrame(tick);    };    const sync = () => {      const shouldPlay = isOnScreen && !document.hidden;      if (shouldPlay && !frame) {        lastFrameAt = performance.now();        frame = requestAnimationFrame(tick);      } else if (!shouldPlay && frame) {        cancelAnimationFrame(frame);        frame = 0;      }    };    const visibility = new IntersectionObserver(([entry]) => {      isOnScreen = entry.isIntersecting;      sync();    });    visibility.observe(svg);    document.addEventListener("visibilitychange", sync);    return () => {      visibility.disconnect();      document.removeEventListener("visibilitychange", sync);      cancelAnimationFrame(frame);      paint(0);    };  }, [width, dots, rings]);  return (    // Doto's comma hangs a dot below the grid.    <svg ref={svgRef} aria-hidden="true" data-slot="credits-dot-number" viewBox={`0 0 ${width} ${height}`} className={cn("overflow-visible text-brand", className)}>      <g ref={ringsRef} fill="none" stroke="currentColor" strokeWidth={RING_STROKE}>        {rings.map((ring) => (          <circle key={`${ring.x}:${ring.y}`} cx={ring.x} cy={ring.y} r={DOT_RADIUS - RING_STROKE / 2} strokeOpacity={ring.opacity} />        ))}      </g>      <g ref={dotsRef} fill="currentColor">        {dots.map(({ pattern }, index) => (          <rect key={index} x={pattern.x - DOT_RADIUS} y={pattern.y - DOT_RADIUS} width={DOT_RADIUS * 2} height={DOT_RADIUS * 2} rx={DOT_RADIUS} />        ))}      </g>    </svg>  );}

Reference: src/components/sections/home/credits-dot-number.tsx.