googleGeminiEffect
A Backgrounds block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call googleGeminiEffect() with no arguments for a working demo, or edit the code below live.
Implementation notes
Ribbon shapes are generated procedurally at build time (Catmull-Rom spline through sine-perturbed anchor points, converted to cubic Bezier segments) instead of hand-authored path 'd' data, per the task's clean-room instruction not to copy path data verbatim. Arc length for those generated ribbons is computed analytically by sampling the Bezier segments, so the default demo's stroke-draw animation is deterministic even under jsdom (no SVGPathElement.getTotalLength() dependency). Callers who instead supply their own custom d string get length via getTotalLength() at mount time (guarded, falls back to a width-based heuristic when unavailable, e.g. headless/jsdom runtimes) since arbitrary path grammar can't be measured analytically. Scroll-progress is tracked via a scroll/resize listener measuring the section's own scroll-through fraction of the viewport (rAF-lerped for smoothness), remapped per-path through staggered [start,end] sub-ranges so ribbons finish drawing at different moments; per-path progress overrides (plain number or State<number>) fully bypass this internal tracking, matching the spec's 'parent controls it directly' requirement. Colors cycle through Domphy theme color roles (info/primary/secondary/error/warning) rather than literal Gemini hex values, since raw hex/rgb is forbidden by the design system.
Status: ported · Reference: Aceternity UI original
// Aceternity UI "Google Gemini Effect" — clean-room reimplementation from the
// public behavior/visual spec only (no upstream source viewed or copied). A
// hero-section centerpiece: a cluster of wavy, multi-colored SVG ribbons that
// "draw themselves in" as the section scrolls through the viewport, evoking
// the Google Gemini brand animation.
//
// Classic SVG stroke-draw technique: every ribbon gets `stroke-dasharray`
// equal to its own total length and a `stroke-dashoffset` that interpolates
// from "fully hidden" (offset = length) to "fully shown" (offset = 0) as a
// reactive draw-progress value changes — the browser tweens the dash offset
// continuously, no per-frame canvas work needed.
//
// Path geometry is generated analytically (Catmull-Rom spline through a
// handful of sine-perturbed anchor points, converted to cubic Bezier
// segments) rather than hand-authored `d` strings, so both the path data and
// its exact arc length are computed with plain math at build time — no DOM
// measurement (`SVGPathElement.getTotalLength()`), which keeps the default
// demo deterministic under jsdom (no real layout/geometry engine) as well as
// in a real browser. Callers who supply their own custom `d` string instead
// fall back to `getTotalLength()` at mount time (guarded, with a heuristic
// estimate when unavailable) since arbitrary path data can't be measured
// analytically without parsing full SVG path grammar.
//
// Per-path draw progress is fully caller-controllable: pass `progress[index]`
// (a plain number or a `State<number>`) to drive that ribbon directly from
// the host page's own scroll/IntersectionObserver logic. Any path without an
// explicit override instead tracks the section's own scroll-through
// fraction internally (a `scroll`/`resize` listener wired in `_onMount`,
// rAF-lerped toward the raw target the same way this package's
// `scrollProgress` smooths its fill), remapped through a small per-path
// `[start, end]` sub-range of that fraction so ribbons complete their draw
// at staggered moments instead of all finishing in lockstep.
import type { DomphyElement, ElementNode, Listener, StyleObject } from "@domphy/core";
import { hashString, toState, type ValueOrState } from "@domphy/core";
import { heading, paragraph } from "@domphy/ui";
import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme";
interface Point {
x: number;
y: number;
}
interface CubicSegment {
p0: Point;
c1: Point;
c2: Point;
p1: Point;
}
export interface GoogleGeminiPathSpec {
/** SVG path `d` data. When omitted, a generated wavy ribbon is used and its
* arc length is computed analytically (no DOM measurement needed). */
d?: string;
/** Theme color family for this ribbon's stroke. Cycles through a
* Gemini-like blue/purple/red/yellow set across the default paths. */
color?: ThemeColor;
/** Stroke width, in SVG user units. Defaults to `3`. */
strokeWidth?: number;
/** `[start, end]` fraction of the overall scroll-through range this ribbon
* completes its draw within. Only used when no explicit `progress` entry
* is supplied for this path's index. Defaults to a staggered per-index range. */
scrollRange?: [number, number];
}
export interface GoogleGeminiEffectProps {
/** Hero heading above the artwork. Defaults to `"Build with Aceternity UI"`. */
title?: string;
/** Supporting description line below the heading. */
description?: string;
/** Per-ribbon overrides (color/strokeWidth/custom d/scrollRange). Defaults
* to 5 generated ribbons in a Gemini-like palette. */
paths?: GoogleGeminiPathSpec[];
/** Per-path draw progress, 0–1, one entry per path (matched by index).
* Accepts a plain number or a `State<number>`. Supplying an entry fully
* hands control of that ribbon to the caller — internal scroll-tracking
* is skipped for that index. */
progress?: ValueOrState<number>[];
/** viewBox width, in SVG user units. Defaults to `1440`. */
width?: number;
/** viewBox height, in SVG user units. Defaults to `320`. */
height?: number;
/** Renders a soft blurred glow duplicate behind each ribbon. Defaults to `true`. */
glow?: boolean;
/** Passthrough style merged onto the outer section. */
style?: StyleObject;
}
const DEFAULT_PATH_PRESETS: Array<{
amplitude: number;
frequency: number;
phase: number;
verticalOffset: number;
color: ThemeColor;
}> = [
{ amplitude: 46, frequency: 2.4, phase: 0, verticalOffset: -72, color: "info" },
{ amplitude: 60, frequency: 2.1, phase: 0.6, verticalOffset: -32, color: "primary" },
{ amplitude: 38, frequency: 2.7, phase: 1.3, verticalOffset: 0, color: "secondary" },
{ amplitude: 54, frequency: 2.2, phase: 2.0, verticalOffset: 34, color: "error" },
{ amplitude: 42, frequency: 2.5, phase: 2.7, verticalOffset: 72, color: "warning" },
];
const ANCHOR_COUNT = 7;
const LENGTH_SAMPLE_STEPS = 24;
function clampToUnitRange(value: number): number {
if (Number.isNaN(value)) return 0;
return Math.min(1, Math.max(0, value));
}
/** Catmull-Rom spline through `points`, converted to cubic Bezier segments (clamped at the ends). */
function catmullRomToBezierSegments(points: Point[]): CubicSegment[] {
const segments: CubicSegment[] = [];
for (let index = 0; index < points.length - 1; index += 1) {
const previous = points[Math.max(0, index - 1)];
const start = points[index];
const end = points[index + 1];
const next = points[Math.min(points.length - 1, index + 2)];
const c1: Point = { x: start.x + (end.x - previous.x) / 6, y: start.y + (end.y - previous.y) / 6 };
const c2: Point = { x: end.x - (next.x - start.x) / 6, y: end.y - (next.y - start.y) / 6 };
segments.push({ p0: start, c1, c2, p1: end });
}
return segments;
}
/** Arc length of one cubic Bezier segment, approximated by sampling and summing chord lengths. */
function sampleCubicSegmentLength(segment: CubicSegment): number {
const { p0, c1, c2, p1 } = segment;
let length = 0;
let previous = p0;
for (let step = 1; step <= LENGTH_SAMPLE_STEPS; step += 1) {
const t = step / LENGTH_SAMPLE_STEPS;
const inverseT = 1 - t;
const x = inverseT ** 3 * p0.x + 3 * inverseT ** 2 * t * c1.x + 3 * inverseT * t ** 2 * c2.x + t ** 3 * p1.x;
const y = inverseT ** 3 * p0.y + 3 * inverseT ** 2 * t * c1.y + 3 * inverseT * t ** 2 * c2.y + t ** 3 * p1.y;
length += Math.hypot(x - previous.x, y - previous.y);
previous = { x, y };
}
return length;
}
/** Generates one horizontal wavy ribbon spanning `width`, plus its exact analytical arc length. */
function buildWaveRibbon(
width: number,
height: number,
amplitude: number,
frequency: number,
phase: number,
verticalOffset: number,
): { d: string; length: number } {
const points: Point[] = [];
for (let index = 0; index < ANCHOR_COUNT; index += 1) {
const t = index / (ANCHOR_COUNT - 1);
points.push({
x: t * width,
y: height / 2 + verticalOffset + Math.sin(t * Math.PI * frequency + phase) * amplitude,
});
}
const segments = catmullRomToBezierSegments(points);
let d = `M ${points[0].x.toFixed(2)},${points[0].y.toFixed(2)}`;
let length = 0;
for (const segment of segments) {
d += ` C ${segment.c1.x.toFixed(2)},${segment.c1.y.toFixed(2)} ${segment.c2.x.toFixed(2)},${segment.c2.y.toFixed(2)} ${segment.p1.x.toFixed(2)},${segment.p1.y.toFixed(2)}`;
length += sampleCubicSegmentLength(segment);
}
return { d, length };
}
/** Staggered `[start, end]` scroll sub-range for a path at `index` of `count` — earlier indices start sooner. */
function defaultScrollRange(index: number, count: number): [number, number] {
const stagger = count > 1 ? index / (count - 1) : 0;
const start = stagger * 0.3;
const end = Math.min(1, start + 0.75);
return [start, end];
}
/** Current "how far has this section scrolled through the viewport" fraction, 0 at first appearance, 1 once fully passed. */
function computeSectionScrollFraction(element: HTMLElement): number {
const rect = element.getBoundingClientRect();
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 1;
const totalTravel = rect.height + viewportHeight;
const traveled = viewportHeight - rect.top;
return clampToUnitRange(totalTravel > 0 ? traveled / totalTravel : 0);
}
let googleGeminiEffectInstanceCounter = 0;
/**
* A hero-section centerpiece: a cluster of wavy, multi-colored SVG ribbons
* that progressively draw themselves in as the section scrolls through the
* viewport. Call with no arguments for a working demo — 5 generated ribbons
* that auto-track the page's own scroll position.
*/
function googleGeminiEffect(props: GoogleGeminiEffectProps = {}): DomphyElement<"section"> {
const instanceId = ++googleGeminiEffectInstanceCounter;
const title = props.title ?? "Build with Aceternity UI";
const description =
props.description ?? "Scroll through this section to watch the ribbons trace themselves in.";
const width = Math.max(1, props.width ?? 1440);
const height = Math.max(1, props.height ?? 320);
const glow = props.glow ?? true;
const pathSpecs: GoogleGeminiPathSpec[] =
props.paths && props.paths.length > 0
? props.paths
: DEFAULT_PATH_PRESETS.map((preset) => ({ color: preset.color }));
// One State<number> per path — either mirrors an explicit `progress[index]`
// override, or is driven internally by the scroll-tracking loop below.
const lengthStates = pathSpecs.map((spec, index) => {
if (spec.d) {
// Custom path data: length is unknown until we can measure the real
// DOM node (or estimate). Seeded with a generous heuristic so the
// ribbon still renders sensibly before that first measurement lands.
return toState(width * 1.4, `google-gemini-length-${instanceId}-${index}`);
}
const preset = DEFAULT_PATH_PRESETS[index % DEFAULT_PATH_PRESETS.length];
const built = buildWaveRibbon(width, height, preset.amplitude, preset.frequency, preset.phase, preset.verticalOffset);
return toState(built.length, `google-gemini-length-${instanceId}-${index}`);
});
const pathData: string[] = pathSpecs.map((spec, index) => {
if (spec.d) return spec.d;
const preset = DEFAULT_PATH_PRESETS[index % DEFAULT_PATH_PRESETS.length];
return buildWaveRibbon(width, height, preset.amplitude, preset.frequency, preset.phase, preset.verticalOffset).d;
});
const rawScrollFraction = toState(0, `google-gemini-scroll-${instanceId}`);
const needsInternalScrollTracking = pathSpecs.some((_spec, index) => props.progress?.[index] === undefined);
const progressGetters: Array<(listener: Listener) => number> = pathSpecs.map((spec, index) => {
const explicitProgress = props.progress?.[index];
if (explicitProgress !== undefined) {
const explicitState = toState(explicitProgress, `google-gemini-progress-${instanceId}-${index}`);
return (listener: Listener) => clampToUnitRange(explicitState.get(listener));
}
const [rangeStart, rangeEnd] = spec.scrollRange ?? defaultScrollRange(index, pathSpecs.length);
return (listener: Listener) => {
const raw = rawScrollFraction.get(listener);
if (rangeEnd <= rangeStart) return raw >= rangeEnd ? 1 : 0;
return clampToUnitRange((raw - rangeStart) / (rangeEnd - rangeStart));
};
});
const glowFilterId = `domphy-google-gemini-glow-${instanceId}`;
function ribbonStyle(index: number, color: ThemeColor, strokeWidth: number, extra?: StyleObject): StyleObject {
return {
fill: "none",
strokeLinecap: "round",
stroke: (listener: Listener) => themeColor(listener, "shift-9", color),
strokeWidth,
strokeDasharray: (listener: Listener) => {
const length = lengthStates[index].get(listener);
return `${length.toFixed(2)} ${length.toFixed(2)}`;
},
strokeDashoffset: (listener: Listener) => {
const length = lengthStates[index].get(listener);
const progress = progressGetters[index](listener);
return `${(length * (1 - progress)).toFixed(2)}`;
},
...(extra ?? {}),
} as StyleObject;
}
const ribbonElements: DomphyElement[] = [];
pathSpecs.forEach((spec, index) => {
const preset = DEFAULT_PATH_PRESETS[index % DEFAULT_PATH_PRESETS.length];
const color = spec.color ?? preset.color;
const strokeWidth = spec.strokeWidth ?? 3;
const d = pathData[index];
if (glow) {
ribbonElements.push({
path: null,
_key: `ribbon-glow-${instanceId}-${index}`,
d,
ariaHidden: "true",
// Decorative glow duplicate with no text of its own — exempt from the
// missing-color contract, matching ripple.ts's ring elements.
_doctorDisable: "missing-color",
style: ribbonStyle(index, color, strokeWidth * 3, {
opacity: 0.35,
filter: `url(#${glowFilterId})`,
}),
} as DomphyElement);
}
ribbonElements.push({
path: null,
_key: `ribbon-${instanceId}-${index}`,
d,
ariaHidden: "true",
_doctorDisable: "missing-color",
_onMount: (node: ElementNode) => {
if (!spec.d || typeof window === "undefined") return;
const pathElement = node.domElement as SVGPathElement | null;
if (!pathElement || typeof pathElement.getTotalLength !== "function") return;
try {
const measured = pathElement.getTotalLength();
if (measured > 0) lengthStates[index].set(measured);
} catch {
// getTotalLength() is unavailable in some headless/test runtimes
// (e.g. jsdom) — keep the seeded heuristic length in that case.
}
},
style: ribbonStyle(index, color, strokeWidth),
} as DomphyElement);
});
const glowDefs: DomphyElement[] = glow
? [
{
filter: [{ feGaussianBlur: null, stdDeviation: "8" } as DomphyElement],
id: glowFilterId,
x: "-30%",
y: "-30%",
width: "160%",
height: "160%",
} as DomphyElement,
]
: [];
const artworkSvg: DomphyElement<"svg"> = {
svg: [...(glowDefs.length ? [{ defs: glowDefs } as DomphyElement] : []), ...ribbonElements],
viewBox: `0 0 ${width} ${height}`,
preserveAspectRatio: "xMidYMid meet",
ariaHidden: "true",
style: { display: "block", width: "100%", height: "auto" } as StyleObject,
} as DomphyElement<"svg">;
const textBlock: DomphyElement<"div"> = {
div: [
{ h2: title, $: [heading()] } as DomphyElement,
{ p: description, $: [paragraph()] } as DomphyElement,
],
style: {
position: "relative",
zIndex: 1,
textAlign: "center",
maxWidth: themeSpacing(160),
marginInline: "auto",
marginBlockEnd: themeSpacing(10),
} as StyleObject,
} as DomphyElement<"div">;
return {
section: [
textBlock,
{
div: [artworkSvg],
style: { position: "relative", zIndex: 1 } as StyleObject,
} as DomphyElement<"div">,
],
dataTone: "shift-16",
_onMount: (node: ElementNode) => {
if (!needsInternalScrollTracking || typeof window === "undefined") return;
const sectionElement = node.domElement as HTMLElement | null;
if (!sectionElement) return;
let currentFraction = computeSectionScrollFraction(sectionElement);
let targetFraction = currentFraction;
let animating = false;
let rafHandle = 0;
rawScrollFraction.set(currentFraction);
const step = () => {
// Belt-and-suspenders stop condition: some hosts (e.g. a test harness
// that wipes the DOM directly instead of going through the framework's
// removal lifecycle) never fire the "Remove" hook below. Bailing here
// once the node is detached prevents the window scroll/resize
// listeners from resurrecting this loop forever.
if (!sectionElement.isConnected) return;
currentFraction += (targetFraction - currentFraction) * 0.2;
if (Math.abs(targetFraction - currentFraction) < 0.0008) {
currentFraction = targetFraction;
rawScrollFraction.set(currentFraction);
animating = false;
return;
}
rawScrollFraction.set(currentFraction);
rafHandle = window.requestAnimationFrame(step);
};
const handleScroll = () => {
targetFraction = computeSectionScrollFraction(sectionElement);
if (!animating) {
animating = true;
rafHandle = window.requestAnimationFrame(step);
}
};
window.addEventListener("scroll", handleScroll, { passive: true });
window.addEventListener("resize", handleScroll, { passive: true });
node.addHook("Remove", () => {
window.removeEventListener("scroll", handleScroll);
window.removeEventListener("resize", handleScroll);
if (rafHandle) window.cancelAnimationFrame(rafHandle);
});
},
style: {
position: "relative",
overflow: "hidden",
borderRadius: themeSpacing(4),
padding: themeSpacing(10),
backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
color: (listener: Listener) => themeColor(listener, "shift-9"),
...(props.style ?? {}),
} as StyleObject,
};
}
export { googleGeminiEffect };