Skip to content

Commit ad35b34

Browse files
committed
fix(webapp): resolve theme variables before framer-motion color interpolation
framer-motion can't parse var() strings, so animated colors (usage bar gradient, dashboard limit progress ring) snap instead of tweening. Adds a useThemeColor hook that resolves theme variables via getComputedStyle on mount, with dark-theme fallbacks for SSR.
1 parent 558f823 commit ad35b34

3 files changed

Lines changed: 29 additions & 11 deletions

File tree

apps/webapp/app/components/billing/FreePlanUsage.tsx

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,20 @@
11
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
22
import { Link } from "@remix-run/react";
33
import { motion, useMotionValue, useTransform } from "framer-motion";
4+
import { useThemeColor } from "~/hooks/useThemeColor";
45
import { cn } from "~/utils/cn";
56

67
export function FreePlanUsage({ to, percentage }: { to: string; percentage: number }) {
78
const cappedPercentage = Math.min(percentage, 1);
89
const widthProgress = useMotionValue(cappedPercentage * 100);
10+
// Resolved to concrete colors - framer-motion can't interpolate var() strings
11+
const successColor = useThemeColor("--color-success", "#28bf5c");
12+
const warningColor = useThemeColor("--color-warning", "#f59e0b");
13+
const errorColor = useThemeColor("--color-error", "#e11d48");
914
const color = useTransform(
1015
widthProgress,
1116
[0, 74, 75, 95, 100],
12-
[
13-
"var(--color-success)",
14-
"var(--color-success)",
15-
"var(--color-warning)",
16-
"var(--color-error)",
17-
"var(--color-error)",
18-
]
17+
[successColor, successColor, warningColor, errorColor, errorColor]
1918
);
2019

2120
const hasHitLimit = cappedPercentage >= 1;

apps/webapp/app/components/navigation/DashboardDialogs.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { Label } from "../primitives/Label";
2424
import { Paragraph } from "../primitives/Paragraph";
2525
import { TextLink } from "../primitives/TextLink";
2626
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
27+
import { useThemeColor } from "~/hooks/useThemeColor";
2728
import { v3BillingPath } from "~/utils/pathBuilder";
2829
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
2930

@@ -161,8 +162,6 @@ export function CreateDashboardPageButton({
161162

162163
const PROGRESS_RING_R = 27.5;
163164
const PROGRESS_RING_CIRCUMFERENCE = 2 * Math.PI * PROGRESS_RING_R;
164-
const PROGRESS_COLOR_SUCCESS = "var(--color-success)"; // mint-500 / success
165-
const PROGRESS_COLOR_ERROR = "var(--color-error)"; // rose-600 / error
166165

167166
function CreateDashboardUpgradeDialog({
168167
limits,
@@ -175,6 +174,11 @@ function CreateDashboardUpgradeDialog({
175174
isFreePlan: boolean;
176175
organization: { slug: string };
177176
}) {
177+
// Resolved to concrete colors - framer-motion can't interpolate var() strings.
178+
// Must be called before the early return below (rules of hooks).
179+
const progressColorSuccess = useThemeColor("--color-success", "#28bf5c");
180+
const progressColorError = useThemeColor("--color-error", "#e11d48");
181+
178182
if (isFreePlan) {
179183
return (
180184
<DialogContent>
@@ -223,11 +227,11 @@ function CreateDashboardUpgradeDialog({
223227
strokeLinecap="round"
224228
initial={{
225229
strokeDasharray: `0 ${PROGRESS_RING_CIRCUMFERENCE}`,
226-
stroke: PROGRESS_COLOR_SUCCESS,
230+
stroke: progressColorSuccess,
227231
}}
228232
animate={{
229233
strokeDasharray: `${filled} ${PROGRESS_RING_CIRCUMFERENCE}`,
230-
stroke: PROGRESS_COLOR_ERROR,
234+
stroke: progressColorError,
231235
}}
232236
transition={{ duration: 1.2, ease: "easeInOut" }}
233237
/>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { useState } from "react";
2+
3+
/**
4+
* Resolve a theme CSS variable to its concrete color once on mount.
5+
* framer-motion can't interpolate `var()` strings, so animated colors must be
6+
* resolved to real values first. The fallback is used during SSR and should
7+
* match the default dark theme (see tailwind.css).
8+
*/
9+
export function useThemeColor(variable: `--${string}`, fallback: string): string {
10+
const [color] = useState(() => {
11+
if (typeof document === "undefined") return fallback;
12+
return getComputedStyle(document.documentElement).getPropertyValue(variable).trim() || fallback;
13+
});
14+
return color;
15+
}

0 commit comments

Comments
 (0)