User Form
A form to create or edit a user, in three presentations: a whole page (variant page), a compact form for a Dialog (modal) and a single-column form for a Sheet (sheet). Fields: profile picture, name, job title, email, phone with a calling-code select, address, time zone, language, bio, role, active switch and team, chosen with a plain fields object. Edit mode adds a danger zone to suspend or reinstate and delete, each behind a confirmation. Save stays disabled until something changes, and Cancel asks before throwing changes away. Your onSubmit 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.
Create
Edit
Permissions
Page
Edit Lena Fischer
In a dialog
In a sheet
Minimal
Installation
npx rdloom add user-formCopies 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/user-form.jsonWorks, but without upgrade tracking.
Usage
import { UserForm } from "@rdloom/react";
<div className="flex w-full justify-center">
<div className="w-full max-w-2xl">
<UserForm
mode="create"
variant="page"
layout="card"
title="New user"
fields={{ address: false, preferences: false, bio: false }}
roles={roles}
teams={teams}
defaultValues={{ role: "viewer" }}
onSubmit={createUser}
onCancel={() => {}}
/>
</div>
</div>API Reference
Defined by the spec. Components also accept the props of the React Aria component they wrap.
| Prop | Type | Default |
|---|---|---|
modecreate starts empty and shows no danger zone. edit makes the email read-only, with a hint, and shows the danger zone. | "create" | "edit" | "create" |
variantpage is a whole page form: sections with the heading on the left and the fields on the right from the lg breakpoint, and a sticky Cancel and Save footer. modal is compact for a Dialog: name, email, role and status only, no danger zone. sheet is single column for a Sheet: avatar header, grouped sections, danger zone at the bottom and a footer pinned to the bottom of the sheet. | "page" | "modal" | "sheet" | "page" |
fieldsWhich optional fields show. Defaults depend on the variant: page and sheet show all of them (team still needs teams, role still needs the changeRole permission); modal shows role and status only. Name and email always show. | Partial<Record<"avatar" | "jobTitle" | "phone" | "address" | "preferences" | "bio" | "team" | "role" | "status", boolean>> | none |
defaultValuesThe starting values. Only read when the form mounts; give it a key to start over. phoneCountry is an id from the calling-code list (for example US, GB, DE, IN). | Partial<{ name: string; email: string; role: string; status: "active" | "suspended"; team: string | null; avatar: string | null; jobTitle: string; phoneCountry: string; phone: string; street: string; apartment: string; city: string; region: string; postalCode: string; country: string; timeZone: string; language: string; bio: string }> | none |
rolesrequiredThe roles a user can have. The description of the chosen role is shown under its Select. | Array<{ id: string; label: string; description?: string }> | none |
teamsThe teams a user can belong to. Without teams the team field is left out. | Array<{ id: string; label: string }> | none |
titleA heading for the form. Leave it out when the form sits in a Sheet or Dialog that already has a title. | node | none |
headingLevelHeading level of the title, 1 to 6. | number | 2 |
layoutplain has no chrome, for a Sheet, a Dialog or a page that already has one. card draws a bordered card with padding around the form. | "card" | "plain" | "plain" |
submitLabelThe text of the Save button. Default: "Create user" or "Save changes". | string | none |
successMessageAnnounced and shown when onSubmit worked. | string | "User saved." |
dangerZoneMore actions for the danger zone, shown next to Suspend and Delete user. Edit mode only, and never in the modal variant. | node | none |
onSubmitrequiredSaves the user; yours, async. Only the values of fields that are shown are sent. avatarFile holds the File when a new picture was chosen. Return { fieldErrors } to show a server problem on a field (for example email) or { formError } for the whole form. | (values: { name: string; email: string; role: string; status: "active" | "suspended"; team: string | null; avatar: string | null; avatarFile?: File | null; jobTitle: string; phoneCountry: string; phone: string; street: string; apartment: string; city: string; region: string; postalCode: string; country: string; timeZone: string; language: string; bio: string }) => void | SubmitResult | Promise<void | SubmitResult> | none |
onCancelCalled when the person cancels. With unsaved changes it is called after they confirm Discard. | () => void | none |
onDeleteDeletes the user; yours. The Delete user button asks first. Without it the button is left out. | () => void | Promise<unknown> | none |
onSuspendSuspends the user right away (called with true), or reinstates one who is suspended (called with false); yours. The button asks first. Without it the button is left out. | (suspended: boolean) => void | Promise<unknown> | none |
permissionsWhat the app allows: edit (the whole form), suspend (Suspend and Reinstate; falls back to edit), delete (Delete user) and changeRole (the role field). Hidden removes it; disabled makes a field read-only but focusable with the reason as its description, and a button stays focusable with the reason. This only changes what people see: the server must check again. | Permissions<"edit" | "delete" | "suspend" | "changeRole"> | none |
classNamesExtra class names for single parts, so you can restyle one part without editing the file. Keys: root, header, title, form, fields, section, avatar, actions, saveButton, cancelButton, dangerZone, status. | Partial<Record<"root" | "header" | "title" | "form" | "fields" | "section" | "avatar" | "actions" | "saveButton" | "cancelButton" | "dangerZone" | "status", string>> | none |
countriesThe countries in the address country select. A short built-in list is used when left out. | Array<{ id: string; label: string }> | none |
timeZonesThe time zones to choose from, as IANA ids. A short built-in list is used when left out. | Array<{ id: string; label: string }> | none |
languagesThe languages to choose from. A short built-in list is used when left out. | Array<{ id: string; label: string }> | none |
Accessibility
Role form, WCAG 2.2 AA. Tested with axe and keyboard tests; screen reader checks are in the audit checklist.
Keyboard
- Tab: moves through the profile picture buttons, the fields in reading order, the danger zone, then Cancel and Save
- Enter or Space on Upload picture: opens the file chooser; Remove picture clears it
- Enter in a text field: saves
- Escape: with unsaved changes asks "Discard changes?"; without them it does nothing here
- Delete user, Suspend and Reinstate open a confirmation that you answer with Tab and Enter
Screen readers announce
- "Email, edit text, read only" with "Email cannot be changed here"
- "Active, switch, on" with "Suspended users cannot sign in"
- "Discard changes?, alert dialog"
- "User saved." after a successful save
- "Picture selected: portrait.png" after choosing a file
- "Phone number, edit text" with a "Calling code" select before it
What your code must do
- Save is disabled until a value changes
- Cancel with unsaved changes opens an alert dialog; Keep editing returns focus to where it was
- A failed save moves focus to the error summary; each item moves to its field
- A field the person may not change is read-only, stays focusable, and its description gives the reason
- The email in edit mode is read-only with a hint saying why
- The danger zone is a labelled group, so it is announced as its own part of the page
- The profile picture has an Upload and a Remove button; the result (chosen, removed, or why a file was refused) is announced in a status region
- Pictures are checked for type (PNG, JPEG, WebP, GIF) and size (2 MB) before they are accepted
- Phone numbers are checked for length and characters, the calling code is a labelled select
- Address, phone and name fields carry autocomplete tokens (street-address, postal-code, country-name, tel-national)
- The bio shows its character count and reports it in the field description
Block contract
- Data
- The user's current values (defaultValues), the roles and teams to choose from, and async onSubmit, onDelete and onSuspend.
- Data states
- ready
- Permissions
- edit, delete, suspend, changeRole
- Events
- onSubmit, onCancel, onDelete, onSuspend
- You can replace
- classNames for each part; dangerZone slot for extra actions; mode, variant, layout, title, submitLabel and successMessage; fields to choose what shows; roles, teams, countries, time zones and languages
Guidelines
Use it when
- Creating a user, or editing one on a page, in a Sheet or in a Dialog
Avoid it when
- Inviting several people by email: use InviteDialog
- Editing your own profile and password: use a settings page
Don't
- Treating the permissions prop as protection: UI permission is not security, so the server must check again
- Hiding a field the person may see but not change: use disabled with a reason
- Closing a Sheet around the form in a way that skips onCancel, which loses the Discard changes question
- Putting variant="page" inside a small Dialog: use variant="modal" or sheet
- Showing Suspend or Delete user in a Dialog that has its own buttons for them: the modal variant leaves them out
Design tokens
The semantic tokens this component uses. Change them once and every component follows; see Design tokens.
--rd-color-surface-default--rd-color-border-default--rd-color-text-default--rd-color-text-muted--rd-color-feedback-danger--rd-radius-overlay--rd-elevation-raised