HextaUI

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.

  1. Add the Pro registry to components.json

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  2. Add your token

    Create a token on your account page and put it in .env.local as HEXTAUI_PRO_TOKEN.

  3. 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.

"use client"

import { IconAdjustmentsHorizontal, IconUserCircle } from "@tabler/icons-react"

import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"

import {
  SettingsGroup,
  SettingsRow,
  SettingsSection,
  SettingsShell,
  useSettingsForm,
  type SettingsSectionItem,
} from "@/components/blocks/settings/settings"

const sections: SettingsSectionItem[] = [
  { id: "profile", label: "Profile", icon: <IconUserCircle />, group: "Account" },
  { id: "preferences", label: "Preferences", icon: <IconAdjustmentsHorizontal />, group: "Account" },
]

type Profile = { name: string; digest: boolean }

function ProfileForm({ profile }: { profile: Profile }) {
  const form = useSettingsForm({
    values: profile,
    validate: (values) => (values.name.trim() ? {} : { name: "Enter your name." }),
    onSave: async (values) => {
      const response = await fetch("/api/profile", {
        method: "PATCH",
        body: JSON.stringify(values),
      })
      if (response.status === 409) return { name: "That name is taken." }
      if (!response.ok) throw new Error("Check your connection and try again.")
    },
  })

  return (
    <SettingsGroup>
      <SettingsRow label="Name" error={form.errors.name}>
        <Input
          value={form.values.name}
          onChange={(event) => form.setValue("name", event.target.value)}
          className="@md/field-group:w-64"
        />
      </SettingsRow>
      <SettingsRow label="Weekly digest" description="A summary every Monday." layout="inline">
        <Switch
          checked={form.values.digest}
          onCheckedChange={(checked) => form.setValue("digest", checked)}
        />
      </SettingsRow>
    </SettingsGroup>
  )
}

export function SettingsPage({ profile }: { profile: Profile }) {
  return (
    <SettingsShell sections={sections} className="h-svh">
      <SettingsSection id="profile">
        <ProfileForm profile={profile} />
      </SettingsSection>
      <SettingsSection id="preferences">{null}</SettingsSection>
    </SettingsShell>
  )
}

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.

"use client"

import { usePathname, useRouter } from "next/navigation"

import {
  SettingsSection,
  SettingsShell,
  type SettingsSectionItem,
} from "@/components/blocks/settings/settings"

const sections: SettingsSectionItem[] = [
  { id: "profile", label: "Profile", group: "Account" },
  { id: "billing", label: "Billing", group: "Workspace" },
]

export function SettingsLayout({ children }: { children: React.ReactNode }) {
  const router = useRouter()
  const section = usePathname().split("/").at(-1) ?? "profile"

  return (
    <SettingsShell
      sections={sections}
      value={section}
      onValueChange={(id) => router.push(`/settings/${id}`)}
      className="h-svh"
    >
      <SettingsSection id={section}>{children}</SettingsSection>
    </SettingsShell>
  )
}

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.

"use client"

import * as React from "react"

import { Switch } from "@/components/ui/switch"

import { SettingsGroup, SettingsRow, SettingsSection, useSettingsForm } from "@/components/blocks/settings/settings"

type Alerts = { invoices: boolean; overage: boolean }

function AlertsForm({ alerts }: { alerts: Alerts }) {
  const form = useSettingsForm({
    values: alerts,
    onSave: async (values) => {
      const response = await fetch("/api/alerts", { method: "PUT", body: JSON.stringify(values) })
      if (!response.ok) throw new Error("Try again in a moment.")
    },
  })

  return (
    <SettingsGroup>
      <SettingsRow label="Invoices" layout="inline">
        <Switch
          checked={form.values.invoices}
          onCheckedChange={(checked) => form.setValue("invoices", checked)}
        />
      </SettingsRow>
      <SettingsRow label="Usage over 80%" layout="inline">
        <Switch
          checked={form.values.overage}
          onCheckedChange={(checked) => form.setValue("overage", checked)}
        />
      </SettingsRow>
    </SettingsGroup>
  )
}

export function AlertsSection() {
  const [alerts, setAlerts] = React.useState<Alerts | null>(null)
  const [failed, setFailed] = React.useState(false)

  const load = React.useCallback(() => {
    setFailed(false)
    fetch("/api/alerts")
      .then((response) => (response.ok ? response.json() : Promise.reject(response)))
      .then(setAlerts)
      .catch(() => setFailed(true))
  }, [])

  React.useEffect(load, [load])

  return (
    <SettingsSection
      id="alerts"
      status={failed ? "error" : alerts ? "ready" : "loading"}
      onRetry={load}
    >
      {alerts ? <AlertsForm alerts={alerts} /> : null}
    </SettingsSection>
  )
}

Anatomy

The parts you compose, from the outside in.

PartDescription
SettingsShellThe page: the section nav, the content column, the save bar and the guard against leaving with unsaved changes.
SettingsSectionOne section. Renders only while it's open, with its heading, optional actions, and loading or error states.
SettingsGroupA titled card of rows, with an optional footer for a note about the group.
SettingsRowA label, description and control, wired together for screen readers, with the field error underneath.
SettingsSelectA quiet picker for one value from a short list.
SettingsLinkA row that opens a page, a dialog or an external link.
SettingsNestedDependent options that slide open while a parent switch is on.
SettingsNumberA number stepper with − and + that repeat while held, built on Base UI's Number Field.
SettingsChoicePicture cards for picking one option, like a theme or a density, with radio semantics.
SettingsSkeletonThe loading placeholder, configurable by rows per group and control shape.
useSettingsFormThe draft for one section. Tracks what changed, validates, saves and connects the section to the save bar.
useSettingsNavigateOpens a section from inside the content, guarded like the sidebar.

SettingsShell

Also accepts every div prop.

PropTypeDefault
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.
stringfirst 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.
booleanfalse
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.
booleantrue

SettingsSection

Also accepts every section prop.

PropTypeDefault
idMatches an id in sections.
string–
titleThe heading.
ReactNodethe section's label
descriptionThe line under the heading.
ReactNodethe 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–
PropTypeDefault
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–
PropTypeDefault
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.
booleanfalse

SettingsSelect

Also accepts every Button prop.

PropTypeDefault
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.

PropTypeDefault
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 | 43
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.

PropTypeDefault
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.
number1

Also accepts every anchor prop. Renders a button when there's no href.

PropTypeDefault
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.
booleanfalse
PropTypeDefault
openShows the options. Usually the parent switch's value.
boolean–
PropTypeDefault
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 }.

PropTypeDefault
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.

PropTypeDefault
navigateOpens the section, or shakes the save bar if something is unsaved.
(id: string) => void–
KeyAction
TabMoves through the nav, then the section, then the save bar when it's open.
EnterOpens the focused section.
↑↓In a stepper, change the number by one step. Shift moves by ten.
EnterIn the search field, opens the first matching section. Escape clears the search.
⌘SSaves 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.