Customer logo dotsDesign system
VisualSite

Customer logo dots

Customer logo rendered as dots, with an arrow transition on hover or focus.

npx context-ds add customer-logo-dots
On the site

Usage

Use it for

The picture of a customer story card (tone="onBlue" on the blue card, onWhite on a white panel). Remote logos need CORS or a same-origin resolveSrc.

Avoid

  • Logos in their own colors on a story card
  • A dot logo without a link around it

Dependencies: logo-dot-sampling, dot-engine

Example

examples/customer-logo-dots.tsx
import { CustomerLogoDots } from "@/components/visuals/customer-logo-dots";// A logo as a data URI, so the example samples without a network request or CORS.const LOGO = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><path d="M20 80V20h18l24 36V20h18v60H62L38 44v36z"/></svg>')}`;export function Site() {  return (    <a href="https://context.dev/customers/northwind" className="group mx-auto flex w-full max-w-sm flex-col overflow-hidden rounded-card bg-brand text-on-brand outline-none focus-visible:ring-2 focus-visible:ring-focus-ring">      <div className="relative h-60">        <CustomerLogoDots src={LOGO} className="absolute inset-0 size-full" />      </div>      <div className="flex flex-col gap-1 p-6">        <span className="text-h5">Northwind</span>        <span className="text-body-sm text-on-brand/80">Branded docs for every customer, built on brand context.</span>      </div>    </a>  );}

Source

