# Cells kit

> Table cells that make rows look finished: avatar with name, status badge, number with a rolling value and coloured delta, a progress meter that fills on view, a self-updating relative time, and a mono ID with a copy button.

- Tier: Free (MIT)
- Interaction: Loading
- Page: https://eased.dev/components/cells
- Installs: motion
- Registry: https://eased.dev/r/cells.json

## Install

```bash
npx shadcn add https://eased.dev/r/cells.json
```

Cells that make table rows look finished. They work in any table and size themselves to an Eased table's density.

```tsx
import { DeltaCell, IdCell, PersonCell, ProgressCell, RelativeTimeCell, StatusCell } from "@/components/eased/cells/cells";

<PersonCell name="Maya Okafor" src={url} detail="maya@northwind.studio" />
<StatusCell tone="success" live>Active</StatusCell>
<DeltaCell value={12480} delta={0.124} format={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }} />
<ProgressCell value={64} label="Onboarding" />
<RelativeTimeCell date={invoice.issuedAt} />
<IdCell id="INV-2041" copyable />
```

| Cell | Props | |
|---|---|---|
| `PersonCell` | `name`, `src?`, `detail?` | 24px avatar when dense, 32px when comfortable. The second line shows only in comfortable rows. |
| `StatusCell` | `children`, `tone?`, `live?` | A Badge with a dot. `live` makes the dot breathe. |
| `DeltaCell` | `value`, `delta` (a fraction), `format?`, `locale?`, `invert?` | The value rolls. The delta sits in a fixed track: a rise is success and a fall is warning (`invert` swaps them). |
| `ProgressCell` | `value`, `max?`, `label?`, `locale?` | A `role="meter"` bar with fixed widths for the track and the label. |
| `RelativeTimeCell` | `date`, `now?`, `locale?` | `<time>` with the full date as its title. It stays current on a 30s clock and shows the short date before hydration. |
| `IdCell` | `id`, `copyable?` | Mono with slashed zeros. The copy button keeps its space while hidden. |

Put numeric cells in `align: "end"` columns.

**Motion signature:** values roll in the direction they change, and deltas change colour with them. Meters fill from empty the first time they scroll into view, then glide on the smooth spring and turn green at 100%. A live status dot breathes and pauses off-screen. The copy icon swaps to a tick with Eased's text-swap blur. Under reduced motion, everything is set at once.

## Example

`components/eased/cells/cells-demo.tsx`

```tsx
"use client";

import { useState } from "react";
import { Button } from "../button/button";
import { DemoFrame } from "../lib/demo-frame";
import { easedColumns, Table } from "../table/table";
import { DeltaCell, PersonCell, ProgressCell, RelativeTimeCell, StatusCell } from "./cells";

type Account = {
  id: string;
  name: string;
  mrr: number;
  delta: number;
  onboarding: number;
  state: "Active" | "Trial" | "Paused";
  seen: number;
};

const NOW = Date.UTC(2026, 9, 18, 12);
const M = 60_000;

const START: Account[] = [
  { id: "a1", name: "Northwind", mrr: 12_480, delta: 0.124, onboarding: 100, state: "Active", seen: NOW - 4 * M },
  { id: "a2", name: "Lumen Labs", mrr: 8_920, delta: -0.038, onboarding: 82, state: "Active", seen: NOW - 52 * M },
  { id: "a3", name: "Fieldnote", mrr: 4_310, delta: 0.061, onboarding: 64, state: "Trial", seen: NOW - 3 * 60 * M },
  { id: "a4", name: "Ostrava Co.", mrr: 3_750, delta: 0, onboarding: 45, state: "Active", seen: NOW - 26 * 60 * M },
  { id: "a5", name: "Pine & Pixel", mrr: 2_180, delta: -0.117, onboarding: 30, state: "Paused", seen: NOW - 5 * 24 * 60 * M },
  { id: "a6", name: "Halcyon", mrr: 1_640, delta: 0.219, onboarding: 18, state: "Trial", seen: NOW - 9 * M },
  { id: "a7", name: "Brightwell", mrr: 1_205, delta: 0.042, onboarding: 72, state: "Active", seen: NOW - 7 * 60 * M },
  { id: "a8", name: "Marlow Studio", mrr: 980, delta: -0.021, onboarding: 55, state: "Active", seen: NOW - 2 * 24 * 60 * M },
  { id: "a9", name: "Quill", mrr: 640, delta: 0.087, onboarding: 9, state: "Trial", seen: NOW - 31 * M },
];

const TONE = { Active: "success", Trial: "info", Paused: "neutral" } as const;
const usd = { style: "currency", currency: "USD", maximumFractionDigits: 0 } as const;

const col = easedColumns<Account>();
const columns = col.columns([
  col.accessor("name", { header: "Account", cell: (c) => <PersonCell name={c.getValue()} />, meta: { emphasis: true, grow: true } }),
  col.accessor("mrr", {
    header: "MRR",
    cell: (c) => <DeltaCell value={c.getValue()} delta={c.row.original.delta} format={usd} locale="en-US" />,
    meta: { align: "end" },
  }),
  col.accessor("onboarding", {
    header: "Onboarding",
    cell: (c) => <ProgressCell value={c.getValue()} label={`${c.row.original.name} onboarding`} />,
    meta: { className: "@max-[24rem]:hidden" },
  }),
  col.accessor("state", {
    header: "Status",
    cell: (c) => (
      <StatusCell tone={TONE[c.getValue()]} live={c.getValue() === "Active"}>
        {c.getValue()}
      </StatusCell>
    ),
    meta: { className: "@max-[36rem]:hidden" },
  }),
  col.accessor("seen", {
    header: "Last seen",
    cell: (c) => <RelativeTimeCell date={c.getValue()} now={NOW} />,
    meta: { align: "end", className: "@max-[36rem]:hidden" },
  }),
]);

// A month passes: revenue drifts, onboarding moves forward.
const advance = (rows: Account[]) =>
  rows.map((r) => {
    const delta = Math.round((Math.random() * 0.3 - 0.12) * 1000) / 1000;
    return {
      ...r,
      mrr: Math.max(200, Math.round(r.mrr * (1 + delta))),
      delta,
      onboarding: Math.min(100, r.onboarding + Math.round(Math.random() * 25)),
    };
  });

export function CellsDemo() {
  const [rows, setRows] = useState(START);
  return (
    <DemoFrame
      title="Accounts"
      actions={
        <Button size="sm" onClick={() => setRows(advance)}>
          Next month
        </Button>
      }
    >
      <Table label="Accounts" data={rows} columns={columns} getRowId={(r) => r.id} className="min-h-0 flex-1" />
    </DemoFrame>
  );
}
```
