Skip to content

Build a SaaS dashboard with React and Arc

Lay out a product dashboard with a sidebar, animated stats, a sortable table and an activity timeline, then keep it fast and readable as data changes.

On this page
  1. Decide what the dashboard is for
  2. Install the pieces
  3. Lay out the shell
  4. Stats that count to new values
  5. A table people can sort
  6. An activity timeline
  7. Loading and empty states
  8. Small screens
  9. Keep it calm as data changes

Decide what the dashboard is for

A dashboard answers a small set of questions someone asks every morning. For a subscription product that is usually: are we growing, who are our biggest accounts, and what changed since yesterday. Each question gets one surface: stat cards for the trend, a table for the accounts, and a timeline for what happened.

Write the questions down before you pick components. If a chart does not answer one of them, leave it out. Four clear numbers beat twelve that nobody reads.

Overview

Revenue
Active accounts
Churn
Top accounts
Enterprise120$9,600
Scale48$2,880
Scale31$1,860
Team12$540
Team7$315

Live Arc components with sample data. Change the range and the cards count to the new values; sort the table by any column.

Install the pieces

The stat card, sortable table, timeline and segmented control are free and install with the shadcn CLI. Each item brings the Arc tokens and any Arc components it depends on.

Terminal
# once per project: register the Arc namespace in components.json# { "registries": { "@uiarc": "https://uiarc.dev/r/{name}.json" } } npx shadcn@latest add @uiarc/stat-card @uiarc/sortable-data-table @uiarc/timeline @uiarc/segmented-control

After the first install, import registry/foundation.css once in your root layout. Components use CSS modules and CSS variables, so they sit next to Tailwind without conflicts.

Lay out the shell

The classic layout is a sidebar on the left and a scrolling main column. Use a two column grid: the sidebar takes the width it needs, and the main column takes the rest. The minmax(0, 1fr) matters. Without it, a wide table forces the whole page wider instead of scrolling inside its own container.

app/(app)/layout.tsx
// app/(app)/layout.tsximport { WorkspaceSidebar } from "@/registry/blocks/workspace-sidebar/workspace-sidebar";import styles from "./layout.module.css"; export default function AppLayout({ children }: { children: React.ReactNode }) {  return (    <div className={styles.shell}>      <WorkspaceSidebar className={styles.sidebar} />      <main className={styles.main}>{children}</main>    </div>  );}
layout.module.css
/* layout.module.css */.shell {  display: grid;  grid-template-columns: auto minmax(0, 1fr);  min-height: 100dvh;  background: var(--background);}.sidebar { position: sticky; top: 0; height: 100dvh; }.main {  display: grid;  align-content: start;  gap: 24px;  padding: 32px clamp(16px, 4vw, 48px) 64px;  min-width: 0; /* lets wide tables scroll instead of pushing the page */}.stats {  display: grid;  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));  gap: 12px;}

Arc's workspace sidebar is a Pro block. It collapses to an icon rail with the [ key, and the main column reflows with it on the same spring, so content never jumps. If you are on the free tier, any nav works in the first grid column; keep it sticky and give it the full viewport height.

Stats that count to new values

When someone changes the date range, the numbers should visibly move from the old value to the new one. That movement is the information: it shows the direction and roughly how far. A value that snaps looks the same as a value that did not change.

StatCard does this for you. Pass the value as a formatted string such as "$48,210" or "2.1%" and it splits out the number, counts up the first time the card scrolls into view, then springs between values after that. The final text reserves its width, so the card never resizes mid-count.

overview.tsx
"use client"; import { useState } from "react";import SegmentedControl from "@/registry/components/segmented-control/segmented-control";import { StatCard } from "@/registry/components/stat-card/stat-card";import styles from "./layout.module.css"; export function Overview({ data }: { data: Record<"7d" | "30d", Metrics> }) {  const [range, setRange] = useState<"7d" | "30d">("30d");  const current = data[range];  return (    <section aria-labelledby="overview">      <h2 id="overview">Overview</h2>      <SegmentedControl        label="Date range"        value={range}        onValueChange={value => setRange(value as "7d" | "30d")}        options={[{ value: "7d", label: "7 days" }, { value: "30d", label: "30 days" }]}      />      <div className={styles.stats}>        <StatCard label="Revenue" value={current.revenue} change={current.revenueChange} trend="up" tone="success" detail="Paid invoices" />        <StatCard label="Active accounts" value={current.accounts} change={current.accountsChange} trend="up" tone="success" />      </div>    </section>  );}
  • Use tone for meaning, not decoration. Churn going down is "success" even though the arrow points down.
  • Say the period in detail or the page heading, so a number never floats without its time frame.
  • For a bare number outside a card, use AnimatedCounter. Each digit turns like an odometer in the direction the value moved.

