Skip to content

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.

Each finish1 / 3
Digest2 / 3
mara@example.org
jules@example.org

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. Pass validate to 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 with peopleFromLists, which lowercases, instead of writing it by hand.
  • A channel missing from person.channels counts as off.
  • Layout. Each channel is a column of 3.5rem on narrow screens and 6.75rem from sm up. The hint is 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 pass switchLabel. Validation errors are announced with role="alert".

What it does not do

  • It never saves or sends. It calls onChange with 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. validate checks 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.

PropTypeDescription
peopleRosterPerson[]The current list, including unsaved edits. Required.
channelsRosterChannel[]One column and one switch per entry. Required.
onChange(people: RosterPerson[]) => voidCalled with the full next list after an add, remove or toggle. Required.
disabledbooleanDisables the switches, the remove buttons and the input, for example while saving.
validate(email: string) => booleanReturn true if the address is acceptable. Defaults to a server-like email check.
emptyMessageReactNodeShown when people is empty. Has a default that tells people to add an address.
footnoteReactNodeOne line under the list, for example what happens when nobody is set.
classNamestringMerged onto the root element.

RosterChannel

FieldTypeDescription
keystringMatches a key in each person's channels.
labelstringColumn heading.
hintstringWhen it sends, for example "as staff complete". Hidden on narrow screens.
limitnumberMaximum people on this channel. The heading shows n / limit.
switchLabel(email: string) => stringAccessible name for a person's switch.

RosterPerson

FieldTypeDescription
emailstringThe person's address, lowercase.
channelsRecord<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.