ComponentsContent

Needs you list

The list on Home of what is waiting on you today, and the sections of the morning brief. Every row is one thing, with the one action that clears it.

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.

Yours

  • Send CVs to Halcyon
  • Call Maya about her notice period
  • Brief Tom on Orbital Freight
  • Check references for Priya
  • Book second interview with Sara Lund
import { NeedsYouSection } from '@/ui/needs-you-list';<NeedsYouSection title="Yours" />

Needs you on Home

Counts first, one line each, linking to the place that holds them. Then Yours: stuck Rules, notices, briefs, meetings that want an outcome and tasks due, about five rows, then Show all. Clear a row and it leaves; Undo brings it back.

3 replies need youOpen Inbox
1 recording to fileOpen Meetings

Yours

  • Stuck: Monday shortlist digestGmail refused the send. The daily sending limit was reached.
  • Your morning brief is ready2 owed a reply, 1 stuck, 3 in interviews.
  • Halcyon Bank intro callSara Lund, Maya Chen · Ended 2h ago
  • Send CVs to HalcyonHalcyon Bank · Due today
import { useState } from 'react';
import { Button } from '@/ui/button';
import { moveList } from '@/ui/motion';
import { NeedsYouCount, NeedsYouList, NeedsYouRow, NeedsYouSection } from '@/ui/needs-you-list';
import { toast } from '@/ui/toast';

const yours = [
  { id: 'rule', title: 'Stuck: Monday shortlist digest', meta: 'Gmail refused the send. The daily sending limit was reached.', action: 'Open run' },
  { id: 'brief', title: 'Your morning brief is ready', meta: '2 owed a reply, 1 stuck, 3 in interviews.', action: 'Open brief', dismiss: true },
  { id: 'call', title: 'Halcyon Bank intro call', meta: 'Sara Lund, Maya Chen · Ended 2h ago', action: 'Log outcome' },
  { id: 'task', title: 'Send CVs to Halcyon', meta: 'Halcyon Bank · Due today', action: 'Complete' },
];

// Home: counts first, then Yours. Complete or dismiss a row and it leaves; Undo brings it back.
export default function NeedsYouHome() {
  const [gone, setGone] = useState<string[]>([]);
  const clear = (id: string, title: string) => {
    moveList(() => setGone((now) => [...now, id]));
    toast.add({ title: `${title} done`, actionProps: { children: 'Undo', onClick: () => moveList(() => setGone((now) => now.filter((g) => g !== id))) } });
  };
  return (
    <NeedsYouList className="w-full max-w-160">
      <NeedsYouCount actionLabel="Open Inbox" href="?inbox">
        3 replies need you
      </NeedsYouCount>
      <NeedsYouCount actionLabel="Open Meetings" href="?meetings">
        1 recording to file
      </NeedsYouCount>
      <NeedsYouSection title="Yours" showAllHref="?tasks">
        {yours
          .filter((row) => !gone.includes(row.id))
          .map((row) => (
            <NeedsYouRow
              key={row.id}
              id={row.id}
              title={row.title}
              meta={row.meta}
              action={
                <Button variant="ghost" aria-label={`${row.action} ${row.title}`} onClick={() => clear(row.id, row.title)}>
                  {row.action}
                </Button>
              }
              onDismiss={row.dismiss ? () => clear(row.id, row.title) : undefined}
            />
          ))}
      </NeedsYouSection>
    </NeedsYouList>
  );
}

Morning brief sections

Each section is a 36px heading with its count, then its rows. The brief is a page of actions to work through, so each row's action is a secondary button. Empty sections are hidden.

Owed a reply · 2

  • Maya ChenRe: Senior Data Engineer shortlist · Wrote 2h ago
  • James ReedInterview times for Thursday · Wrote yesterday

Open roles · 1

  • Senior Data Engineer2 of 5 CVs sent · Interview 3 · Offer 1
import { Avatar } from '@/ui/avatar';
import { Button } from '@/ui/button';
import { NeedsYouList, NeedsYouRow, NeedsYouSection } from '@/ui/needs-you-list';

