Hero circle patternDesign system
VisualSite

Hero circle pattern

Ring-pattern background with content exclusion areas and optional diamond clusters.

npx context-ds add hero-circle-pattern
On the site

Usage

Use it for

Behind a demo, a card's picture or a small invite panel. tone="subtle" behind animated demos, tone="rings" (fills in under the pointer) for a plain panel, tone="onBlue" on the brand blue. Mark the content with the pattern hole attribute.

Avoid

  • The ring pattern behind body text
  • Clusters behind an animated demo
  • More than one pattern on a card

Dependencies: hero-pattern-hole

Example

examples/hero-circle-pattern.tsx
import { HeroCirclePatternSurface, StoryStage } from "@/components/visuals/hero-circle-pattern";import { patternHole } from "@/components/visuals/hero-pattern-hole";export function Site() {  return (    <div className="grid w-full max-w-5xl gap-6 md:grid-cols-2">      <div className="overflow-hidden rounded-card shadow-card">        <StoryStage>          <div className="flex h-40 items-center justify-center rounded-card bg-surface font-mono text-caption text-fg-muted shadow-card">GET /v1/search?q=pricing</div>        </StoryStage>      </div>      <HeroCirclePatternSurface tone="rings" clusters={false} className="flex min-h-88 flex-col items-center justify-center gap-3 overflow-hidden rounded-card px-6 py-12 text-center shadow-card">        <h3 {...patternHole("text")} className="text-h3 font-normal text-fg">          Talk to the team        </h3>        <p {...patternHole("text")} className="max-w-xs text-body text-fg-muted">          See a demo on your own pages, with your own questions.        </p>      </HeroCirclePatternSurface>    </div>  );}

Source

