diff --git a/CLAUDE.md b/CLAUDE.md
index de61424..d734005 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -161,7 +161,9 @@ The aesthetic lives in `app/globals.css` — a retro arcade / CRT theme:
Generic shadcn/ui primitives carried over from the base: button, card, dialog,
select, tabs, toast/toaster, tooltip, badge, alert, progress, switch, skeleton,
-surface-card, page-container, section-title, empty-state, completion-ring, etc.
+surface-card, page-container, section-title, empty-state, etc. `animated-text`
+wraps the `slot-text` library (slot-machine roll for changing values, with a
+`prefers-reduced-motion` fallback) — used for the dashboard value figures.
### Key conventions
diff --git a/components/dashboard/inventory-value-panel.tsx b/components/dashboard/inventory-value-panel.tsx
index f740c0c..6d50141 100644
--- a/components/dashboard/inventory-value-panel.tsx
+++ b/components/dashboard/inventory-value-panel.tsx
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from "react"
import { Line, LineChart, ReferenceDot, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
import { ChevronLeft, ChevronRight, PackageOpen, RefreshCw, TrendingUp } from "lucide-react"
+import { AnimatedText } from "@/components/ui/animated-text"
import { Button } from "@/components/ui/button"
import { surfaceCardVariants } from "@/components/ui/surface-card"
import { EmptyState } from "@/components/ui/empty-state"
@@ -125,7 +126,9 @@ export function InventoryValuePanel() {
Inventory value
{latest ? (
-
{formatPrice(latest.totalValue, latest.currency)}
+
+
+
) : (
—
)}
@@ -181,6 +184,10 @@ export function InventoryValuePanel() {
strokeWidth={2}
dot={false}
activeDot={{ r: 5 }}
+ // Without this, every post-sync refetch hands recharts a new data
+ // array and the line replays its full draw — reads as the whole
+ // panel reloading. The value roll is the sync feedback instead.
+ isAnimationActive={false}
/>
{selectedPoint && (
- {formatPrice(holdings.totalValue, holdings.currency)}
+
+
+
{holdings.items.length} distinct items
)}
diff --git a/components/inventory/holding-row.tsx b/components/inventory/holding-row.tsx
index 14ebeae..f451d6e 100644
--- a/components/inventory/holding-row.tsx
+++ b/components/inventory/holding-row.tsx
@@ -1,6 +1,7 @@
"use client"
import { PackageOpen } from "lucide-react"
+import { AnimatedText } from "@/components/ui/animated-text"
import { surfaceCardVariants } from "@/components/ui/surface-card"
import { StickerBadge } from "@/components/inventory/sticker-badge"
import { formatPrice } from "@/lib/market"
@@ -39,9 +40,13 @@ export function HoldingRow({ item, currency }: { item: HoldingItem; currency: st
{item.unitPrice != null ? (
<>
-
{formatPrice(item.lineTotal ?? 0, currency)}
+
+
+
{item.count > 1 && (
-
{formatPrice(item.unitPrice, currency)} / unit
+
+ / unit
+
)}
>
) : (
diff --git a/components/inventory/inventory-list.tsx b/components/inventory/inventory-list.tsx
index facd407..cd9529e 100644
--- a/components/inventory/inventory-list.tsx
+++ b/components/inventory/inventory-list.tsx
@@ -3,6 +3,7 @@
import Link from "next/link"
import { useCallback, useEffect, useMemo, useState } from "react"
import { RefreshCw, Search, PackageOpen, AlertCircle, Info } from "lucide-react"
+import { AnimatedText } from "@/components/ui/animated-text"
import { Button } from "@/components/ui/button"
import { InputFrame } from "@/components/ui/input-frame"
import { Skeleton } from "@/components/ui/skeleton"
@@ -76,9 +77,13 @@ function ItemCard({ item, currency }: { item: InventoryItemView; currency: strin
{item.unitPrice != null ? (
<>
-
{formatPrice(item.lineTotal ?? 0, currency)}
+
+
+
{item.count > 1 && (
-
{formatPrice(item.unitPrice, currency)} / unit
+
+ / unit
+
)}
>
) : (
@@ -112,19 +117,31 @@ export function InventoryList() {
const [sort, setSort] = useState
("value-desc")
const [syncing, setSyncing] = useState(false)
- const load = useCallback(async () => {
- setLoading(true)
+ // `background` keeps the current list on screen (no skeleton swap) so the
+ // price roll animations can play in place after a sync — including on
+ // failure, where it toasts instead of wiping the mounted list.
+ const load = useCallback(async (background = false) => {
+ if (!background) setLoading(true)
setError(null)
try {
const res = await fetch("/api/inventory/items", { cache: "no-store" })
const body = await res.json()
if (!res.ok) {
- setError(body.error ?? "Failed to load inventory.")
+ const message = body.error ?? "Failed to load inventory."
+ if (background) {
+ toast({ title: "Refresh failed", description: message, variant: "destructive" })
+ return
+ }
+ setError(message)
setData(null)
return
}
setData(body as InventoryItemsResponse)
} catch {
+ if (background) {
+ toast({ title: "Network error while refreshing", variant: "destructive" })
+ return
+ }
setError("Network error while loading inventory.")
} finally {
setLoading(false)
@@ -145,7 +162,7 @@ export function InventoryList() {
return
}
toast({ title: "Prices refreshed", description: formatPrice(body.totalValue, body.currency) })
- await load()
+ await load(true)
} catch {
toast({ title: "Network error", variant: "destructive" })
} finally {
@@ -169,7 +186,9 @@ export function InventoryList() {
) : data ? (
<>
- {formatPrice(data.totalValue, data.currency)}
+
+
+
{data.totalItemCount} items · {data.pricedRows} priced
diff --git a/components/inventory/item-price-chart.tsx b/components/inventory/item-price-chart.tsx
index 1f4016e..a7aecc3 100644
--- a/components/inventory/item-price-chart.tsx
+++ b/components/inventory/item-price-chart.tsx
@@ -3,6 +3,7 @@
import { useState } from "react"
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
import { TrendingUp } from "lucide-react"
+import { AnimatedText } from "@/components/ui/animated-text"
import { EmptyState } from "@/components/ui/empty-state"
import { surfaceCardVariants } from "@/components/ui/surface-card"
import { DateRangeSelector } from "@/components/charts/date-range-selector"
@@ -34,7 +35,9 @@ function Stat({ label, value }: { label: string; value: string }) {
return (
)
}
diff --git a/components/ui/animated-number.tsx b/components/ui/animated-number.tsx
deleted file mode 100644
index 9d73468..0000000
--- a/components/ui/animated-number.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-"use client"
-
-import { useEffect, useMemo, useState } from "react"
-
-type AnimatedNumberProps = {
- value: number
- className?: string
- durationMs?: number
-}
-
-const DIGITS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]
-const DIGIT_HEIGHT_EM = 1.05
-
-function shortestCircularOffset(digit: number, activeDigit: number) {
- const raw = digit - activeDigit
- if (raw > 5) return raw - 10
- if (raw < -5) return raw + 10
- return raw
-}
-
-function DigitColumn({
- digit,
- previousDigit,
- durationMs,
-}: {
- digit: string
- previousDigit: string
- durationMs: number
-}) {
- const targetDigit = Number(digit)
- const startDigit = Number(previousDigit)
- const [activeDigit, setActiveDigit] = useState(startDigit)
-
- useEffect(() => {
- const frame = window.requestAnimationFrame(() => {
- setActiveDigit(targetDigit)
- })
-
- return () => {
- window.cancelAnimationFrame(frame)
- }
- }, [targetDigit])
-
- return (
-
- {DIGITS.map((item) => {
- const offset = shortestCircularOffset(Number(item), activeDigit)
-
- return (
-
- {item}
-
- )
- })}
-
- )
-}
-
-export function AnimatedNumber({ value, className, durationMs = 1400 }: AnimatedNumberProps) {
- const [previousValue, setPreviousValue] = useState(0)
-
- const formatter = useMemo(() => new Intl.NumberFormat("en-US", { maximumFractionDigits: 0, useGrouping: false }), [])
-
- const formattedCurrent = formatter.format(value)
- const formattedPrevious = formatter.format(previousValue)
- const maxLength = Math.max(formattedCurrent.length, formattedPrevious.length)
- const paddedCurrent = formattedCurrent.padStart(maxLength, " ")
- const paddedPrevious = formattedPrevious.padStart(maxLength, " ")
-
- useEffect(() => {
- const timeout = setTimeout(() => {
- setPreviousValue(value)
- }, durationMs)
- return () => clearTimeout(timeout)
- }, [value, durationMs])
-
- return (
-
- {paddedCurrent.split("").map((char, index) => {
- const previousChar = paddedPrevious[index] ?? " "
-
- if (!/\d/.test(char)) {
- return (
-
- {char === " " ? "" : char}
-
- )
- }
-
- const safePreviousDigit = /\d/.test(previousChar) ? previousChar : "0"
-
- return (
-
- )
- })}
-
- )
-}
diff --git a/components/ui/animated-text.tsx b/components/ui/animated-text.tsx
new file mode 100644
index 0000000..5c224a7
--- /dev/null
+++ b/components/ui/animated-text.tsx
@@ -0,0 +1,55 @@
+"use client"
+
+import "slot-text/style.css"
+import { useEffect, useState, useSyncExternalStore } from "react"
+import { SlotText } from "slot-text/react"
+import type { SlotOptions } from "slot-text"
+
+// Single tuning point for every rolling label in the app.
+const ROLL_OPTIONS: SlotOptions = {
+ duration: 300,
+ stagger: 45,
+ bounce: 0.6,
+}
+
+const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"
+
+function subscribeToReducedMotion(onChange: () => void) {
+ const mql = window.matchMedia(REDUCED_MOTION_QUERY)
+ mql.addEventListener("change", onChange)
+ return () => mql.removeEventListener("change", onChange)
+}
+
+function prefersReducedMotion() {
+ return window.matchMedia(REDUCED_MOTION_QUERY).matches
+}
+
+type AnimatedTextProps = {
+ text: string
+ className?: string
+}
+
+/**
+ * Slot-machine roll for short changing labels (values, totals). Rolls in on
+ * mount too: the first paint masks every digit to 0 (same width, symbols and
+ * separators static) and the real digits roll in right after, so each page
+ * load gets the animation — not just later value changes. Falls back to a
+ * plain under prefers-reduced-motion, which slot-text does not handle
+ * itself.
+ */
+export function AnimatedText({ text, className }: AnimatedTextProps) {
+ const reducedMotion = useSyncExternalStore(subscribeToReducedMotion, prefersReducedMotion, () => false)
+ const [display, setDisplay] = useState(() => text.replace(/\d/g, "0"))
+
+ useEffect(() => {
+ setDisplay(text)
+ }, [text])
+
+ if (reducedMotion) {
+ return {text}
+ }
+
+ // aria-label pins the accessible name to the real value even while the
+ // masked first frame is on screen.
+ return
+}
diff --git a/components/ui/completion-ring.tsx b/components/ui/completion-ring.tsx
deleted file mode 100644
index 3197b64..0000000
--- a/components/ui/completion-ring.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-"use client"
-
-import { useEffect, useId, useState } from "react"
-
-import { AnimatedNumber } from "@/components/ui/animated-number"
-
-interface CompletionRingProps {
- percent: number
- size?: number
-}
-
-// Animated circular progress ring for the dashboard hero stat. The dasharray
-// transitions from 0 to the target percent on mount, creating a "filling up"
-// effect that anchors the dashboard's first visual moment.
-export function CompletionRing({ percent, size = 96 }: CompletionRingProps) {
- // Tick after mount so the CSS transition runs from 0 → target.
- const [animatedPercent, setAnimatedPercent] = useState(0)
- useEffect(() => {
- const t = setTimeout(() => setAnimatedPercent(percent), 50)
- return () => clearTimeout(t)
- }, [percent])
-
- const r = 42
- const c = 2 * Math.PI * r
- const filled = (animatedPercent / 100) * c
- const gap = c - filled
-
- // Unique gradient id per instance to avoid collisions if the component is
- // rendered more than once on the same page.
- const uid = useId()
- const gradId = `completion-ring-grad-${uid}`
-
- return (
-
-
-
-
-
-
-
-
- {/* Track */}
-
- {/* Progress */}
-
-
-
-
- )
-}
diff --git a/package.json b/package.json
index 567dacb..a98b7b6 100644
--- a/package.json
+++ b/package.json
@@ -52,6 +52,7 @@
"react-dom": "^19.2.4",
"react-hook-form": "^7.79.0",
"recharts": "3.8.1",
+ "slot-text": "0.3.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.1",
"zod": "4.4.3"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9a8df02..8d0dd3c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -83,6 +83,9 @@ importers:
recharts:
specifier: 3.8.1
version: 3.8.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)
+ slot-text:
+ specifier: 0.3.2
+ version: 0.3.2(react@19.2.7)
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -2449,6 +2452,23 @@ packages:
resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==}
engines: {node: '>=20'}
+ slot-text@0.3.2:
+ resolution: {integrity: sha512-UT80AGwUsxdYAlE6JAUAntUPVT2vQUCE9nx3rDyDTB3fSvjTv+hjwPbtukJDCE86MDJEh4r8zlE7rYIgeOs45A==}
+ peerDependencies:
+ react: '>=18 <20'
+ solid-js: '>=1.8 <2'
+ svelte: '>=4 <6'
+ vue: '>=3.4 <4'
+ peerDependenciesMeta:
+ react:
+ optional: true
+ solid-js:
+ optional: true
+ svelte:
+ optional: true
+ vue:
+ optional: true
+
sonic-boom@4.2.1:
resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==}
@@ -4861,6 +4881,10 @@ snapshots:
ansi-styles: 6.2.3
is-fullwidth-code-point: 5.1.0
+ slot-text@0.3.2(react@19.2.7):
+ optionalDependencies:
+ react: 19.2.7
+
sonic-boom@4.2.1:
dependencies:
atomic-sleep: 1.0.0
diff --git a/test/animated-text.test.tsx b/test/animated-text.test.tsx
new file mode 100644
index 0000000..288107a
--- /dev/null
+++ b/test/animated-text.test.tsx
@@ -0,0 +1,47 @@
+import { render } from "@testing-library/react"
+import { afterEach, describe, expect, it, vi } from "vitest"
+import { AnimatedText } from "@/components/ui/animated-text"
+
+function mockMatchMedia(matches: boolean) {
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn().mockReturnValue({
+ matches,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ } as unknown as MediaQueryList),
+ )
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe("AnimatedText", () => {
+ it("renders the text through slot-text when motion is allowed", () => {
+ mockMatchMedia(false)
+ const { container } = render( )
+ // slot-text splits the label into per-character cells (each holding old and
+ // new glyphs), so the accessible name — not textContent — is the contract.
+ const root = container.querySelector(".slot-text")
+ expect(root).not.toBeNull()
+ expect(root).toHaveAttribute("aria-label", "$1,234.56")
+ })
+
+ it("falls back to a plain span under prefers-reduced-motion", () => {
+ mockMatchMedia(true)
+ const { container } = render( )
+ const span = container.querySelector("span.text-accent")
+ expect(span).not.toBeNull()
+ expect(span?.textContent).toBe("$1,234.56")
+ // Plain fallback: no slot-text cell structure inside.
+ expect(span?.children.length).toBe(0)
+ })
+
+ it("updates the accessible name when the prop changes", () => {
+ mockMatchMedia(false)
+ const { container, rerender } = render( )
+ rerender( )
+ expect(container.querySelector(".slot-text")).toHaveAttribute("aria-label", "$12.50")
+ })
+})