ComponentsAI

Typed object card

What Flo shows in a chat besides words: a table, a pick list, what it changed, a draft. One card per object type, through loading, ready and acted on. A card never moves the screen.

Playground

Change the controls to try each prop. The code under the preview updates as you go and lists only the props you changed; copy it into your page.

Shortlist for Senior Data Engineer

  • Maya Chen
  • Priya Nair
  • Owen Hart
import { TypedObjectCard } from '@/ui/typed-object-card';<TypedObjectCard title="Shortlist for Senior Data Engineer" />

Ready

The card's title names what it holds, with a count at the header's end. The body is the object; the footer holds its one primary action, named for the result, and the secondary ones. Press Add: the card becomes its receipt, with Undo.

Shortlist for Senior Data Engineer

3 of 142
  • Maya ChenGreatRan Halcyon's lakehouse
  • Priya NairGoodSpark, Northwind
  • Owen HartOkayNo banking
import { useState } from 'react';
import { Button } from '@/ui/button';
import { Lozenge } from '@/ui/lozenge';
import { RecordChip } from '@/ui/record-chip';
import { TypedObjectCard } from '@/ui/typed-object-card';

const people = [
  { name: 'Maya Chen', fit: 'Great', reason: "Ran Halcyon's lakehouse" },
  { name: 'Priya Nair', fit: 'Good', reason: 'Spark, Northwind' },
  { name: 'Owen Hart', fit: 'Okay', reason: 'No banking' },
];

export default function TypedObjectCardPickList() {
  const [added, setAdded] = useState(false);
  return (
    <TypedObjectCard
      className="w-full max-w-[25rem]"
      title="Shortlist for Senior Data Engineer"
      meta="3 of 142"
      status={added ? 'acted' : 'ready'}
      receipt="Added 3 to Wave 2"
      onUndo={() => setAdded(false)}
      footer={
        <>
          <Button variant="primary" onClick={() => setAdded(true)}>
            Add 3 to Wave 2
          </Button>
          <Button>Export</Button>
        </>
      }
    >
      <ul>
        {people.map((person) => (
          <li key={person.name} className="flex h-11 items-center gap-100 border-b border-border px-150 last:border-b-0">
            <RecordChip variant="inline" type="candidate" name={person.name} />
            <Lozenge appearance={person.fit === 'Great' ? 'success' : person.fit === 'Good' ? 'information' : 'warning'}>{person.fit}</Lozenge>
            <span className="min-w-0 truncate text-text-subtle">{person.reason}</span>
          </li>
        ))}
      </ul>
    </TypedObjectCard>
  );
}

Loading

While the tool runs, the card holds its final height with skeleton rows (44px each, loadingRows) and says what Flo is doing in the header, so nothing below it jumps when the result arrives. The result fades in over 150ms.

Finding people…

import { TypedObjectCard } from '@/ui/typed-object-card';

export default function TypedObjectCardLoading() {
  return <TypedObjectCard className="w-full max-w-[25rem]" status="loading" title="Shortlist for Senior Data Engineer" loadingLabel="Finding people…" />;
}

A table

Records of one type, as a slice of the table: the first column is the record's name, and Open as a View carries the filters to the full table.

Candidates at CV sent

3 of 12
NameJobWaiting
Maya ChenSenior Data Engineer6 days
Tomás ReyesHead of Risk4 days
Priya NairSenior Data Engineer3 days
import { Link } from '@/ui/link';
import { RecordChip } from '@/ui/record-chip';
import { TypedObjectCard } from '@/ui/typed-object-card';

const rows = [
  { name: 'Maya Chen', job: 'Senior Data Engineer', waiting: '6 days' },
  { name: 'Tomás Reyes', job: 'Head of Risk', waiting: '4 days' },
  { name: 'Priya Nair', job: 'Senior Data Engineer', waiting: '3 days' },
];

export default function TypedObjectCardTable() {
  return (
    <TypedObjectCard
      className="w-full max-w-120"
      title="Candidates at CV sent"
      meta="3 of 12"
      footer={
        <Link href="#candidates" variant="subtle">
          Open as a View
        </Link>
      }
    >
      <table className="w-full border-collapse text-start">
        <thead>
          <tr className="h-8 border-b border-border text-body-small leading-body-small text-text-subtle">
            <th className="px-150 text-start font-medium">Name</th>
            <th className="px-150 text-start font-medium">Job</th>
            <th className="px-150 text-start font-medium">Waiting</th>
          </tr>
        </thead>
        <tbody>
          {rows.map((row) => (
            <tr key={row.name} className="h-9 border-b border-border last:border-b-0">
              <td className="px-150">
                <RecordChip variant="plain" type="candidate" name={row.name} href={`#${row.name}`} />
              </td>
              <td className="px-150 text-text-subtle">{row.job}</td>
              <td className="px-150 text-text-subtle tabular-nums">{row.waiting}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </TypedObjectCard>
  );
}

Acted on

Once acted on, the card folds to one quiet line: a check, what happened in the past tense with a link to where it went, and Undo.

Added 2 to Wave 2 · Open Job

import { Link } from '@/ui/link';
import { TypedObjectCard } from '@/ui/typed-object-card';

const later = () => new Promise<void>((resolve) => setTimeout(resolve, 800));

export default function TypedObjectCardActed() {
  return (
    <TypedObjectCard
      className="w-full max-w-[25rem]"
      status="acted"
      title="Shortlist for Senior Data Engineer"
      receipt={
        <>
          Added 2 to Wave 2 ·{' '}
          <Link href="#job" variant="subtle">
            Open Job
          </Link>
        </>
      }
      onUndo={later}
    />
  );
}

When the tool failed

If the tool behind the card failed, the card keeps its title and says what happened and what to do next, in danger, with Try again.

Shortlist for Senior Data Engineer

import { TypedObjectCard } from '@/ui/typed-object-card';

export default function TypedObjectCardError() {
  return (
    <TypedObjectCard
      className="w-full max-w-[25rem]"
      status="error"
      title="Shortlist for Senior Data Engineer"
      error="Flo couldn't finish the search: it timed out. Try again, or narrow it to one company."
      onRetry={() => {}}
    />
  );
}

Receipts

Every change Flo made also lands as a receipt line under its reply, so the chat reads as a log. Undo is an underlined word; once it worked the line is struck through and reads Undone. A change that can't go back has no Undo.

Moved Maya Chen to CV sent

Added Priya Nair to Wave 2Undone

Sent Maya Chen's CV to Halcyon Bank

import { FloReceipt } from '@/ui/typed-object-card';

const later = () => new Promise<void>((resolve) => setTimeout(resolve, 800));

export default function TypedObjectCardReceipts() {
  return (
    <div className="flex flex-col gap-050">
      <FloReceipt onUndo={later}>Moved Maya Chen to CV sent</FloReceipt>
      <FloReceipt status="undone" onUndo={later}>
        Added Priya Nair to Wave 2
      </FloReceipt>
      <FloReceipt>Sent Maya Chen&apos;s CV to Halcyon Bank</FloReceipt>
    </div>
  );
}

On this page