components/visuals/customer-logo-dots.tsx
"use client";import * as React from "react";import { useHoverDotMorph, type LoadDotMorph } from "./use-hover-dot-morph";import { cn } from "@/lib/utils";import { LOGO_CELLS, LOGO_GRID, LOGO_LEFT, LOGO_TOP } from "./customer-logo-grid";import { type LogoCell, sampleLogoCells } from "@/components/visuals/logo-dot-sampling";const { columns: GRID_COLUMNS, rows: GRID_ROWS, pitch: DOT_PITCH, dotRadius: DOT_RADIUS, ringStroke: RING_STROKE } = LOGO_GRID;// The backdrop uses every other cell at low opacity so it reads as texture, and keeps this many cells clear of the// logo's dots so the logo keeps a clean silhouette.const FIELD_STEP = 2;const FIELD_CLEARANCE = 2;const FIELD_OPACITY = 0.22;const VIEW_WIDTH = GRID_COLUMNS * DOT_PITCH;const VIEW_HEIGHT = GRID_ROWS * DOT_PITCH;// Rings stay this far inside the visible edge, so the card never slices one in half.const FIELD_EDGE_MARGIN = DOT_PITCH / 2;interface ViewBounds {  left: number;  top: number;  right: number;  bottom: number;}interface FieldCell {  column: number;  row: number;}const center = (index: number) => (index + 0.5) * DOT_PITCH;/** Many same-sized dots as one SVG path, each a closed pair of arcs, rather than hundreds of circles. */function dotsPath(centers: Iterable<{ cx: number; cy: number }>, radius: number) {  const diameter = +(radius * 2).toFixed(2);  const r = +radius.toFixed(2);  let path = "";  for (const { cx, cy } of centers) path += `M${+(cx - radius).toFixed(2)} ${+cy.toFixed(2)}a${r} ${r} 0 1 0 ${diameter} 0a${r} ${r} 0 1 0 ${-diameter} 0`;  return path;}// The part of the viewBox that `slice` actually shows for this element's size, in viewBox units.function visibleBounds(width: number, height: number): ViewBounds {  const scale = Math.max(width / VIEW_WIDTH, height / VIEW_HEIGHT);  const visibleWidth = width / scale;  const visibleHeight = height / scale;  const left = (VIEW_WIDTH - visibleWidth) / 2;  const top = (VIEW_HEIGHT - visibleHeight) / 2;  return { left, top, right: left + visibleWidth, bottom: top + visibleHeight };}// The backdrop's rings, split into the ones drawn and the ones left out because they sit too near the logo.function fieldCells(logoCells: LogoCell[], bounds: ViewBounds) {  const nearLogo = new Set<string>();  for (const cell of logoCells) {    for (let dy = -FIELD_CLEARANCE; dy <= FIELD_CLEARANCE; dy++) {      for (let dx = -FIELD_CLEARANCE; dx <= FIELD_CLEARANCE; dx++) nearLogo.add(`${cell.column + LOGO_LEFT + dx}:${cell.row + LOGO_TOP + dy}`);    }  }  const shown: FieldCell[] = [];  const cleared: FieldCell[] = [];  for (let row = 0; row < GRID_ROWS; row += FIELD_STEP) {    for (let column = 0; column < GRID_COLUMNS; column += FIELD_STEP) {      const cx = center(column);      const cy = center(row);      const fitsInView =        cx - DOT_RADIUS - FIELD_EDGE_MARGIN >= bounds.left &&        cx + DOT_RADIUS + FIELD_EDGE_MARGIN <= bounds.right &&        cy - DOT_RADIUS - FIELD_EDGE_MARGIN >= bounds.top &&        cy + DOT_RADIUS + FIELD_EDGE_MARGIN <= bounds.bottom;      if (fitsInView) (nearLogo.has(`${column}:${row}`) ? cleared : shown).push({ column, row });    }  }  return { shown, cleared };}/** * A customer's logo redrawn in the brand dot grid, over a faint backdrop of rings that stays clear of it. Inside a link * (or an element marked `data-hover-morph`), hovering or focusing it melts the logo into an arrow. `onBlue` draws white * dots for a blue card, `onWhite` brand blue dots for a white one. A logo that cannot be sampled shows as a plain image. */export function CustomerLogoDots({  src,  background,  resolveSrc,  className,  tone = "onBlue",}: {  /** The logo image. A remote one must allow CORS, or pass `resolveSrc`. */  src: string;  /** A color painted behind the logo before sampling, for logos drawn on a tile. */  background?: string;  /** Maps the logo's URL to one the canvas may read (a same-origin proxy). */  resolveSrc?: (src: string) => string;  className?: string;  tone?: "onBlue" | "onWhite";}) {  const [cells, setCells] = React.useState<LogoCell[] | null>(null);  const [samplingFailed, setSamplingFailed] = React.useState(false);  const [bounds, setBounds] = React.useState<ViewBounds | null>(null);  const svgRef = React.useRef<SVGSVGElement>(null);  const canvasRef = React.useRef<HTMLCanvasElement>(null);  const field = React.useMemo(() => (cells && bounds ? fieldCells(cells, bounds) : null), [cells, bounds]);  const loadMorph = React.useCallback<LoadDotMorph>(    async (canvas, onPlayingChange) => {      if (!cells || !field) return null;      const surface = canvas.closest("article, [data-dot-surface]");      const { createCustomerLogoMorph } = await import("./customer-logo-morph");      return createCustomerLogoMorph({        canvas,        cells,        clearedBackdrop: { cells: field.cleared.map((cell) => cell.row * GRID_COLUMNS + cell.column), opacity: FIELD_OPACITY },        palette: { dots: getComputedStyle(canvas).color, background: surface ? getComputedStyle(surface).backgroundColor : "transparent" },        onPlayingChange,      });    },    [cells, field],  );  const isMorphing = useHoverDotMorph(canvasRef, loadMorph);  React.useEffect(() => {    const svg = svgRef.current;    if (!svg) return;    const observer = new ResizeObserver(([entry]) => setBounds(visibleBounds(entry.contentRect.width, entry.contentRect.height)));    observer.observe(svg);    return () => observer.disconnect();  }, []);  // Sampling fetches and decodes the logo, so it waits until the card is nearly on screen.  const resolveRef = React.useRef(resolveSrc);  React.useEffect(() => {    resolveRef.current = resolveSrc;  }, [resolveSrc]);  React.useEffect(() => {    const svg = svgRef.current;    if (!svg) return;    let cancelled = false;    setSamplingFailed(false);    const nearView = new IntersectionObserver(      ([entry]) => {        if (!entry.isIntersecting) return;        nearView.disconnect();        sampleLogoCells(src, { background, cellsPerSide: LOGO_CELLS, resolveSrc: resolveRef.current })          .then((sampled) => {            if (cancelled) return;            if (sampled.length === 0) throw new Error(`No logo pixels found in ${src}`);            setCells(sampled);          })          // A logo that can't be sampled into dots falls back to the plain image, so the card is never left blank.          .catch(() => {            if (!cancelled) setSamplingFailed(true);          });      },      { rootMargin: "600px" },    );    nearView.observe(svg);    return () => {      cancelled = true;      nearView.disconnect();    };  }, [src, background]);  const ringRadius = DOT_RADIUS - RING_STROKE / 2;  const logoCell = (cell: LogoCell) => ({ cx: center(cell.column + LOGO_LEFT), cy: center(cell.row + LOGO_TOP) });  return (    <div data-slot="customer-logo-dots" className={cn("relative", tone === "onWhite" ? "text-brand" : "text-white", className)} aria-hidden="true">      <svg        ref={svgRef}        viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`}        preserveAspectRatio="xMidYMid slice"        className={cn("block size-full transition-opacity duration-500 ease-out motion-reduce:transition-none", cells ? "opacity-100" : "opacity-0")}      >        {field && (          <path            fill="none"            stroke="currentColor"            strokeWidth={RING_STROKE}            opacity={FIELD_OPACITY}            d={dotsPath(              field.shown.map((cell) => ({ cx: center(cell.column), cy: center(cell.row) })),              ringRadius,            )}          />        )}        {cells && (          <g className={cn(isMorphing && "invisible")}>            <path fill="none" stroke="currentColor" strokeWidth={RING_STROKE} d={dotsPath(cells.filter((cell) => !cell.filled).map(logoCell), ringRadius)} />            <path fill="currentColor" d={dotsPath(cells.filter((cell) => cell.filled).map(logoCell), DOT_RADIUS)} />          </g>        )}      </svg>      <canvas ref={canvasRef} className={cn("absolute inset-0 size-full", !isMorphing && "invisible")} />      {samplingFailed && (        <span className="absolute inset-0 flex items-center justify-center">          <img src={src} alt="" className="size-24 rounded-shell object-contain" style={{ background }} />        </span>      )}    </div>  );}
components/visuals/customer-logo-grid.ts
import type { DotGrid } from "@/components/visuals/dot-engine/dot-morph-dots";export const LOGO_CELLS = 28;// The art fills its whole area (slice), so the grid runs past the logo on every side; the logo sits in its middle.export const LOGO_GRID: DotGrid = { columns: 76, rows: 54, pitch: 10, dotRadius: 3.2, ringStroke: 1, haloRadius: 16 };export const LOGO_LEFT = (LOGO_GRID.columns - LOGO_CELLS) / 2;export const LOGO_TOP = (LOGO_GRID.rows - LOGO_CELLS) / 2;
components/visuals/customer-logo-morph.ts
import { LOGO_CELLS, LOGO_GRID, LOGO_LEFT, LOGO_TOP } from "./customer-logo-grid";import { createGlyphShader, glyphRestingDots, type GlyphMorph } from "@/components/visuals/dot-engine/dot-glyph-morph";import type { DotPalette } from "@/components/visuals/dot-engine/dot-morph-dots";import { createDotMorphPlayer, type DotMorphFrame, type DotMorphPlayer } from "@/components/visuals/dot-engine/dot-morph-player";import type { LogoCell } from "@/components/visuals/logo-dot-sampling";/** On hover a customer's logo melts into an arrow pointing up and to the right: the card is a link, and the arrow says where it goes. */const LOGO_CENTER = (LOGO_CELLS * LOGO_GRID.pitch) / 2;// The arrow is drawn about its own middle, as wide as its head: a short shaft tucked under a broad corner.const ARROW_WEIGHT = 44;const ARROW_REACH = 82;const ARROW_TIP_INSET = 11;const ARROW_RUN_UP = 46;const ARROW_NUDGE = 7;const NUDGES_PER_SECOND = 0.5;const MORPH_IN_SECONDS = 0.75;const clamp01 = (value: number) => Math.min(1, Math.max(0, value));const phase = (morph: number, start: number, end: number) => clamp01((morph - start) / (end - start));const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);const easeOutBack = (t: number) => 1 + 2.70158 * (t - 1) ** 3 + 1.70158 * (t - 1) ** 2;function drawArrow(context: CanvasRenderingContext2D, { morph, spin }: DotMorphFrame) {  const runUp = ARROW_RUN_UP * (1 - easeOutBack(phase(morph, 0.35, 0.95)));  const nudge = ARROW_NUDGE * easeInOut(phase(morph, 0.85, 1)) * (1 - Math.cos(spin)) * 0.5;  const travel = nudge - runUp;  context.translate(LOGO_CENTER + travel, LOGO_CENTER - travel);  context.strokeStyle = "#000";  context.lineWidth = ARROW_WEIGHT;  context.lineJoin = "miter";  context.beginPath();  context.moveTo(-ARROW_REACH, -ARROW_REACH);  context.lineTo(ARROW_REACH, -ARROW_REACH);  context.lineTo(ARROW_REACH, ARROW_REACH);  context.moveTo(-ARROW_REACH, ARROW_REACH);  context.lineTo(ARROW_REACH - ARROW_TIP_INSET, -ARROW_REACH + ARROW_TIP_INSET);  context.stroke();}interface CustomerLogoMorphOptions {  canvas: HTMLCanvasElement;  cells: LogoCell[];  /** The backdrop rings left out around the logo, as grid cell indices. */  clearedBackdrop: { cells: number[]; opacity: number };  palette: DotPalette;  onPlayingChange: (isPlaying: boolean) => void;}export function createCustomerLogoMorph({ canvas, cells, clearedBackdrop, palette, onPlayingChange }: CustomerLogoMorphOptions): DotMorphPlayer {  const glyph: GlyphMorph = { grid: LOGO_GRID, box: { left: LOGO_LEFT, top: LOGO_TOP, cellsPerSide: LOGO_CELLS }, cells, drawTarget: drawArrow };  return createDotMorphPlayer({    canvas,    palette,    onPlayingChange,    source: {      grid: LOGO_GRID,      fit: "cover",      restingDots: glyphRestingDots(glyph),      spinSpeed: NUDGES_PER_SECOND * Math.PI * 2,      spinPeriod: Math.PI * 2,      sample: createGlyphShader(glyph),      backdrop: clearedBackdrop,      morphInSeconds: MORPH_IN_SECONDS,    },  });}

Reference: src/components/sections/shared/customer-logo-dots.tsx; src/components/sections/shared/customer-logo-grid.ts; src/components/sections/shared/customer-logo-morph.ts.