Invite Dialog
A dialog for inviting people, in three shapes: typed emails with a role on one compact line each, a search of a directory you pass, or a two-step pick-from-a-list with a role per person. Checks for format, duplicates and people who are already members, an error summary, an optional message and a submit button that says how many invitations it sends. Your onInvite does the work. UI permission is not security: the server must check again.
A ready-made piece, built from the library's own parts.
A block puts several components together into something you would otherwise assemble by hand. It never fetches data: you give it the data, or answer its callbacks. It is copied into your project like any component, with the parts it uses, so you can change anything.
Basic
With server errors
Permissions
Many people
With message toggle
Search directory
Pick from list
Installation
npx rdloom add invite-dialogCopies the source into src/components/rdloom. Edit it freely: rdloom upgrade merges later versions into your changes. It needs react-aria-components; add --install to install them.
Use another registry client
npx shadcn@latest add https://rdloom.vimalbhatt.com/r/invite-dialog.jsonWorks, but without upgrade tracking.
Usage
import { Button, DialogTrigger, InviteDialog } from "@rdloom/react";
<div className="flex w-full justify-center">
<DialogTrigger>
<Button>Invite people</Button>
<InviteDialog
roles={roles}
defaultRole="viewer"
existingEmails={["amara.okafor@example.com", "lena.fischer@example.com"]}
description="They get an email with a link to join your workspace."
onInvite={sendInvites}
/>
</DialogTrigger>
</div>API Reference
Defined by the spec. Components also accept the props of the React Aria component they wrap.
| Prop | Type | Default |
|---|---|---|
variantemails: one compact line per person, typed email and role. search: look people up in a directory you pass and add them to a to-invite list. list: a two-step modal, pick people from a list, then give each a role. | "emails" | "search" | "list" | "emails" |
rolesrequiredThe roles a person can be invited as. The description of a role shows in the open list of the role select. | Array<{ id: string; label: string; description?: string }> | none |
defaultRoleThe id of the role each new row starts with. Defaults to the first role. | string | none |
existingEmailsEmails that are already members. Typing one is an error on that row. Compared without case. | string[] | none |
defaultInvitesRows the emails variant starts with, for example people to invite again. Without it there is one empty row. | Array<{ email: string; role?: string }> | none |
peopleThe directory for the search and list variants. In search it is what the search looks in unless onSearch is given; in list it is every person that can be picked. | Array<{ id: string; name: string; email: string; avatarUrl?: string }> | none |
onSearchSearch variant only: looks people up on your server as the person types. Return the matches; the latest query wins. Without it, people is filtered in the browser. | (query: string) => Array<{ id: string; name: string; email: string; avatarUrl?: string }> | Promise<Array<{ id: string; name: string; email: string; avatarUrl?: string }>> | none |
maxVisibleRowsRows shown before the list scrolls inside itself, with a fade at the edge that has more. The dialog never grows past 90% of the screen height. | number | 4 |
maxInvitesThe most people one send can hold. Add another is disabled at the limit and says so. | number | 10 |
titleThe dialog's title, shown as its heading. Default: "Invite people"; the list variant shows "Select users" and then "Give user role". | string | none |
descriptionOne sentence under the title. | string | none |
allowMessageOffers an optional message that goes with every invitation, behind an "Add a message" switch. Set false to remove it entirely. The list variant never has one. | boolean | true |
messageLabelThe label of the message field. | string | "Message (optional)" |
submitLabelThe text of the submit button for a number of invitations. Default: "Send invitation" and "Send 3 invitations". | (count: number) => string | none |
successMessageThe status message announced once the dialog has closed after a send. Default: "3 invitations sent". | (count: number) => string | none |
onInviterequiredSends the invitations; yours, async. person is set when someone was picked from the directory. Resolve for success. Return { fieldErrors: { "invites[1].email": "..." } } to show a problem on a row, or { formError } for the whole form; a throw also keeps the dialog open. | (invites: Array<{ email: string; role: string; person?: { id: string; name: string; email: string; avatarUrl?: string } }>, details: { message?: string }) => void | SubmitResult | Promise<void | SubmitResult> | none |
onCancelCalled when the person cancels: the Cancel button or Escape. | () => void | none |
onOpenChangeCalled with false when the dialog closes after a cancel or a successful send. Needed with isOpen. | (isOpen: boolean) => void | none |
isOpenControlled open state, for a dialog opened without a DialogTrigger. | boolean | none |
permissionsWhat the app allows: invite. Hidden renders nothing; disabled keeps the dialog but the submit button stays focusable with aria-disabled and the reason shown and read, and nothing is sent. This only changes what people see: the server must check again. | Permissions<"invite"> | none |
classNamesExtra class names for single parts, so you can restyle one part without editing the file. Keys: dialog, form, list, message, toggle, search, results, counter, actions, cancelButton, submitButton, reason, status. | Partial<Record<"dialog" | "form" | "list" | "message" | "toggle" | "search" | "results" | "counter" | "actions" | "cancelButton" | "submitButton" | "reason" | "status", string>> | none |
Accessibility
Role dialog, WCAG 2.2 AA. Tested with axe and keyboard tests; screen reader checks are in the audit checklist.
Keyboard
- Tab: moves through the rows, Add another, the message switch, Cancel and the submit button; focus stays inside the dialog
- Escape: cancels and closes
- Enter in an email field: sends
- Search variant: Down arrow from the search field moves into the results; Enter adds the person
- List variant: Up and Down move between people, Space or Enter picks or drops one, Next goes to the roles step, Prev comes back
Screen readers announce
- "Invite people, dialog"
- "Email 2, edit text, required", then "Role 2"
- "Select users, Choose people, list box, multiple selection"
- "Send 3 invitations, button"
- "3 invitations sent" after the dialog closes
What your code must do
- Focus lands on the first email field when the dialog opens, and on a new row's email when one is added
- After a send that worked the dialog closes and focus returns to the control that opened it
- A failed send keeps the dialog open and moves focus to the error summary; each item moves to its field
- Row errors are linked to their field with aria-describedby and aria-invalid
- Every row's inputs keep a unique accessible name (Email 2, Role 2) even where the label is visually hidden
- Adding and removing a row is announced; a status message tells the result after the dialog closes
- The list variant moves focus to the new step's heading on Next and Prev and announces the step politely
- A disabled invite stays focusable and the reason is its description
Block contract
- Data
- The roles people can be invited as, the emails of existing members, optionally a directory of people (static or from an async onSearch), and an async onInvite that does the sending.
- Data states
- ready
- Permissions
- invite
- Events
- onInvite, onCancel, onOpenChange
- You can replace
- classNames for each part; title, description, messageLabel, submitLabel and successMessage; roles, maxInvites, maxVisibleRows and allowMessage; variant emails, search or list; people and onSearch for a directory; works inside DialogTrigger or controlled with isOpen
Guidelines
Use it when
- Inviting people to a team, workspace or account by email, one or many at a time
Avoid it when
- Adding a user with a full profile: use UserForm
- Importing hundreds of people: use a file import flow
Don't
- Treating the permissions prop as protection: UI permission is not security, so the server must check again
- Sending the invitations from inside the component: onInvite is yours
- Closing the dialog yourself after a failed onInvite: it stays open on purpose so the person can fix the rows
Design tokens
The semantic tokens this component uses. Change them once and every component follows; see Design tokens.
--rd-color-surface-raised--rd-color-border-default--rd-color-text-default--rd-color-text-muted--rd-color-feedback-danger--rd-color-focus-ring--rd-radius-overlay--rd-elevation-overlay