components/visuals/hero-circle-pattern.tsx
"use client";import * as React from "react";import { cn } from "@/lib/utils";import { HERO_PATTERN_HOLE_ATTRIBUTE, type HeroPatternHole } from "@/components/visuals/hero-pattern-hole";const DESKTOP_PITCH = 16;const MOBILE_PITCH = 12;const MOBILE_BREAKPOINT = 640;const DOT_RADIUS_RATIO = 0.31;const CLEARANCE_RATIO = 1.25;// Leaves a column of dots in the gap between carousel cards.const TIGHT_CLEARANCE_RATIO = 0.25;// Brings the dots up to a card without letting a ring touch it.const SNUG_CLEARANCE_RATIO = 0.75;// Solid clusters sit off center, loosely around the content, like the brand pattern posts.const CLUSTER_TARGETS = [  { x: 0.12, y: 0.2 },  { x: 0.86, y: 0.3 },  { x: 0.1, y: 0.78 },  { x: 0.9, y: 0.84 },];interface Rect {  left: number;  top: number;  right: number;  bottom: number;}interface Hole extends Rect {  kind: HeroPatternHole;}interface Dot {  key: string;  cx: number;  cy: number;}interface ClusterShape {  footprint: [number, number][];  solid: [number, number][];}interface PatternLayout {  radius: number;  hollow: Dot[];  solid: Dot[];}/** * Many same-sized dots as one SVG path, each a closed pair of arcs. A picture of a thousand dots is then a handful of * DOM nodes rather than a thousand circles to style, lay out and hydrate. */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;}function diamondShape(radius: number): ClusterShape {  const cells: [number, number][] = [];  for (let dy = -radius; dy <= radius; dy++) {    for (let dx = -radius; dx <= radius; dx++) {      if (Math.abs(dx) + Math.abs(dy) <= radius) cells.push([dx, dy]);    }  }  return { footprint: cells, solid: cells };}function textLineBoxes(element: HTMLElement) {  const range = document.createRange();  range.selectNodeContents(element);  return Array.from(range.getClientRects());}// Text holes follow each rendered line rather than the element's box, so the cut-out traces a headline's ragged edge.function measureHoles(container: HTMLElement): Hole[] {  const origin = container.getBoundingClientRect();  const rects: Hole[] = [];  for (const element of container.querySelectorAll<HTMLElement>(`[${HERO_PATTERN_HOLE_ATTRIBUTE}]`)) {    const kind = element.getAttribute(HERO_PATTERN_HOLE_ATTRIBUTE) as HeroPatternHole;    const boxes = kind === "text" ? textLineBoxes(element) : [element.getBoundingClientRect()];    for (const box of boxes) {      if (box.width === 0 || box.height === 0) continue;      rects.push({ left: box.left - origin.left, top: box.top - origin.top, right: box.right - origin.left, bottom: box.bottom - origin.top, kind });    }  }  return rects;}// Rows and columns are stretched by a fraction of a pixel so the outer dots sit exactly half a pitch inside every edge:// no dot is cropped, and two patterned sections that touch continue at the same spacing across their seam.function fitAxis(extent: number, pitch: number) {  const count = Math.max(1, Math.round(extent / pitch));  const step = count > 1 ? (extent - pitch) / (count - 1) : 0;  return { count, position: (index: number) => pitch / 2 + index * step };}function isInside(hole: Rect, x: number, y: number, clearance: number) {  return x > hole.left - clearance && x < hole.right + clearance && y > hole.top - clearance && y < hole.bottom + clearance;}function nearestClusterAnchor(  targetColumn: number,  targetRow: number,  cluster: [number, number][],  isFree: (column: number, row: number) => boolean,  columns: number,  rows: number,) {  let best: { column: number; row: number; distance: number } | null = null;  for (let row = 0; row < rows; row++) {    for (let column = 0; column < columns; column++) {      const distance = (column - targetColumn) ** 2 + (row - targetRow) ** 2;      if (best && distance >= best.distance) continue;      if (cluster.every(([dx, dy]) => isFree(column + dx, row + dy))) best = { column, row, distance };    }  }  return best;}function buildLayout(width: number, height: number, holes: Hole[], withClusters: boolean): PatternLayout {  const isMobile = width < MOBILE_BREAKPOINT;  const pitch = isMobile ? MOBILE_PITCH : DESKTOP_PITCH;  const clearanceFor = (hole: Hole) => pitch * (hole.kind === "tight-box" ? TIGHT_CLEARANCE_RATIO : hole.kind === "snug-box" ? SNUG_CLEARANCE_RATIO : CLEARANCE_RATIO);  const { count: columns, position: columnX } = fitAxis(width, pitch);  const { count: rows, position: rowY } = fitAxis(height, pitch);  const clearingHoles = holes.filter((hole) => hole.kind !== "cover");  const covers = holes.filter((hole) => hole.kind === "cover");  const visible = new Uint8Array(columns * rows);  const covered = new Uint8Array(columns * rows);  for (let row = 0; row < rows; row++) {    for (let column = 0; column < columns; column++) {      const index = row * columns + column;      visible[index] = clearingHoles.some((hole) => isInside(hole, columnX(column), rowY(row), clearanceFor(hole))) ? 0 : 1;      covered[index] = covers.some((cover) => isInside(cover, columnX(column), rowY(row), pitch * TIGHT_CLEARANCE_RATIO)) ? 1 : 0;    }  }  const inGrid = (column: number, row: number) => column >= 0 && row >= 0 && column < columns && row < rows;  const isVisible = (column: number, row: number) => inGrid(column, row) && visible[row * columns + column] === 1;  const diamond = diamondShape(isMobile ? 2 : 3);  const solidCells = new Set<string>();  const claimedCells = new Set<string>();  const isFree = (column: number, row: number) => isVisible(column, row) && covered[row * columns + column] === 0 && !claimedCells.has(`${column}:${row}`);  for (const target of withClusters ? CLUSTER_TARGETS : []) {    const anchor = nearestClusterAnchor(target.x * (columns - 1), target.y * (rows - 1), diamond.footprint, isFree, columns, rows);    if (!anchor) continue;    for (const [dx, dy] of diamond.footprint) claimedCells.add(`${anchor.column + dx}:${anchor.row + dy}`);    for (const [dx, dy] of diamond.solid) solidCells.add(`${anchor.column + dx}:${anchor.row + dy}`);  }  const hollow: Dot[] = [];  const solid: Dot[] = [];  for (let row = 0; row < rows; row++) {    for (let column = 0; column < columns; column++) {      if (!isVisible(column, row)) continue;      const key = `${column}:${row}`;      const dot = { key, cx: columnX(column), cy: rowY(row) };      (solidCells.has(key) ? solid : hollow).push(dot);    }  }  return { radius: pitch * DOT_RADIUS_RATIO, hollow, solid };}// Only the plain ring tone lights up under the pointer; the other tones sit behind animated demos, where a moving// spotlight would be one motion too many.const PATTERN_TONES = {  onBlue: { color: "var(--ds-color-white)", hollowOpacity: 1, solidOpacity: 1, hoverOpacity: 0 },  subtle: { color: "var(--ds-color-brand)", hollowOpacity: 0.14, solidOpacity: 0.3, hoverOpacity: 0 },  rings: { color: "var(--ds-color-brand)", hollowOpacity: 0.25, solidOpacity: 0.4, hoverOpacity: 0.5 },} as const;export type HeroCirclePatternTone = keyof typeof PATTERN_TONES;const SPOTLIGHT_RADIUS_IN_DOTS = 6;// Fills the rings nearest the pointer through a soft radial mask. Only the mask's circle moves, so following the// pointer costs two attribute writes a frame rather than repainting dots one by one.function useSpotlight(container: HTMLDivElement | null, enabled: boolean) {  const spotlightRef = React.useRef<SVGCircleElement>(null);  const [lit, setLit] = React.useState(false);  React.useEffect(() => {    const spotlight = spotlightRef.current;    if (!container || !spotlight || !enabled || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;    let frame = 0;    let point = { x: 0, y: 0 };    const place = () => {      frame = 0;      spotlight.setAttribute("cx", String(point.x));      spotlight.setAttribute("cy", String(point.y));    };    const onPointerMove = (event: PointerEvent) => {      if (event.pointerType !== "mouse") return;      const origin = container.getBoundingClientRect();      point = { x: event.clientX - origin.left, y: event.clientY - origin.top };      if (!frame) frame = requestAnimationFrame(place);      setLit(true);    };    const onPointerLeave = () => setLit(false);    container.addEventListener("pointermove", onPointerMove, { passive: true });    container.addEventListener("pointerleave", onPointerLeave);    return () => {      cancelAnimationFrame(frame);      container.removeEventListener("pointermove", onPointerMove);      container.removeEventListener("pointerleave", onPointerLeave);    };  }, [container, enabled]);  return { spotlightRef, lit };}/** * The brand dot grid as a surface: it clears itself around every descendant marked with the hole attribute, so the * empty space takes the content's shape. Its solid clusters are the brand's diamonds; `clusters={false}` leaves only * the plain rings. `onBlue` is white rings for a blue panel, `subtle` faint blue rings behind a demo, and `rings` a * stronger blue that fills in under the pointer. */export function HeroCirclePatternSurface({  className,  tone = "onBlue",  clusters = true,  children,}: {  className?: string;  tone?: HeroCirclePatternTone;  clusters?: boolean;  children: React.ReactNode;}) {  const toneStyle = PATTERN_TONES[tone];  const [container, setContainer] = React.useState<HTMLDivElement | null>(null);  const [layout, setLayout] = React.useState<PatternLayout | null>(null);  const hasSpotlight = toneStyle.hoverOpacity > 0;  const { spotlightRef, lit } = useSpotlight(container, hasSpotlight && layout !== null);  const maskId = `pattern-spotlight-${React.useId().replace(/:/g, "")}`;  React.useEffect(() => {    if (!container) return;    let frame = 0;    const relayout = () => {      cancelAnimationFrame(frame);      frame = requestAnimationFrame(() => setLayout(buildLayout(container.clientWidth, container.clientHeight, measureHoles(container), clusters)));    };    const observer = new ResizeObserver(relayout);    observer.observe(container);    for (const hole of container.querySelectorAll(`[${HERO_PATTERN_HOLE_ATTRIBUTE}]`)) {      observer.observe(hole);      if (hole.parentElement) observer.observe(hole.parentElement);    }    void document.fonts.ready.then(relayout);    return () => {      cancelAnimationFrame(frame);      observer.disconnect();    };  }, [clusters, container]);  return (    <div ref={setContainer} data-slot="hero-circle-pattern" className={cn("relative", className)}>      <svg        aria-hidden="true"        className={cn(          "pointer-events-none absolute inset-0 size-full transition-opacity duration-700 ease-out motion-reduce:transition-none",          layout ? "opacity-100" : "opacity-0",        )}      >        {layout && (          <>            <path fill="none" style={{ stroke: toneStyle.color }} strokeWidth="1" opacity={toneStyle.hollowOpacity} d={dotsPath(layout.hollow, layout.radius - 0.5)} />            <path style={{ fill: toneStyle.color }} opacity={toneStyle.solidOpacity} d={dotsPath(layout.solid, layout.radius)} />            {hasSpotlight && (              <>                <defs>                  <radialGradient id={`${maskId}-falloff`}>                    <stop offset="0%" style={{ stopColor: "var(--ds-color-white)" }} />                    <stop offset="100%" style={{ stopColor: "var(--ds-color-black)" }} />                  </radialGradient>                  <mask id={maskId}>                    <circle ref={spotlightRef} cx={-9999} cy={-9999} r={(layout.radius / DOT_RADIUS_RATIO) * SPOTLIGHT_RADIUS_IN_DOTS} fill={`url(#${maskId}-falloff)`} />                  </mask>                </defs>                <path                  mask={`url(#${maskId})`}                  className="transition-opacity duration-300 ease-out motion-reduce:transition-none"                  style={{ fill: toneStyle.color, opacity: lit ? toneStyle.hoverOpacity : 0 }}                  d={dotsPath([...layout.hollow, ...layout.solid], layout.radius - 0.5)}                />              </>            )}          </>        )}      </svg>      {children}    </div>  );}/** Where a card's dot story plays: the brand's faint ring pattern, keeping clear of the picture in its middle. */export function StoryStage({ children, className, contentClassName }: { children: React.ReactNode; className?: string; contentClassName?: string }) {  return (    <HeroCirclePatternSurface tone="subtle" clusters={false} className={cn("flex min-h-88 flex-1 items-center justify-center px-4 py-8 select-none sm:px-9", className)}>      <div {...{ [HERO_PATTERN_HOLE_ATTRIBUTE]: "snug-box" }} className={cn("relative w-full max-w-104", contentClassName)}>        {children}      </div>    </HeroCirclePatternSurface>  );}

Reference: src/components/sections/shared/hero-circle-pattern.tsx; src/components/sections/home/story-stage.tsx.