VisualSite
Hero circle pattern
Ring-pattern background with content exclusion areas and optional diamond clusters.
$ npx context-ds add hero-circle-patternUsage
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.