From 09e14a5f4f0d6881ce4becd06f9e92c8c3904ec0 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Thu, 4 Jun 2026 12:51:33 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A4=96=20feat(site):=20add=20Animated?= =?UTF-8?q?Number=20component=20with=20per-digit=20pop-in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each character slides up and fades in with a bouncy overshoot easing. The last two characters stagger behind the leading ones. The animation replays on value change via React key remounting. Integrates into UsageIndicator percentage display so the usage ring numbers animate alongside the stroke. --- .../AnimatedNumber/AnimatedNumber.stories.tsx | 78 +++++++++++++++++++ .../AnimatedNumber/AnimatedNumber.tsx | 51 ++++++++++++ .../AgentsPage/components/UsageIndicator.tsx | 3 +- 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 site/src/components/AnimatedNumber/AnimatedNumber.stories.tsx create mode 100644 site/src/components/AnimatedNumber/AnimatedNumber.tsx diff --git a/site/src/components/AnimatedNumber/AnimatedNumber.stories.tsx b/site/src/components/AnimatedNumber/AnimatedNumber.stories.tsx new file mode 100644 index 0000000000000..4d3ccc2b38975 --- /dev/null +++ b/site/src/components/AnimatedNumber/AnimatedNumber.stories.tsx @@ -0,0 +1,78 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; +import { AnimatedNumber } from "./AnimatedNumber"; + +const meta: Meta = { + title: "components/AnimatedNumber", + component: AnimatedNumber, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + value: 75, + className: "text-2xl font-semibold text-content-primary", + }, +}; + +export const Percentage: Story = { + render: function PercentageStory() { + const [value, setValue] = useState(42); + + return ( +
+ + + +
+ +
+
+ ); + }, +}; + +export const Counter: Story = { + render: function CounterStory() { + const [count, setCount] = useState(0); + + return ( +
+ + + +
+ + + +
+
+ ); + }, +}; diff --git a/site/src/components/AnimatedNumber/AnimatedNumber.tsx b/site/src/components/AnimatedNumber/AnimatedNumber.tsx new file mode 100644 index 0000000000000..a77916791bd4c --- /dev/null +++ b/site/src/components/AnimatedNumber/AnimatedNumber.tsx @@ -0,0 +1,51 @@ +import type { FC, HTMLAttributes } from "react"; +import { cn } from "#/utils/cn"; + +interface AnimatedNumberProps extends HTMLAttributes { + /** The numeric value (or formatted string) to display. */ + value: number | string; +} + +/** + * Renders each character of a number as an individually animated span. + * Characters slide up and fade in with a bouncy overshoot easing; the + * last two characters stagger behind the leading ones so trailing + * digits feel alive without looking chaotic. + * + * The animation replays whenever `value` changes because each character + * span is keyed on the stringified value, forcing React to remount them. + */ +export const AnimatedNumber: FC = ({ + value, + className, + ...props +}) => { + const str = String(value); + const chars = str.split(""); + const len = chars.length; + + return ( + + {chars.map((char, i) => { + // Leading characters enter together; the last two trail + // behind by 1x and 2x the stagger interval (70ms). + const fromEnd = len - 1 - i; + const delay = fromEnd < 2 ? (2 - fromEnd) * 70 : 0; + + return ( + 0 ? { animationDelay: `${delay}ms` } : undefined} + > + {char} + + ); + })} + + ); +}; diff --git a/site/src/pages/AgentsPage/components/UsageIndicator.tsx b/site/src/pages/AgentsPage/components/UsageIndicator.tsx index 48b53aea3501a..4578382ba7998 100644 --- a/site/src/pages/AgentsPage/components/UsageIndicator.tsx +++ b/site/src/pages/AgentsPage/components/UsageIndicator.tsx @@ -6,6 +6,7 @@ import { Link } from "react-router"; import { chatUsageLimitStatus } from "#/api/queries/chats"; import { workspaceQuota } from "#/api/queries/workspaceQuota"; import { workspaces } from "#/api/queries/workspaces"; +import { AnimatedNumber } from "#/components/AnimatedNumber/AnimatedNumber"; import { DropdownMenu, DropdownMenuContent, @@ -253,7 +254,7 @@ const UsageSection: FC<{ section: UsageSectionData }> = ({ section }) => { - {roundedPercent}% + From bfe37d2f58f226241bacf78534e18c3bede98e86 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Thu, 4 Jun 2026 13:01:27 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A4=96=20fix(site):=20only=20animate?= =?UTF-8?q?=20digits=20that=20changed,=20add=20tabular-nums?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track a generation counter per character position. Only the positions where the character actually changed get a new React key, so only those spans remount and replay the enter animation. Stable digits stay put. Add tabular-nums to the wrapper so digit widths are fixed and the layout does not shift as values change. --- .../AnimatedNumber/AnimatedNumber.tsx | 62 ++++++++++--------- 1 file changed, 34 insertions(+), 28 deletions(-) diff --git a/site/src/components/AnimatedNumber/AnimatedNumber.tsx b/site/src/components/AnimatedNumber/AnimatedNumber.tsx index a77916791bd4c..6ae83dc972174 100644 --- a/site/src/components/AnimatedNumber/AnimatedNumber.tsx +++ b/site/src/components/AnimatedNumber/AnimatedNumber.tsx @@ -1,4 +1,4 @@ -import type { FC, HTMLAttributes } from "react"; +import { type FC, type HTMLAttributes, useRef } from "react"; import { cn } from "#/utils/cn"; interface AnimatedNumberProps extends HTMLAttributes { @@ -8,12 +8,12 @@ interface AnimatedNumberProps extends HTMLAttributes { /** * Renders each character of a number as an individually animated span. - * Characters slide up and fade in with a bouncy overshoot easing; the - * last two characters stagger behind the leading ones so trailing - * digits feel alive without looking chaotic. + * Only characters that actually changed since the last render animate; + * stable digits stay put. Changed characters slide up and fade in with + * a bouncy overshoot easing. * - * The animation replays whenever `value` changes because each character - * span is keyed on the stringified value, forcing React to remount them. + * Uses tabular-nums so digit widths are fixed and the layout does not + * shift as values change. */ export const AnimatedNumber: FC = ({ value, @@ -22,30 +22,36 @@ export const AnimatedNumber: FC = ({ }) => { const str = String(value); const chars = str.split(""); - const len = chars.length; - return ( - - {chars.map((char, i) => { - // Leading characters enter together; the last two trail - // behind by 1x and 2x the stagger interval (70ms). - const fromEnd = len - 1 - i; - const delay = fromEnd < 2 ? (2 - fromEnd) * 70 : 0; + // Track a generation counter per character position. When the + // character at a position changes the counter bumps, giving + // that span a new React key so it remounts and replays its + // enter animation. Stable characters keep the same key. + const prevStr = useRef(""); + const gens = useRef([]); + + if (str !== prevStr.current) { + const prev = prevStr.current.split(""); + gens.current = chars.map((char, i) => { + const g = gens.current[i] ?? 0; + return char !== prev[i] ? g + 1 : g; + }); + prevStr.current = str; + } - return ( - 0 ? { animationDelay: `${delay}ms` } : undefined} - > - {char} - - ); - })} + return ( + + {chars.map((char, i) => ( + + {char} + + ))} ); };