A table people can sort

Keep raw values in your rows and format them in render. The table sorts on the raw value, so $9,600 sorts above $540 instead of below it. Columns where every value is a number are detected as numeric, right aligned, and set in tabular numerals so digits line up.

top-accounts.tsx
import { SortableDataTable, type DataColumn } from "@/registry/components/sortable-data-table/sortable-data-table"; type Account = { id: string; company: string; plan: string; seats: number; mrr: number }; const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }); const columns: DataColumn<Account>[] = [  { key: "company", label: "Account" },  { key: "plan", label: "Plan" },  { key: "seats", label: "Seats" },  // The raw number stays in the row, so sorting compares numbers, not strings.  { key: "mrr", label: "MRR", render: value => usd.format(Number(value)) },]; export function TopAccounts({ accounts }: { accounts: Account[] }) {  return (    <SortableDataTable      caption="Top accounts"      rows={accounts}      columns={columns}      rowKey="id"      defaultSort={{ key: "mrr", direction: "desc" }}      itemName={{ one: "account", other: "accounts" }}    />  );}

Clicking a header sorts ascending, then descending. The rows glide to their new positions instead of jumping, and column widths are measured once and held, so nothing reflows while they move. A screen reader hears the new sort order. Add selectable when people need to act on several accounts at once.

An activity timeline

A timeline answers “what changed?”. Use it when order and recency matter more than comparing values; if people need to sort or filter, that is a table.

activity.tsx
import { Timeline, type TimelineEvent } from "@/registry/components/timeline/timeline"; // Pass the time the data was fetched. A fixed value keeps the server render// and the browser in agreement about "2h ago".export function Activity({ events, fetchedAt }: { events: TimelineEvent[]; fetchedAt: number }) {  return <Timeline events={events} now={fetchedAt} label="Account activity" maxHeight={420} />;}

The now prop is required. Relative labels like “2h ago” and the day groups are calculated from it, and a fixed value means the server and the browser print the same text, so there is no hydration mismatch. Pass the time you fetched the data, or a clock that ticks once a minute if the page stays open.

Today, 3 updates
  1. Production deploy passedBuild 2,041 · 3m 48s
  2. Emma Collins invited 6 people to Northwind StudioScale plan
Yesterday, 2 updates
  1. Marcus Johnson added a note to Halcyon LabsRenewal in 12 days
Rows with detail expand in place. Arrow keys move between rows; Enter or Space expands one.

Loading and empty states

Every panel has three states before it has data: loading, empty, and failed. Design them before the happy path, because they are what a new account sees first.

accounts-panel.tsx
import { Skeleton } from "@/registry/components/skeleton/skeleton";import { EmptyState } from "@/registry/components/empty-state/empty-state";import { Button } from "@/registry/components/button/button"; export function AccountsPanel({ accounts, loading }: { accounts: Account[] | undefined; loading: boolean }) {  return (    <Skeleton loading={loading} lines={5} label="Loading accounts">      {accounts?.length ? (        <TopAccounts accounts={accounts} />      ) : (        <EmptyState          title="No paying accounts yet"          description="Accounts appear here after their first invoice is paid."          action={<Button variant="secondary">Invite a customer</Button>}        />      )}    </Skeleton>  );}

Skeleton with children keeps a placeholder of about the right size while loading is true, then fades the content in and springs its height to fit, so the panels below move once instead of twice. The empty state says why the panel is empty and what to do next. For failures, keep the last good data on screen and say it is stale rather than replacing everything with an error.

Small screens

  • Collapse the sidebar to a rail on tablets and move navigation into a sheet on phones.
  • Let the stat grid wrap with repeat(auto-fit, minmax(220px, 1fr)). At 320 px each card gets a full row.
  • Wide tables scroll sideways inside their own container. Below 620 px Arc's sortable table goes further: rows stack as cards and the headers become a strip of sort buttons. Either way, put the identifying column first.
  • Give the timeline a maxHeight on desktop and let it grow with the page on phones, where a nested scroll area is easy to get stuck in.

Keep it calm as data changes

Dashboards refresh. If every refresh animates every row, the page never stops moving and people stop trusting what they see. Animate the things that changed because someone asked, and update the rest quietly.

  • Animate a value when the user changes the range or the filter. On a background refresh, update in place.
  • Use tabular numerals anywhere numbers change, so a 1 and an 8 take the same width and columns do not wobble.
  • Reserve space for values and labels. A card that grows by 4 px when a number gains a digit pushes everything below it.
  • Respect reduced motion. Arc's stat cards and counters jump straight to the new value when it is on, and the text still changes.

More guides