Recipient roster
One list of people with a switch per channel, per-channel limits, paste-to-add and inline validation.
Build this with your agent
Copy a ready prompt for Claude Code, Cursor or any coding agent.
No one is set to hear as staff finish, so those notices go to the admin team instead.
"use client"
import * as React from "react"Use it when the same people can hear about something in more than one way: an email as things happen, a digest when someone sends it, or both. Each person appears once, with a switch for every channel. Your storage can stay one list per channel, and notify-envelope converts between the two shapes.
Installation#
pnpm dlx shadcn@latest add https://realgood.site/r/recipient-roster.json
Or, with the @crisp namespace set up:
pnpm dlx shadcn@latest add @crisp/recipient-roster
This also adds the shadcn button, input and switch, and lucide-react.
Usage#
import { peopleFromLists } from "@/lib/notify-envelope"
import {
RecipientRoster,
type RosterChannel,
type RosterPerson,
} from "@/components/ui/recipient-roster"const channels: RosterChannel[] = [
{
key: "automatic",
label: "Each finish",
hint: "as staff complete",
limit: 5,
},
{ key: "onDemand", label: "Digest", hint: "only when sent", limit: 5 },
]
const [people, setPeople] = React.useState<RosterPerson[]>(() =>
peopleFromLists(saved, ["automatic", "onDemand"])
)<RecipientRoster
people={people}
channels={channels}
onChange={setPeople}
footnote="No one is set, so these notices go to the admin team instead."
/>The roster is controlled. It never saves anything. It calls onChange with the next list and you keep the state, so you decide when a change is saved. Pair it with save-bar.
Notes#
- Adding people. The input at the bottom adds on Enter, and also when it loses focus, so an address typed just before clicking Save is not lost. Pasting text that contains spaces, commas or semicolons adds every address in it.
- Addresses are lowercased and trimmed. Duplicates, including ones already on the roster, are skipped. New people start with every channel off.
- Validation is all or nothing. If any address in a paste fails
validate, none of them are added and the message names the first bad one. The default check is deliberately close to what servers accept. Passvalidateto match your backend exactly. It receives the lowercased address. - Limits. When a channel is at its
limit, the switches for people who are not on it lock. Switches that are on stay usable, so you can always turn one off. - Pass lowercase addresses in
people. Duplicate detection compares exact strings. Build the list withpeopleFromLists, which lowercases, instead of writing it by hand. - A channel missing from
person.channelscounts as off. - Layout. Each channel is a column of 3.5rem on narrow screens and 6.75rem from
smup. Thehintis hidden on narrow screens. The remove button shows on hover on wide screens, and always on touch devices. - Accessibility. Each switch is named "
label:email" unless you passswitchLabel. Validation errors are announced withrole="alert".
What it does not do#
- It never saves or sends. It calls
onChangewith the next list. You keep the state and decide when to save. - Contacts are email addresses only. There are no phone numbers and no SMS or text channels. A channel here is a way an email is sent, such as as things happen or in a digest, not another kind of contact.
- It does not check that an address exists.
validatechecks the shape of an address, not that anyone reads it. - It does not record consent. A switch is a setting in your list. It is not proof that a person agreed, and opt-out handling is yours.
- It does not control access or make anything compliant. It is UI cues, not compliance.
Props#
RecipientRoster#
Also accepts the props of a div (except onChange, which is the roster's own), including ref. They are applied to the root element. The prop types are exported as RecipientRosterProps.
| Prop | Type | Description |
|---|---|---|
people | RosterPerson[] | The current list, including unsaved edits. Required. |
channels | RosterChannel[] | One column and one switch per entry. Required. |
onChange | (people: RosterPerson[]) => void | Called with the full next list after an add, remove or toggle. Required. |
disabled | boolean | Disables the switches, the remove buttons and the input, for example while saving. |
validate | (email: string) => boolean | Return true if the address is acceptable. Defaults to a server-like email check. |
emptyMessage | ReactNode | Shown when people is empty. Has a default that tells people to add an address. |
footnote | ReactNode | One line under the list, for example what happens when nobody is set. |
className | string | Merged onto the root element. |
RosterChannel#
| Field | Type | Description |
|---|---|---|
key | string | Matches a key in each person's channels. |
label | string | Column heading. |
hint | string | When it sends, for example "as staff complete". Hidden on narrow screens. |
limit | number | Maximum people on this channel. The heading shows n / limit. |
switchLabel | (email: string) => string | Accessible name for a person's switch. |
RosterPerson#
| Field | Type | Description |
|---|---|---|
email | string | The person's address, lowercase. |
channels | Record<string, boolean> | Whether they are on each channel, by key. |
Agent prompt#
Paste this into your coding agent (Claude Code, Cursor, Codex or similar) in your project. Replace the bracketed parts with your own.
Goal: add one list of people to my [screen], where each person appears once with a switch per way of hearing about [what, e.g. training completion]. Contacts are email addresses only.
Install:
npx shadcn@latest add https://realgood.site/r/recipient-roster.json
npx shadcn@latest add https://realgood.site/r/notify-envelope.json
Read the installed files (components/ui/recipient-roster.tsx, lib/notify-envelope.ts) before writing any code. Do not guess props.
Contract:
type RosterPerson = { email: string /* lowercase */; channels: Record<string, boolean> }
type RosterChannel = { key: string; label: string; hint?: string; limit?: number; switchLabel?: (email: string) => string }
<RecipientRoster people channels onChange={(people) => void} disabled? validate?={(email) => boolean} emptyMessage? footnote? />
lib/notify-envelope: peopleFromLists(lists, channelKeys), listForChannel(people, key), sameList(a, b)
Wiring rule: the roster is controlled and never saves. Keep `people` in state, built with peopleFromLists from my saved lists (it lowercases). Save one list per channel, made with listForChannel, to my own API, and update the saved lists only after the write succeeds. Pass `validate` to match my backend's email rule. Pair it with save-bar so changes are saved on purpose.
Contacts are email addresses only. Do not add phone numbers, SMS or texting, and do not add a phone field or another channel type.
States to handle: empty list (emptyMessage), a channel at its limit (switches for people not on it lock), an invalid or duplicate address (error shown, nothing added), saving (disabled), save failed.
Acceptance checks (run them and show me the output):
1. Typecheck and lint pass.
2. Pasting "a@x.org, b@x.org bad" adds nobody and names the bad address.
3. A channel at its limit locks the other switches, and the ones that are on can still be turned off.
4. Duplicates are skipped and addresses are stored in lowercase.
5. Keyboard only: Tab reaches each switch, and each is named "<label>: <email>".
UI only: it does not send, store, record consent or control access, and it makes no compliance claim. No new dependencies or abstractions beyond this. If something is unclear, ask me.Examples by sector#
Example data only, to show the wording. These are not claims about any real organisation, and the component proves nothing about a sector's rules. The pattern is the same in each; only the data changes.
- Education. Office staff and department leads who hear when staff finish training: one switch for each finish, one for a weekly digest.
- Manufacturing. Supervisors and quality engineers who hear about each overdue calibration as it happens, and a plant manager who gets only the digest.
- Engineering. Reviewers and the document controller on a change order: one switch for the approval notice, one for a weekly summary.
- Health. Practice manager and credentialing staff who hear when a staff licence is close to lapsing. Staff email addresses only, and no patient details in what is sent.
Next#
Comes after: status-strip, data-table. Leads to: alert-rules, confirm-send.