// The morning brief: a page of actions to work through, so each row's action is a secondary button. Empty sections hide.
export default function NeedsYouBrief() {
  return (
    <NeedsYouList className="w-full max-w-160">
      <NeedsYouSection title="Owed a reply" count={2}>
        <NeedsYouRow id="maya" title={<span className="flex items-center gap-075"><Avatar name="Maya Chen" />Maya Chen</span>} meta="Re: Senior Data Engineer shortlist · Wrote 2h ago" action={<Button aria-label="Reply to Maya Chen">Reply</Button>} />
        <NeedsYouRow id="james" title={<span className="flex items-center gap-075"><Avatar name="James Reed" />James Reed</span>} meta="Interview times for Thursday · Wrote yesterday" action={<Button aria-label="Reply to James Reed">Reply</Button>} />
      </NeedsYouSection>
      <NeedsYouSection title="Stuck" count={0} />
      <NeedsYouSection title="Open roles" count={1}>
        <NeedsYouRow id="sde" title="Senior Data Engineer" meta="2 of 5 CVs sent · Interview 3 · Offer 1" action={<Button aria-label="Send CVs for Senior Data Engineer">Send CVs</Button>} />
      </NeedsYouSection>
    </NeedsYouList>
  );
}

Empty

Nothing waiting says "Nothing here." in subtle text, with no illustration.

Nothing here.

import { NeedsYouList } from '@/ui/needs-you-list';

// Nothing waiting: one line of subtle text, no illustration on Home.
export default function NeedsYouEmpty() {
  return <NeedsYouList className="w-full max-w-160" />;
}

When an action fails, and long rows

Dismiss the notice: it leaves at once, the server refuses, and it comes back with a toast that stays until you close it. A long title or reason ends in an ellipsis on one line, and the title shows in full on hover.

Yours

  • Nguyễn Thị Minh Khai replied to the Halcyon Bank shortlistFrom the Rule “Tell me when a client replies” · 5 min ago
  • Stuck: Weekly pipeline summary for the Halcyon Bank, Orbital Freight and Northwind Logistics accountsGmail refused the send because the daily sending limit for this mailbox was reached. It runs again tomorrow at 08:00.
import { useState } from 'react';
import { moveList } from '@/ui/motion';
import { NeedsYouList, NeedsYouRow, NeedsYouSection } from '@/ui/needs-you-list';
import { toast } from '@/ui/toast';

// Dismiss the notice: the row slides out at once, the server refuses, and it slides back with a toast that stays.
export default function NeedsYouFailure() {
  const [gone, setGone] = useState(false);
  const dismiss = () => {
    moveList(() => setGone(true));
    setTimeout(() => {
      moveList(() => setGone(false));
      toast.add({ type: 'error', title: "Not dismissed. Check your connection and try again." });
    }, 600);
  };
  return (
    <NeedsYouList className="w-full max-w-160">
      <NeedsYouSection title="Yours">
        {gone ? null : <NeedsYouRow id="notice" title="Nguyễn Thị Minh Khai replied to the Halcyon Bank shortlist" meta="From the Rule “Tell me when a client replies” · 5 min ago" onDismiss={dismiss} />}
        <NeedsYouRow id="long" title="Stuck: Weekly pipeline summary for the Halcyon Bank, Orbital Freight and Northwind Logistics accounts" meta="Gmail refused the send because the daily sending limit for this mailbox was reached. It runs again tomorrow at 08:00." />
      </NeedsYouSection>
    </NeedsYouList>
  );
}

Motion

A cleared row leaves with the row in and out pattern. Press Replay to watch the first row leave and come back.

Yours

  • Send CVs to Halcyon
  • Call Maya about her notice period
  • Brief Tom on Orbital Freight
import { useState } from 'react';
import { Button } from '@/ui/button';
import { moveList } from '@/ui/motion';
import { NeedsYouList, NeedsYouRow, NeedsYouSection } from '@/ui/needs-you-list';

const rows = ['Send CVs to Halcyon', 'Call Maya about her notice period', 'Brief Tom on Orbital Freight'];

// Replay clears the first row, then brings it back: it fades up 4px and leaves in 160ms, the rest slide up in 200ms.
export default function NeedsYouMotion() {
  const [shown, setShown] = useState(rows);
  const replay = () => {
    moveList(() => setShown(rows.slice(1)));
    setTimeout(() => moveList(() => setShown(rows)), 900);
  };
  return (
    <div className="flex w-full max-w-160 flex-col items-start gap-300">
      <NeedsYouList className="w-full">
        <NeedsYouSection title="Yours">
          {shown.map((title) => (
            <NeedsYouRow key={title} id={title.replace(/\W+/g, '-')} title={title} />
          ))}
        </NeedsYouSection>
      </NeedsYouList>
      <Button size="sm" onClick={replay}>
        Replay
      </Button>
    </div>
  );
}

On this page