diff --git a/apps/start/src/components/charts/area-gradient-defs.tsx b/apps/start/src/components/charts/area-gradient-defs.tsx
index 8615ff3f3..958181cd4 100644
--- a/apps/start/src/components/charts/area-gradient-defs.tsx
+++ b/apps/start/src/components/charts/area-gradient-defs.tsx
@@ -47,11 +47,13 @@ export function AreaGradientDefs({
)}
@@ -62,7 +64,8 @@ export function AreaGradientDefs({
))}
@@ -75,7 +78,8 @@ export function AreaGradientDefs({
))}
diff --git a/apps/start/src/components/charts/area.tsx b/apps/start/src/components/charts/area.tsx
index f1f9e77d2..314aa7057 100644
--- a/apps/start/src/components/charts/area.tsx
+++ b/apps/start/src/components/charts/area.tsx
@@ -7,7 +7,9 @@ import { AreaClosed, LinePath } from "@visx/shape";
// biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type
type CurveFactory = any;
-import { useCallback, useId, useRef } from "react";
+import { useCallback, useRef } from "react";
+import { useSvgId } from "./use-svg-id";
+
import { AreaGradientDefs } from "./area-gradient-defs";
import { chartCssVars, useChartStable } from "./chart-context";
import { type FadeEdges, resolveFadeSides } from "./fade-edges";
@@ -100,8 +102,8 @@ export function Area({
const pathMetricsKey = `${renderData.length}:${innerWidth}:${dashFromIndex}:${showLine}`;
const { pathLength, pathD } = usePathStrokeMetrics(pathRef, pathMetricsKey);
- // Unique IDs for this area
- const uniqueId = useId();
+ // Unique IDs for this area (Safari-safe — raw useId breaks url(#…) paints)
+ const uniqueId = useSvgId("area");
const gradientId = `area-gradient-${dataKey}-${uniqueId}`;
const strokeGradientId = `area-stroke-gradient-${dataKey}-${uniqueId}`;
const edgeMaskId = `area-edge-mask-${dataKey}-${uniqueId}`;
diff --git a/apps/start/src/components/charts/bar.tsx b/apps/start/src/components/charts/bar.tsx
index 784f2416f..ca5c62366 100644
--- a/apps/start/src/components/charts/bar.tsx
+++ b/apps/start/src/components/charts/bar.tsx
@@ -3,7 +3,8 @@
import type { scaleBand } from "@visx/scale";
import type { Transition } from "motion/react";
import { motion } from "motion/react";
-import { memo, useId, useMemo } from "react";
+import { memo, useMemo } from "react";
+import { useSvgId } from "./use-svg-id";
import { chartCssVars, useChart, useChartStable } from "./chart-context";
import { transitionWithDelay } from "./motion-utils";
@@ -163,7 +164,7 @@ const BarInner = memo(function BarInner({
const staggerSpread = totalAnimDuration * 0.4; // 40% of time for stagger spread
const calculatedStaggerDelay =
staggerDelay ?? (data.length > 1 ? staggerSpread / 1000 / data.length : 0);
- const uniqueId = useId();
+ const uniqueId = useSvgId("bar");
const isHorizontal = orientation === "horizontal";
diff --git a/apps/start/src/components/charts/chart-reveal-clip.tsx b/apps/start/src/components/charts/chart-reveal-clip.tsx
index 3a8591a0c..2b807155d 100644
--- a/apps/start/src/components/charts/chart-reveal-clip.tsx
+++ b/apps/start/src/components/charts/chart-reveal-clip.tsx
@@ -2,6 +2,7 @@
import type { Transition } from "motion/react";
import { motion } from "motion/react";
+import { useEffect, useState } from "react";
import { clipRevealTransition } from "./animation";
export interface ChartRevealClipProps {
@@ -15,6 +16,15 @@ export interface ChartRevealClipProps {
padding?: number;
}
+function isSafariSvgClipBug(): boolean {
+ if (typeof navigator === "undefined") {
+ return false;
+ }
+ // Motion animates SVG `width` via CSS on Safari, which clipPath ignores —
+ // charts stay clipped to 0. Skip the motion path there.
+ return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
+}
+
/**
* Left-to-right clip reveal for cartesian series.
* Grows clip rect width from 0 → full (true LTR; scaleX is avoided — it reveals from center).
@@ -30,6 +40,24 @@ export function ChartRevealClip({
const transition = clipRevealTransition(enterTransition);
const paddedWidth = Math.max(0, targetWidth + padding * 2);
const paddedHeight = height + padding * 2;
+ const [safariSafe, setSafariSafe] = useState(false);
+
+ useEffect(() => {
+ setSafariSafe(isSafariSvgClipBug());
+ }, []);
+
+ if (safariSafe) {
+ return (
+
+
+
+ );
+ }
return (
diff --git a/apps/start/src/components/charts/dash-tail-stroke.tsx b/apps/start/src/components/charts/dash-tail-stroke.tsx
index becbca5fa..1c643205a 100644
--- a/apps/start/src/components/charts/dash-tail-stroke.tsx
+++ b/apps/start/src/components/charts/dash-tail-stroke.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useId } from "react";
+import { useSvgId } from "./use-svg-id";
export interface DashTailStrokeProps {
/** SVG path `d` for the full series (single curved path). */
@@ -30,7 +30,7 @@ export function DashTailStroke({
strokeWidth,
dashArray,
}: DashTailStrokeProps) {
- const clipPathId = useId().replace(/:/g, "");
+ const clipPathId = useSvgId("dash-tail");
if (!pathD || pathLength <= 0 || dashStartLength >= pathLength) {
return null;
diff --git a/apps/start/src/components/charts/grid.tsx b/apps/start/src/components/charts/grid.tsx
index 08bf38377..e75d7b0cd 100644
--- a/apps/start/src/components/charts/grid.tsx
+++ b/apps/start/src/components/charts/grid.tsx
@@ -1,7 +1,8 @@
"use client";
import { GridColumns, GridRows } from "@visx/grid";
-import { useId } from "react";
+import { useSvgId } from "./use-svg-id";
+
import { chartCssVars, useChartStable } from "./chart-context";
export interface GridProps {
@@ -68,7 +69,7 @@ export function Grid({
// For vertical grid lines in horizontal bar charts, use yScale (the value scale)
// For time-based charts, use xScale
const columnScale = isHorizontalBarChart ? yScale : xScale;
- const uniqueId = useId();
+ const uniqueId = useSvgId("grid");
// Horizontal fade mask (for grid rows - fades left/right)
const hMaskId = `grid-rows-fade-${uniqueId}`;
@@ -84,13 +85,10 @@ export function Grid({
{horizontal && fadeHorizontal && (
-
-
-
-
+
+
+
+
-
-
-
-
+
+
+
+
) => {
@@ -115,7 +116,8 @@ export function Line({
))}
diff --git a/apps/start/src/components/charts/sanitize-svg-id.test.ts b/apps/start/src/components/charts/sanitize-svg-id.test.ts
new file mode 100644
index 000000000..d5ad6124b
--- /dev/null
+++ b/apps/start/src/components/charts/sanitize-svg-id.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from "vitest";
+import { sanitizeSvgId } from "./sanitize-svg-id";
+
+describe("sanitizeSvgId", () => {
+ it("strips React useId colons so Safari can resolve url(#id)", () => {
+ expect(sanitizeSvgId(":r1:")).toBe("r1");
+ });
+
+ it("strips guillemet wrappers from older React 19 ids", () => {
+ expect(sanitizeSvgId("«r0»")).toBe("r0");
+ });
+
+ it("keeps underscore-style React 19.2 ids", () => {
+ expect(sanitizeSvgId("_r_0_")).toBe("_r_0_");
+ });
+
+ it("prefixes when the cleaned id would start with a digit", () => {
+ expect(sanitizeSvgId("123")).toBe("svg-123");
+ });
+});
diff --git a/apps/start/src/components/charts/sanitize-svg-id.ts b/apps/start/src/components/charts/sanitize-svg-id.ts
new file mode 100644
index 000000000..abb44f990
--- /dev/null
+++ b/apps/start/src/components/charts/sanitize-svg-id.ts
@@ -0,0 +1,12 @@
+/**
+ * React `useId()` historically produced ids like `:r1:` / `«r0»` that are
+ * invalid inside SVG `url(#…)` references on Safari. Strip anything that
+ * is not a safe SVG/XML Name character so fills, masks, and clipPaths resolve.
+ */
+export function sanitizeSvgId(id: string): string {
+ const cleaned = id.replace(/[^A-Za-z0-9_-]/g, "");
+ // SVG ids must not start with a digit.
+ return cleaned.length === 0 || /^[0-9]/.test(cleaned)
+ ? `svg-${cleaned || "id"}`
+ : cleaned;
+}
diff --git a/apps/start/src/components/charts/tooltip/tooltip-indicator.tsx b/apps/start/src/components/charts/tooltip/tooltip-indicator.tsx
index 446c815ab..776fabba6 100644
--- a/apps/start/src/components/charts/tooltip/tooltip-indicator.tsx
+++ b/apps/start/src/components/charts/tooltip/tooltip-indicator.tsx
@@ -107,14 +107,16 @@ function TooltipIndicatorInner({
-
-
-
+
+
+
diff --git a/apps/start/src/components/charts/use-svg-id.ts b/apps/start/src/components/charts/use-svg-id.ts
new file mode 100644
index 000000000..41e35a4f4
--- /dev/null
+++ b/apps/start/src/components/charts/use-svg-id.ts
@@ -0,0 +1,12 @@
+"use client";
+
+import { useId } from "react";
+import { sanitizeSvgId } from "./sanitize-svg-id";
+
+/**
+ * Stable, Safari-safe id for SVG paint servers (`url(#id)`, mask, clipPath).
+ */
+export function useSvgId(prefix?: string): string {
+ const raw = sanitizeSvgId(useId());
+ return prefix ? `${sanitizeSvgId(prefix)}-${raw}` : raw;
+}
diff --git a/apps/start/src/components/overview/overview-metrics.tsx b/apps/start/src/components/overview/overview-metrics.tsx
index 8449d9837..be6bf272c 100644
--- a/apps/start/src/components/overview/overview-metrics.tsx
+++ b/apps/start/src/components/overview/overview-metrics.tsx
@@ -34,7 +34,7 @@ interface OverviewMetricsProps {
const REVENUE_COLOR = 'oklch(0.68 0.11 158)';
// Bump alpha (0.2 → 0.4) so the bklit hover highlight — which inherits the
// line's stroke color — has enough body to be perceptible on hover.
-const PREV_LINE_COLOR = 'oklch(from var(--foreground) l c h / 0.4)';
+const PREV_LINE_COLOR = 'var(--chart-crosshair)';
const TITLES = [
{
diff --git a/apps/start/src/components/report-chart/area/chart.tsx b/apps/start/src/components/report-chart/area/chart.tsx
index 70adba4be..5919f18d8 100644
--- a/apps/start/src/components/report-chart/area/chart.tsx
+++ b/apps/start/src/components/report-chart/area/chart.tsx
@@ -226,7 +226,7 @@ export function Chart({ data }: Props) {