Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions site/src/components/AnimatedNumber/AnimatedNumber.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useState } from "react";
import { AnimatedNumber } from "./AnimatedNumber";

const meta: Meta<typeof AnimatedNumber> = {
title: "components/AnimatedNumber",
component: AnimatedNumber,
};

export default meta;
type Story = StoryObj<typeof AnimatedNumber>;

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 (
<div className="flex items-center gap-4">
<span className="text-2xl font-semibold text-content-primary">
<AnimatedNumber value={`${value}%`} />
</span>
<div className="flex gap-2">
<button
type="button"
className="rounded border border-solid border-border px-3 py-1 text-sm bg-surface-primary text-content-primary"
onClick={() => setValue(Math.floor(Math.random() * 100))}
>
Randomize
</button>
</div>
</div>
);
},
};

export const Counter: Story = {
render: function CounterStory() {
const [count, setCount] = useState(0);

return (
<div className="flex items-center gap-4">
<span className="text-3xl font-semibold tabular-nums text-content-primary">
<AnimatedNumber value={count.toLocaleString("en-US")} />
</span>
<div className="flex gap-2">
<button
type="button"
className="rounded border border-solid border-border px-3 py-1 text-sm bg-surface-primary text-content-primary"
onClick={() => setCount((c) => c + 1)}
>
+1
</button>
<button
type="button"
className="rounded border border-solid border-border px-3 py-1 text-sm bg-surface-primary text-content-primary"
onClick={() => setCount((c) => c + 100)}
>
+100
</button>
<button
type="button"
className="rounded border border-solid border-border px-3 py-1 text-sm bg-surface-primary text-content-primary"
onClick={() => setCount(0)}
>
Reset
</button>
</div>
</div>
);
},
};
57 changes: 57 additions & 0 deletions site/src/components/AnimatedNumber/AnimatedNumber.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { type FC, type HTMLAttributes, useRef } from "react";
import { cn } from "#/utils/cn";

interface AnimatedNumberProps extends HTMLAttributes<HTMLSpanElement> {
/** 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<AnimatedNumberProps> = ({
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<number[]>([]);

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 (
<span
className={cn("inline-flex items-baseline tabular-nums", className)}
{...props}
>
{chars.map((char, i) => (
<span
key={`${i}-${gens.current[i]}`}
className="inline-block animate-in fade-in-0 slide-in-from-bottom-1 fill-mode-backwards duration-500 [animation-timing-function:cubic-bezier(0.34,1.45,0.64,1)]"
>
{char}
</span>
))}
</span>
);
};
3 changes: 2 additions & 1 deletion site/src/pages/AgentsPage/components/UsageIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -253,7 +254,7 @@ const UsageSection: FC<{ section: UsageSectionData }> = ({ section }) => {
<span
className={cn("shrink-0 text-xs", getTextClassName(section.severity))}
>
{roundedPercent}%
<AnimatedNumber value={`${roundedPercent}%`} />
</span>
</div>

Expand Down
Loading