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..6ae83dc972174 --- /dev/null +++ b/site/src/components/AnimatedNumber/AnimatedNumber.tsx @@ -0,0 +1,57 @@ +import { type FC, type HTMLAttributes, useRef } 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. + * 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. + * + * Uses tabular-nums so digit widths are fixed and the layout does not + * shift as values change. + */ +export const AnimatedNumber: FC = ({ + value, + className, + ...props +}) => { + const str = String(value); + const chars = str.split(""); + + // 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 ( + + {chars.map((char, i) => ( + + {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}% +