VisualSite
Footer watermark
Dot-rendered Context.dev wordmark with pointer interaction and an animated cursor.
$ npx context-ds add footer-watermarkUsage
Use it for
The dark band at the very bottom of the site footer, filling it. Pass outlined and solid for another wordmark; mark a button beside it with the cursor origin attribute for the cursor.
Avoid
- The watermark on a light background
- A watermark anywhere but the footer
Example
examples/footer-watermark.tsx
import { Button } from "@/components/ui/button";import { FooterWatermark, WATERMARK_CURSOR_ORIGIN_ATTRIBUTE } from "@/components/visuals/footer-watermark";export function Site() { return ( <div className="relative h-44 w-full overflow-hidden bg-dark select-none sm:h-88"> <FooterWatermark className="max-sm:top-14 max-sm:h-auto" /> <span {...{ [WATERMARK_CURSOR_ORIGIN_ATTRIBUTE]: "" }} className="absolute top-[calc(50%+min(1.65vw,2.1rem))] left-1/2 z-10 -translate-x-1/2 -translate-y-1/2 max-sm:top-4 max-sm:translate-y-0"> <Button asChild variant="pill-on-brand" size="sm" className="transition-transform duration-150 ease-out in-data-pressed:scale-95"> <a href="https://docs.context.dev">Onboard your agent</a> </Button> </span> </div> );}Source
components/visuals/footer-watermark.tsx
"use client";import * as React from "react";import { cn } from "@/lib/utils";import { type DotLayer, sampleWatermarkDots, type WatermarkDots, type WatermarkPart } from "./footer-watermark-dots";type Point = { x: number; y: number };/** Mark an element beside the watermark (in the same parent) with this, and the cursor clicks it before gliding off. */export const WATERMARK_CURSOR_ORIGIN_ATTRIBUTE = "data-watermark-cursor-origin";const VIEW_BOX = { width: 140, height: 24 };const BASELINE_Y = 22;// In viewBox units. On a narrow screen the dots stop shrinking with the wordmark, so they stay circles, not a gray wash.const DOT_PITCH = 0.6;const SMALLEST_DOT_PITCH_PX = 3;const DOT_PITCH_STEPS_PER_UNIT = 20;const RING_OPACITY = 0.55;const CURSOR_SIZE = 9;const RESTING_CURSOR: Point = { x: 110, y: 5.5 };// Where the pointer's tip lands inside the icon box, in viewBox units, with the icon's upright turn (its left edge// vertical, like a system cursor).const CURSOR_TIP: Point = { x: CURSOR_SIZE * (42 / 79), y: CURSOR_SIZE * (28 / 79) };// In viewBox units: wide enough that the patch covers the letters from ascender to baseline, not just their tops.const PATCH_RADIUS = 15;// The patch swaps the paints fully out to this fraction of its radius, then feathers to nothing at the edge: a clean// solid and outline swap inside, instead of letters caught half way in a gray smear.const PATCH_CORE = 0.6;// Time constants (ms) for easing toward the target, so it feels the same at 60Hz and 120Hz: the patch keeps close to// the pointer, grows in quickly, and fades a little faster than it grows when the pointer leaves.const FOLLOW_MS = 55;const GROW_MS = 90;const SHRINK_MS = 70;// The cursor waits on the origin element as the watermark scrolls in, then glides to its resting spot once the wordmark// is mostly on screen, so the move is seen rather than finished before anyone gets there.const CURSOR_LAUNCH_MS = 900;const CURSOR_LAUNCH_EASING = "cubic-bezier(0.65, 0, 0.35, 1)";const CURSOR_LAUNCH_VISIBLE_RATIO = 0.6;// Before the glide the cursor clicks the origin: it presses toward its tip, holds, lets go, then sets off.const CURSOR_PRESS_MS = 150;const CURSOR_RELEASE_TO_LAUNCH_MS = 180;const CURSOR_PRESSED_SCALE = 0.86;const ease = (from: number, to: number, dt: number, tau: number) => to + (from - to) * Math.exp(-dt / tau);function dotPitchAt(pixelsPerUnit: number) { const pitch = Math.max(DOT_PITCH, SMALLEST_DOT_PITCH_PX / pixelsPerUnit); return Math.round(pitch * DOT_PITCH_STEPS_PER_UNIT) / DOT_PITCH_STEPS_PER_UNIT;}function DotLayerPaths({ layer, ringStroke }: { layer: DotLayer; ringStroke: number }) { return ( <> <path d={layer.rings} fill="none" stroke="currentColor" strokeOpacity={RING_OPACITY} strokeWidth={ringStroke} /> <path d={layer.solids} fill="currentColor" /> </> );}const CURSOR_DOT_PITCH = 3.4;const CURSOR_DOT_RADIUS = 1.15;const CURSOR_PATH = "M36.16 32.93C32.65 32.44 30.21 36.34 32.19 39.27L48.13 62.93C50.03 65.75 54.34 65.16 55.41 61.93L58.87 51.53L66.7 43.85C69.13 41.47 67.77 37.34 64.4 36.87L36.16 32.93Z";const CURSOR_OUTLINE = "M31.36 39.83C28.901 36.18 31.93 31.33 36.29 31.939L64.54 35.88C68.73 36.47 70.42 41.6 67.4 44.57L59.75 52.07L56.36 62.25C55.02 66.26 49.66 66.99 47.3 63.49L31.36 39.83Z";// A pointer filled with the brand dot pattern over a dark base, so it reads as part of the dotted wordmark it rests on.// The shadow sits outside the rotation so it always falls downward.function CursorIcon({ id, ...props }: React.SVGProps<SVGSVGElement> & { id: string }) { const dotsId = `${id}-dots`; return ( <svg viewBox="0 0 79 79" fill="none" aria-hidden="true" {...props}> <g filter={`url(#${id})`}> <g transform="rotate(34 50 48)"> <path d={CURSOR_PATH} style={{ fill: "var(--ds-color-bg-code)" }} /> <path d={CURSOR_PATH} fill={`url(#${dotsId})`} /> <path d={CURSOR_OUTLINE} strokeWidth="3" style={{ stroke: "var(--ds-color-bg-code)" }} /> <path d={CURSOR_OUTLINE} strokeWidth="1.4" style={{ stroke: "var(--ds-color-blue-80)" }} /> </g> </g> <defs> <pattern id={dotsId} width={CURSOR_DOT_PITCH} height={CURSOR_DOT_PITCH} patternUnits="userSpaceOnUse"> <circle cx={CURSOR_DOT_PITCH / 2} cy={CURSOR_DOT_PITCH / 2} r={CURSOR_DOT_RADIUS} style={{ fill: "var(--ds-color-blue-80)" }} /> </pattern> <filter id={id} x="-10" y="-10" width="99" height="99" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB"> <feFlood floodOpacity="0" result="BackgroundImageFix" /> <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" /> <feOffset dy="4" /> <feGaussianBlur stdDeviation="2" /> <feComposite in2="hardAlpha" operator="out" /> <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0" /> <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow" /> <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape" /> </filter> </defs> </svg> );}// Waits on the origin, then glides home with the Web Animations API once launched (offset goes back to null).function LaunchingCursor({ id, offset, isHidden, isPressing }: { id: string; offset: Point | null; isHidden: boolean; isPressing: boolean }) { const groupRef = React.useRef<SVGGElement>(null); const lastOffset = React.useRef<Point | null>(null); React.useEffect(() => { const group = groupRef.current; if (!group) return; if (offset) { lastOffset.current = offset; return; } const from = lastOffset.current; lastOffset.current = null; if (!from) return; const glide = group.animate([{ transform: `translate(${from.x}px, ${from.y}px)` }, { transform: "translate(0px, 0px)" }], { duration: CURSOR_LAUNCH_MS, easing: CURSOR_LAUNCH_EASING, }); return () => glide.cancel(); }, [offset]); return ( <g ref={groupRef} style={{ transform: `translate(${offset?.x ?? 0}px, ${offset?.y ?? 0}px)`, visibility: isHidden ? "hidden" : "visible" }}> <g className="transition-transform duration-150 ease-out motion-reduce:transition-none" style={{ transform: `scale(${isPressing ? CURSOR_PRESSED_SCALE : 1})`, transformOrigin: `${RESTING_CURSOR.x + CURSOR_TIP.x}px ${RESTING_CURSOR.y + CURSOR_TIP.y}px` }} > <CursorIcon id={id} x={RESTING_CURSOR.x} y={RESTING_CURSOR.y} width={CURSOR_SIZE} height={CURSOR_SIZE} /> </g> </g> );}/** * The wordmark as a watermark, rebuilt from the brand's circles: `outlined` in rings, `solid` in solid dots. A soft * patch follows the pointer, and inside it the rings fill in and the dots hollow out. It fills its parent (a dark band * at the foot of the footer) and draws in `currentColor`, white by default. With an element marked * WATERMARK_CURSOR_ORIGIN_ATTRIBUTE beside it, a dotted cursor clicks that element as the watermark scrolls in, then * glides onto the letters. The letters are sampled from the brand face, so they appear once it has loaded. */export function FooterWatermark({ outlined = "context.", solid = "dev", className }: { outlined?: string; solid?: string; className?: string }) { const svgRef = React.useRef<SVGSVGElement>(null); const wordRef = React.useRef<SVGTextElement>(null); const [dots, setDots] = React.useState<WatermarkDots | null>(null); const inkRef = React.useRef<SVGGElement>(null); const [inkOffsetY, setInkOffsetY] = React.useState(0); const patchRefs = React.useRef<SVGCircleElement[]>([]); const target = React.useRef<Point & { radius: number }>({ x: 0, y: 0, radius: 0 }); const current = React.useRef<Point & { radius: number }>({ x: 0, y: 0, radius: 0 }); const frame = React.useRef(0); const lastFrame = React.useRef(0); const snap = React.useRef(false); const [cursorLaunchOffset, setCursorLaunchOffset] = React.useState<Point | null>(null); const [hasCursorLaunched, setHasCursorLaunched] = React.useState(false); const [isCursorPressing, setIsCursorPressing] = React.useState(false); const id = React.useId().replace(/:/g, ""); const parts = React.useMemo<WatermarkPart[]>( () => [ { firstCharacter: 0, characterCount: outlined.length, isMostlySolid: false }, { firstCharacter: outlined.length, characterCount: solid.length, isMostlySolid: true }, ], [outlined, solid], ); React.useEffect(() => { snap.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches; return () => cancelAnimationFrame(frame.current); }, []); React.useEffect(() => { const svg = svgRef.current; const word = wordRef.current; if (!svg || !word) return; let sampledPitch = 0; let isFontReady = false; const sample = () => { const pixelsPerUnit = svg.getScreenCTM()?.a; if (!isFontReady || !pixelsPerUnit) return; const pitch = dotPitchAt(pixelsPerUnit); if (pitch === sampledPitch) return; sampledPitch = pitch; setDots(sampleWatermarkDots(word, parts, pitch, VIEW_BOX)); }; const observer = new ResizeObserver(sample); observer.observe(svg); let isMounted = true; // The letters' shapes are read from the rendered font, so sampling waits for it. void document.fonts.ready.then(() => { if (!isMounted) return; isFontReady = true; sample(); }); return () => { isMounted = false; observer.disconnect(); }; }, [parts]); React.useEffect(() => { const svg = svgRef.current; const origin = svg?.parentElement?.querySelector(`[${WATERMARK_CURSOR_ORIGIN_ATTRIBUTE}]`); if (!svg || !origin || !dots || hasCursorLaunched) return; const isReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const measureLaunchOffset = () => { const matrix = svg.getScreenCTM(); if (!matrix) return null; const bounds = origin.getBoundingClientRect(); const topBorderCenter = new DOMPoint(bounds.left + bounds.width / 2, bounds.top).matrixTransform(matrix.inverse()); return { x: topBorderCenter.x - (RESTING_CURSOR.x + CURSOR_TIP.x), y: topBorderCenter.y - (RESTING_CURSOR.y + CURSOR_TIP.y) }; }; const clickTimers: ReturnType<typeof setTimeout>[] = []; const observer = new IntersectionObserver( ([entry]) => { if (!entry?.isIntersecting) return; if (isReducedMotion) { observer.disconnect(); setHasCursorLaunched(true); return; } setCursorLaunchOffset((offset) => offset ?? measureLaunchOffset()); if (entry.intersectionRatio < CURSOR_LAUNCH_VISIBLE_RATIO) return; observer.disconnect(); setIsCursorPressing(true); origin.setAttribute("data-pressed", ""); clickTimers.push( setTimeout(() => { setIsCursorPressing(false); origin.removeAttribute("data-pressed"); }, CURSOR_PRESS_MS), setTimeout(() => setHasCursorLaunched(true), CURSOR_PRESS_MS + CURSOR_RELEASE_TO_LAUNCH_MS), ); }, { threshold: [0, CURSOR_LAUNCH_VISIBLE_RATIO] }, ); observer.observe(svg); return () => { observer.disconnect(); clickTimers.forEach(clearTimeout); origin.removeAttribute("data-pressed"); }; }, [dots, inkOffsetY, hasCursorLaunched]); // The font box leaves more room above the letters than below them, so the view is centered on the dots themselves. React.useEffect(() => { const ink = inkRef.current?.getBBox(); if (!ink || ink.height === 0) return; setInkOffsetY(ink.y + ink.height / 2 - VIEW_BOX.height / 2); }, [dots]); const paint = () => { const { x, y, radius } = current.current; for (const patch of patchRefs.current) { patch.setAttribute("cx", String(x)); patch.setAttribute("cy", String(y)); patch.setAttribute("r", String(radius)); } }; const tick = (time: number) => { const now = current.current; const goal = target.current; // Capped, so a frame after the tab was hidden doesn't leap. const dt = lastFrame.current ? Math.min(64, time - lastFrame.current) : 16; lastFrame.current = time; if (snap.current) { Object.assign(now, goal); } else { now.x = ease(now.x, goal.x, dt, FOLLOW_MS); now.y = ease(now.y, goal.y, dt, FOLLOW_MS); now.radius = ease(now.radius, goal.radius, dt, goal.radius > now.radius ? GROW_MS : SHRINK_MS); } const settled = Math.abs(goal.x - now.x) < 0.01 && Math.abs(goal.y - now.y) < 0.01 && Math.abs(goal.radius - now.radius) < 0.01; if (settled) Object.assign(now, goal); paint(); if (settled) { frame.current = 0; lastFrame.current = 0; } else { frame.current = requestAnimationFrame(tick); } }; const animate = () => { if (!frame.current) frame.current = requestAnimationFrame(tick); }; const toViewBox = (event: React.PointerEvent<SVGSVGElement>): Point | null => { const matrix = svgRef.current?.getScreenCTM(); if (!matrix) return null; const { x, y } = new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse()); return { x, y }; }; const onPointerMove = (event: React.PointerEvent<SVGSVGElement>) => { const point = toViewBox(event); if (!point) return; // Entering from nothing starts the patch under the pointer rather than sliding in from its last spot. if (current.current.radius < 0.5) Object.assign(current.current, point); target.current = { ...point, radius: PATCH_RADIUS }; animate(); }; const onPointerLeave = () => { target.current = { ...target.current, radius: 0 }; animate(); }; const viewBox = `0 ${inkOffsetY} ${VIEW_BOX.width} ${VIEW_BOX.height}`; return ( <> {/* Takes the pointer by design (the patch follows it); it holds no controls, so nothing is blocked. */} <svg ref={svgRef} aria-hidden="true" data-slot="footer-watermark" viewBox={viewBox} preserveAspectRatio="xMidYMid meet" className={cn("pointer-events-auto absolute inset-0 size-full text-white mix-blend-plus-lighter select-none", className)} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave} onPointerCancel={onPointerLeave} onPointerUp={(event) => { if (event.pointerType !== "mouse") onPointerLeave(); }} > <defs> <radialGradient id={`${id}-patch-in`}> <stop offset={PATCH_CORE} stopOpacity={1} style={{ stopColor: "var(--ds-color-white)" }} /> <stop offset="1" stopOpacity={0} style={{ stopColor: "var(--ds-color-white)" }} /> </radialGradient> <radialGradient id={`${id}-patch-out`}> <stop offset={PATCH_CORE} stopOpacity={1} style={{ stopColor: "var(--ds-color-black)" }} /> <stop offset="1" stopOpacity={0} style={{ stopColor: "var(--ds-color-black)" }} /> </radialGradient> <mask id={`${id}-inside`} maskUnits="userSpaceOnUse" x="-1000" y="-1000" width="2000" height="2000"> <circle ref={(element) => { if (element) patchRefs.current[0] = element; }} r="0" fill={`url(#${id}-patch-in)`} /> </mask> <mask id={`${id}-outside`} maskUnits="userSpaceOnUse" x="-1000" y="-1000" width="2000" height="2000"> <rect x="-1000" y="-1000" width="2000" height="2000" style={{ fill: "var(--ds-color-white)" }} /> <circle ref={(element) => { if (element) patchRefs.current[1] = element; }} r="0" fill={`url(#${id}-patch-out)`} /> </mask> </defs> <text ref={wordRef} aria-hidden visibility="hidden" x={VIEW_BOX.width / 2} y={BASELINE_Y} textAnchor="middle" fontSize={VIEW_BOX.height} className="tracking-tighter" style={{ fontFamily: "var(--ds-font-sans)", fontWeight: "var(--ds-font-weight-medium)" }} > {outlined} {solid} </text> {dots && ( <g ref={inkRef}> <g aria-hidden mask={`url(#${id}-outside)`}> <DotLayerPaths layer={dots.resting} ringStroke={dots.ringStroke} /> </g> <g aria-hidden mask={`url(#${id}-inside)`}> <DotLayerPaths layer={dots.swapped} ringStroke={dots.ringStroke} /> </g> </g> )} </svg> {/* Its own layer, above the origin element, so the cursor rests on it rather than sliding under it. */} <svg aria-hidden="true" viewBox={viewBox} preserveAspectRatio="xMidYMid meet" className="pointer-events-none absolute inset-0 z-20 size-full"> <LaunchingCursor id={`${id}-cursor`} offset={hasCursorLaunched ? null : cursorLaunchOffset} isHidden={!hasCursorLaunched && !cursorLaunchOffset} isPressing={isCursorPressing} /> </svg> </> );}components/visuals/footer-watermark-dots.ts
// Roughly one dot in six flips to the other state, the speckle the brand's dot shader gives its imagery.const SPECKLE_RATE = 0.16;function isSpeckled(column: number, row: number) { const hash = Math.sin(column * 12.9898 + row * 78.233) * 43758.5453; return hash - Math.floor(hash) < SPECKLE_RATE;}/** Mostly rings or mostly solid dots, with the shader speckle flipping a few. */const isFilledAmongRings = isSpeckled;const isFilledAmongSolids = (column: number, row: number) => !isSpeckled(column, row);const SAMPLES_PER_CELL = 6;const MIN_COVERAGE = 0.3;const RADIUS_RATIO = 0.37;const RING_STROKE_RATIO = 0.17;// A ring this much smaller than its cell closes up into a smudge, so it is drawn as a solid speck instead.const SMALLEST_RING_SCALE = 0.8;const SPECK_SCALE = 0.55;export interface DotLayer { rings: string; solids: string;}export interface WatermarkDots { ringStroke: number; resting: DotLayer; swapped: DotLayer;}export interface WatermarkPart { firstCharacter: number; characterCount: number; isMostlySolid: boolean;}interface InkCell { column: number; row: number; /** Share of the cell the letter covers, which sets how large its dot is. */ coverage: number;}interface Size { width: number; height: number;}const rounded = (value: number) => Math.round(value * 1000) / 1000;const circlePath = (x: number, y: number, radius: number) => `M${rounded(x - radius)} ${rounded(y)}a${rounded(radius)} ${rounded(radius)} 0 1 0 ${rounded(radius * 2)} 0a${rounded(radius)} ${rounded(radius)} 0 1 0 ${rounded(-radius * 2)} 0`;// Characters are placed where the SVG laid them out, so the dots follow its kerning and tracking rather than the canvas's.function inkCells(text: SVGTextElement, { firstCharacter, characterCount }: WatermarkPart, pitch: number, { width, height }: Size): InkCell[] { const columns = Math.ceil(width / pitch); const rows = Math.ceil(height / pitch); const canvas = document.createElement("canvas"); canvas.width = columns * SAMPLES_PER_CELL; canvas.height = rows * SAMPLES_PER_CELL; const context = canvas.getContext("2d", { willReadFrequently: true }); if (!context) return []; const samplesPerUnit = SAMPLES_PER_CELL / pitch; const style = getComputedStyle(text); context.font = `${style.fontStyle} ${style.fontWeight} ${parseFloat(style.fontSize) * samplesPerUnit}px ${style.fontFamily}`; const characters = text.textContent ?? ""; for (let index = firstCharacter; index < firstCharacter + characterCount; index++) { const { x, y } = text.getStartPositionOfChar(index); context.fillText(characters[index], x * samplesPerUnit, y * samplesPerUnit); } const { data } = context.getImageData(0, 0, canvas.width, canvas.height); const samplesInCell = SAMPLES_PER_CELL * SAMPLES_PER_CELL; const cells: InkCell[] = []; for (let row = 0; row < rows; row++) { for (let column = 0; column < columns; column++) { let alpha = 0; for (let sampleRow = 0; sampleRow < SAMPLES_PER_CELL; sampleRow++) { const rowStart = ((row * SAMPLES_PER_CELL + sampleRow) * canvas.width + column * SAMPLES_PER_CELL) * 4; for (let sampleColumn = 0; sampleColumn < SAMPLES_PER_CELL; sampleColumn++) alpha += data[rowStart + sampleColumn * 4 + 3]; } const coverage = alpha / (255 * samplesInCell); if (coverage >= MIN_COVERAGE) cells.push({ column, row, coverage }); } } return cells;}/** * The wordmark rebuilt from the brand's circles: each part mostly rings or mostly solid dots, sized by how much of the * letter falls in the cell. The swapped layer trades the two, for the patch under the pointer. */export function sampleWatermarkDots(text: SVGTextElement, parts: WatermarkPart[], pitch: number, size: Size): WatermarkDots { const ringStroke = rounded(pitch * RING_STROKE_RATIO); const resting = { rings: "", solids: "" }; const swapped = { rings: "", solids: "" }; const place = (layer: DotLayer, { column, row, coverage }: InkCell, isFilled: boolean) => { const x = (column + 0.5) * pitch; const y = (row + 0.5) * pitch; const scale = Math.sqrt(Math.min(1, coverage)); const radius = pitch * RADIUS_RATIO * scale; if (isFilled) layer.solids += circlePath(x, y, radius); else if (scale < SMALLEST_RING_SCALE) layer.solids += circlePath(x, y, radius * SPECK_SCALE); else layer.rings += circlePath(x, y, radius - ringStroke / 2); }; for (const part of parts) { const isFilledAtRest = part.isMostlySolid ? isFilledAmongSolids : isFilledAmongRings; for (const cell of inkCells(text, part, pitch, size)) { const isFilled = isFilledAtRest(cell.column, cell.row); place(resting, cell, isFilled); place(swapped, cell, !isFilled); } } return { ringStroke, resting, swapped };}Reference: src/components/sections/shared/footer-watermark.tsx; src/components/sections/shared/footer-watermark-dots.ts; src/components/sections/shared/CursorClickIcon.tsx.