Settings
Settings for an AI product, laid out like Cursor and Claude. A filled sidebar with search, groups and external links, cards of rows with quiet pickers and nested options, a dark save island that rises only when something changed, ⌘S to save, field errors from your checks or your server, and loading states shaped like the content.
Cursor, Claude and Codex all settled on the same settings page: a filled sidebar with search and a handful of grouped sections, and on the right, cards of rows with a label and description on the left and a quiet control on the right. Settings is that page. It holds your sections and handles the parts every settings page gets wrong: losing edits, saving twice, and the jump from a sidebar on desktop to a list on a phone.
Rows take any control. SettingsSelect is the compact value picker those apps use, a small outlined button that opens a menu of choices, and SettingsNumber is a stepper you can hold to repeat. Both are named by its row so screen readers hear "Chat font, Serif". SettingsLink is a row that opens something else, with a chevron or an arrow for links that leave the app. SettingsNested slides open dependent options under a switch, like network access under Run code. Search filters the sidebar by label, description and keywords, and Enter opens the first match. SettingsChoice turns a choice into picture cards, so people pick a theme or a density by what it looks like.
Nothing saves until you say so. As soon as a value differs from what's saved, a dark island rises from the bottom with Discard and Save, and the section's entry in the sidebar gets a dot. Change it back and the bar goes away. Try to open another section, go back on a phone or close the tab, and the switch is blocked: the bar shakes and says to save or discard first, and the browser asks before the tab closes. ⌘S or Ctrl+S saves from anywhere.
Saving shows its progress in the button, then the island shrinks to a Saved check and slides away. If your checks fail, the fields show their errors, focus moves to the first one, and the bar says how many to fix. If the server says no, return errors for the fields or throw, and the draft stays exactly as typed. Keep typing while it saves and the bar stays up for the newer edits.
On a phone the sidebar becomes a grouped list with descriptions and chevrons. Tapping a section slides it in over the list with a back button, and focus moves to its heading. While a section's data loads it shows a skeleton shaped like rows of switches, or your own through the skeleton prop, and an error with Try again if loading fails.
Add the Pro registry to components.json
components.json Add your token
Create a token on your account page and put it in
.env.localasHEXTAUI_PRO_TOKEN.Add the block
pnpm dlx shadcn@latest add @hextaui-pro/settings
Wire a section to your API
useSettingsForm keeps a draft of the values you pass. Return field errors from onSave to show them under the field, or throw to show the message in the save bar. Either way the draft stays.
One route per section
Control the active section with value and onValueChange to give each section its own URL. The shell still blocks the switch while something is unsaved, so onValueChange only fires when it's safe to leave.
Loading and errors
Pass status while a section's data loads. The skeleton waits 150ms so fast loads never flash, and an error state offers Try again through onRetry.
Anatomy
The parts you compose, from the outside in.
| Part | Description |
|---|---|
SettingsShell | The page: the section nav, the content column, the save bar and the guard against leaving with unsaved changes. |
SettingsSection | One section. Renders only while it's open, with its heading, optional actions, and loading or error states. |
SettingsGroup | A titled card of rows, with an optional footer for a note about the group. |
SettingsRow | A label, description and control, wired together for screen readers, with the field error underneath. |
SettingsSelect | A quiet picker for one value from a short list. |
SettingsLink | A row that opens a page, a dialog or an external link. |
SettingsNested | Dependent options that slide open while a parent switch is on. |
SettingsNumber | A number stepper with − and + that repeat while held, built on Base UI's Number Field. |
SettingsChoice | Picture cards for picking one option, like a theme or a density, with radio semantics. |
SettingsSkeleton | The loading placeholder, configurable by rows per group and control shape. |
useSettingsForm | The draft for one section. Tracks what changed, validates, saves and connects the section to the save bar. |
useSettingsNavigate | Opens a section from inside the content, guarded like the sidebar. |
SettingsShell
Also accepts every div prop.
| Prop | Type | Default |
|---|---|---|
sections{ id, label, description?, icon?, group?, keywords?, href? }. Consecutive items with the same group share a heading. keywords help search find a section, and href makes the item an external link. | SettingsSectionItem[] | – |
valueThe open section, when you control it. | string | – |
defaultValueThe section open at first. | string | first section |
onValueChangeCalled when someone opens another section. Never called while something is unsaved or saving. | (value: string) => void | – |
titleThe page heading above the nav, and the back button's label on phones. | ReactNode | "Settings" |
descriptionA line under the title. | ReactNode | – |
navHeaderContent at the top of the sidebar, like a Back link to the app. | ReactNode | – |
searchableAdds a search field above the sections. | boolean | false |
navFooterContent pinned to the bottom of the sidebar, like the signed-in user. | ReactNode | – |
groupLabelsShows each group's name above it. Turn off to separate groups by space alone; the names still label the groups for screen readers. | boolean | true |
SettingsSection
Also accepts every section prop.
| Prop | Type | Default |
|---|---|---|
idMatches an id in sections. | string | – |
titleThe heading. | ReactNode | the section's label |
descriptionThe line under the heading. | ReactNode | the section's description |
actionsButtons beside the heading. | ReactNode | – |
statusShows a skeleton or an error instead of the children. | "ready" | "loading" | "error" | "ready" |
skeletonWhat to show while status is loading. | ReactNode | <SettingsSkeleton /> |
errorThe message for the error state. | ReactNode | – |
onRetryAdds Try again to the error state. | () => void | – |
| Prop | Type | Default |
|---|---|---|
titleHeading above the card. | ReactNode | – |
descriptionA muted line under the heading, for what the group is about. | ReactNode | – |
footerA muted strip at the bottom of the card, for notes like what a change affects. | ReactNode | – |
| Prop | Type | Default |
|---|---|---|
labelLabels the control inside the row. | ReactNode | – |
descriptionHelp text, read out with the control. | ReactNode | – |
errorMarks the control invalid and shows the message under the row. | string | – |
layoutauto puts the control beside the label when the card is wide and under it when narrow. inline keeps it beside the label, for switches. stacked always puts it underneath, for text areas. | "auto" | "inline" | "stacked" | "auto" |
disabledDisables the row's field. | boolean | false |
SettingsSelect
Also accepts every Button prop.
| Prop | Type | Default |
|---|---|---|
valueThe chosen value. | string | – |
onValueChangeCalled with the new value. | (value: string) => void | – |
optionsThe choices, in order. | { value, label }[] | – |
SettingsChoice
A radio group, so arrow keys move between cards. Also accepts every Base UI RadioGroup prop.
| Prop | Type | Default |
|---|---|---|
valueThe chosen option. | string | – |
onValueChangeCalled with the new option. | (value: string) => void | – |
optionsEach card's picture and the name under it. | { value, label, preview }[] | – |
columnsCards per row. 4 drops to 2 when the row is narrow. | 2 | 3 | 4 | 3 |
ratio16:10 previews, or 2:1 for shorter ones. | "card" | "wide" | "card" |
SettingsNumber
Also accepts every Base UI NumberField.Root prop, such as format and smallStep.
| Prop | Type | Default |
|---|---|---|
valueThe current number. | number | null | – |
onValueChangeCalled as the number changes. | (value: number | null) => void | – |
minLowest value. The − button disables there. | number | – |
maxHighest value. The + button disables there. | number | – |
stepHow much each press or arrow key changes it. | number | 1 |
SettingsLink
Also accepts every anchor prop. Renders a button when there's no href.
| Prop | Type | Default |
|---|---|---|
labelThe row's title. | ReactNode | – |
descriptionA line under the title. | ReactNode | – |
externalOpens href in a new tab and shows an arrow instead of a chevron. | boolean | false |
| Prop | Type | Default |
|---|---|---|
openShows the options. Usually the parent switch's value. | boolean | – |
| Prop | Type | Default |
|---|---|---|
groupsHow many rows each placeholder group has. | number[] | [3, 2] |
controlThe shape on the right of each row. | "switch" | "select" | "input" | "switch" |
useSettingsForm
Returns { values, setValue, errors, dirty, status, save, discard }.
| Prop | Type | Default |
|---|---|---|
valuesWhat's saved now. When it changes and there are no edits, the draft follows it. | Values | – |
onSaveSave the draft. Return { field: message } to show field errors, or throw to show the message in the save bar. | (values) => void | errors | Promise<void | errors> | – |
validateRuns before onSave. Any error stops the save and focuses the first invalid field. | (values) => errors | undefined | – |
useSettingsNavigate
Returns a function that opens a section from anywhere inside the shell, like a banner's Open button. It respects unsaved changes the same way the sidebar does.
| Prop | Type | Default |
|---|---|---|
navigateOpens the section, or shakes the save bar if something is unsaved. | (id: string) => void | – |
| Key | Action |
|---|---|
| Tab | Moves through the nav, then the section, then the save bar when it's open. |
| Enter | Opens the focused section. |
| ↑↓ | In a stepper, change the number by one step. Shift moves by ten. |
| Enter | In the search field, opens the first matching section. Escape clears the search. |
| ⌘S | Saves while something is unsaved. Ctrl+S on Windows and Linux. |
- The nav is a landmark, and the open section is marked as the current page.
- Each section is a region named by its heading. On phones, focus moves to the heading when a section opens and back to its row when you go back.
- Rows use Field, so labels, descriptions and errors are attached to the control.
- Blocked navigation is announced politely, and a failed save is announced as an alert.
- The save bar and any hidden panel are inert, so they're out of the tab order and hidden from screen readers.
- With reduced motion, panels fade instead of sliding and the save bar's shake becomes a ring.
Code
6 files, added to components/blocks/settings.