Einstellungen

Einstellungen für ein KI-Produkt, gestaltet wie bei Cursor und Claude. Eine gefüllte Seitenleiste mit Suche, Gruppen und externen Links, Karten mit Zeilen, dezenten Pickern und verschachtelten Optionen, eine dunkle Speicher-Insel, die nur erscheint, wenn sich etwas geändert hat, ⌘S zum Speichern, Feldfehler aus deinen Prüfungen oder deinem Server und Ladezustände in der Form des Inhalts.

Cursor, Claude und Codex haben sich alle auf dieselbe Einstellungsseite geeinigt: eine gefüllte Seitenleiste mit Suche und einer Handvoll gruppierter Bereiche und rechts Cards aus Zeilen mit Label und Beschreibung links und einem dezenten Steuerelement rechts. Settings ist diese Seite. Es hält deine Bereiche und kümmert sich um die Dinge, die jede Einstellungsseite falsch macht: verlorene Änderungen, doppeltes Speichern und der Sprung von einer Seitenleiste am Desktop zu einer Liste auf dem Smartphone.

Zeilen nehmen jedes Steuerelement. SettingsSelect ist der kompakte Werte-Picker, den diese Apps nutzen, ein kleiner Button mit Umriss, der ein Auswahlmenü öffnet, und SettingsNumber ist ein Stepper, den du zum Wiederholen halten kannst. Beide tragen den Namen ihrer Zeile, sodass Screenreader „Chat font, Serif“ hören. SettingsLink ist eine Zeile, die etwas anderes öffnet, mit einem Chevron oder einem Pfeil für Links, die die App verlassen. SettingsNested gleitet abhängige Optionen unter einem Switch auf, etwa Netzwerkzugriff unter Run code. Die Suche filtert die Seitenleiste nach Label, Beschreibung und Keywords, und Enter öffnet den ersten Treffer. SettingsChoice macht aus einer Auswahl Bildkarten, sodass Leute ein Theme oder eine Dichte danach wählen, wie es aussieht.

Nichts wird gespeichert, bis du es sagst. Sobald ein Wert vom Gespeicherten abweicht, steigt eine dunkle Insel mit Discard und Save von unten auf, und der Eintrag des Bereichs in der Seitenleiste erhält einen Punkt. Ändere ihn zurück, und die Leiste verschwindet. Versuchst du, einen anderen Bereich zu öffnen, auf dem Smartphone zurückzugehen oder den Tab zu schließen, wird der Wechsel blockiert: Die Leiste wackelt und sagt, du sollst zuerst speichern oder verwerfen, und der Browser fragt vor dem Schließen des Tabs. ⌘S oder Strg+S speichert von überall.

Beim Speichern zeigt der Button den Fortschritt, dann schrumpft die Insel zu einem Saved-Häkchen und gleitet weg. Schlagen deine Prüfungen fehl, zeigen die Felder ihre Fehler, der Fokus springt zum ersten, und die Leiste sagt, wie viele zu beheben sind. Sagt der Server nein, gib Fehler für die Felder zurück oder wirf einen Fehler, und der Entwurf bleibt exakt wie getippt. Tippe weiter, während gespeichert wird, und die Leiste bleibt für die neueren Änderungen stehen.

Auf dem Smartphone wird die Seitenleiste zu einer gruppierten Liste mit Beschreibungen und Chevrons. Ein Tipp auf einen Bereich gleitet ihn mit einem Zurück-Button über die Liste, und der Fokus springt zu seiner Überschrift. Während die Daten eines Bereichs laden, zeigt er ein Skeleton in der Form von Switch-Zeilen oder dein eigenes über die skeleton-Prop und bei einem Ladefehler einen Fehler mit Try again.

  1. Die Pro-Registry zu components.json hinzufügen

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  2. Token hinzufügen

    Erstelle auf deiner Kontoseite einen Token und trage ihn in .env.local als HEXTAUI_PRO_TOKEN ein.

  3. Den Block hinzufügen

    pnpm dlx shadcn@latest add @hextaui-pro/settings

Einen Bereich an deine API anbinden

useSettingsForm hält einen Entwurf der Werte, die du übergibst. Gib Feldfehler aus onSave zurück, um sie unter dem Feld zu zeigen, oder wirf einen Fehler, um die Meldung in der Speicherleiste zu zeigen. In beiden Fällen bleibt der Entwurf.

"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>
  )
}

Eine Route pro Bereich

Steuere den aktiven Bereich mit value und onValueChange, um jedem Bereich eine eigene URL zu geben. Die Shell blockiert den Wechsel weiterhin, solange etwas ungespeichert ist, sodass onValueChange nur feuert, wenn das Verlassen sicher ist.

"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>
  )
}

Laden und Fehler

Übergib status, während die Daten eines Bereichs laden. Das Skeleton wartet 150 ms, damit schnelle Ladevorgänge nie aufblitzen, und ein Fehlerzustand bietet Try again über onRetry an.

"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>
  )
}

Aufbau

Die Teile, die du zusammensetzt, von außen nach innen.

PartBeschreibung
SettingsShellDie Seite: die Bereichsnavigation, die Inhaltsspalte, die Speicherleiste und der Schutz vor dem Verlassen mit ungespeicherten Änderungen.
SettingsSectionEin Bereich. Rendert nur, solange er geöffnet ist, mit Überschrift, optionalen Aktionen und Lade- oder Fehlerzuständen.
SettingsGroupEine betitelte Card aus Zeilen, mit optionaler Fußzeile für einen Hinweis zur Gruppe.
SettingsRowEin Label, eine Beschreibung und ein Steuerelement, für Screenreader miteinander verbunden, mit dem Feldfehler darunter.
SettingsSelectEin dezenter Picker für einen Wert aus einer kurzen Liste.
SettingsLinkEine Zeile, die eine Seite, einen Dialog oder einen externen Link öffnet.
SettingsNestedAbhängige Optionen, die aufgleiten, solange ein übergeordneter Switch an ist.
SettingsNumberEin Zahlen-Stepper mit − und +, die beim Halten wiederholen, gebaut auf dem Number Field von Base UI.
SettingsChoiceBildkarten zur Wahl einer Option, etwa eines Themes oder einer Dichte, mit Radio-Semantik.
SettingsSkeletonDer Lade-Platzhalter, konfigurierbar über Zeilen pro Gruppe und Form des Steuerelements.
useSettingsFormDer Entwurf für einen Bereich. Verfolgt, was sich geändert hat, validiert, speichert und verbindet den Bereich mit der Speicherleiste.
useSettingsNavigateÖffnet einen Bereich aus dem Inhalt heraus, genauso abgesichert wie die Seitenleiste.

SettingsShell

Akzeptiert auch alle div-Props.

PropTypStandard
sections{ id, label, description?, icon?, group?, keywords?, href? }. Aufeinanderfolgende Items mit derselben group teilen sich eine Überschrift. keywords helfen der Suche, einen Bereich zu finden, und href macht das Item zu einem externen Link.
SettingsSectionItem[]–
valueDer geöffnete Bereich, wenn du ihn kontrollierst.
string–
defaultValueDer zuerst geöffnete Bereich.
stringfirst section
onValueChangeWird aufgerufen, wenn jemand einen anderen Bereich öffnet. Wird nie aufgerufen, solange etwas ungespeichert ist oder gespeichert wird.
(value: string) => void–
titleDie Seitenüberschrift über der Navigation und das Label des Zurück-Buttons auf Smartphones.
ReactNode"Settings"
descriptionEine Zeile unter dem Titel.
ReactNode–
navHeaderInhalt oben in der Seitenleiste, etwa ein Back-Link zur App.
ReactNode–
searchableFügt über den Bereichen ein Suchfeld hinzu.
booleanfalse
navFooterInhalt, der unten in der Seitenleiste angeheftet ist, etwa der angemeldete Nutzer.
ReactNode–
groupLabelsZeigt den Namen jeder Gruppe über ihr. Schalte es aus, um Gruppen nur durch Abstand zu trennen; die Namen beschriften die Gruppen weiterhin für Screenreader.
booleantrue

SettingsSection

Akzeptiert auch alle section-Props.

PropTypStandard
idEntspricht einer ID in sections.
string–
titleDie Überschrift.
ReactNodethe section's label
descriptionDie Zeile unter der Überschrift.
ReactNodethe section's description
actionsButtons neben der Überschrift.
ReactNode–
statusZeigt ein Skeleton oder einen Fehler statt der children.
"ready" | "loading" | "error""ready"
skeletonWas angezeigt wird, solange status loading ist.
ReactNode<SettingsSkeleton />
errorDie Meldung für den Fehlerzustand.
ReactNode–
onRetryFügt dem Fehlerzustand Try again hinzu.
() => void–
PropTypStandard
titleÜberschrift über der Card.
ReactNode–
descriptionEine gedämpfte Zeile unter der Überschrift, die sagt, worum es in der Gruppe geht.
ReactNode–
footerEin gedämpfter Streifen am unteren Rand der Card, für Hinweise wie das, was eine Änderung betrifft.
ReactNode–
PropTypStandard
labelBeschriftet das Steuerelement in der Zeile.
ReactNode–
descriptionHilfetext, der mit dem Steuerelement vorgelesen wird.
ReactNode–
errorMarkiert das Steuerelement als ungültig und zeigt die Meldung unter der Zeile.
string–
layoutauto setzt das Steuerelement neben das Label, wenn die Card breit ist, und darunter, wenn sie schmal ist. inline hält es neben dem Label, für Switches. stacked setzt es immer darunter, für Textareas.
"auto" | "inline" | "stacked""auto"
disabledDeaktiviert das Feld der Zeile.
booleanfalse

SettingsSelect

Akzeptiert auch alle Button-Props.

PropTypStandard
valueDer gewählte Wert.
string–
onValueChangeWird mit dem neuen Wert aufgerufen.
(value: string) => void–
optionsDie Auswahlmöglichkeiten, der Reihe nach.
{ value, label }[]–

SettingsChoice

Eine Radio-Gruppe, sodass Pfeiltasten zwischen Cards wechseln. Akzeptiert auch alle RadioGroup-Props von Base UI.

PropTypStandard
valueDie gewählte Option.
string–
onValueChangeWird mit der neuen Option aufgerufen.
(value: string) => void–
optionsDas Bild jeder Card und der Name darunter.
{ value, label, preview }[]–
columnsCards pro Zeile. 4 wird bei schmaler Zeile zu 2.
2 | 3 | 43
ratio16:10-Vorschauen, oder 2:1 für flachere.
"card" | "wide""card"

SettingsNumber

Akzeptiert auch alle NumberField.Root-Props von Base UI, etwa format und smallStep.

PropTypStandard
valueDie aktuelle Zahl.
number | null–
onValueChangeWird aufgerufen, wenn sich die Zahl ändert.
(value: number | null) => void–
minNiedrigster Wert. Der −-Button wird dort deaktiviert.
number–
maxHöchster Wert. Der +-Button wird dort deaktiviert.
number–
stepWie stark jeder Druck oder jede Pfeiltaste den Wert ändert.
number1

Akzeptiert auch alle Anchor-Props. Rendert einen Button, wenn es kein href gibt.

PropTypStandard
labelDer Titel der Zeile.
ReactNode–
descriptionEine Zeile unter dem Titel.
ReactNode–
externalÖffnet href in einem neuen Tab und zeigt einen Pfeil statt eines Chevrons.
booleanfalse
PropTypStandard
openZeigt die Optionen. Meist der Wert des übergeordneten Switches.
boolean–
PropTypStandard
groupsWie viele Zeilen jede Platzhaltergruppe hat.
number[][3, 2]
controlDie Form rechts in jeder Zeile.
"switch" | "select" | "input""switch"

useSettingsForm

Gibt { values, setValue, errors, dirty, status, save, discard } zurück.

PropTypStandard
valuesDas jetzt Gespeicherte. Ändert es sich und gibt es keine Änderungen, folgt der Entwurf ihm.
Values–
onSaveSpeichert den Entwurf. Gib { field: message } zurück, um Feldfehler zu zeigen, oder wirf einen Fehler, um die Meldung in der Speicherleiste zu zeigen.
(values) => void | errors | Promise<void | errors>–
validateLäuft vor onSave. Jeder Fehler stoppt das Speichern und fokussiert das erste ungültige Feld.
(values) => errors | undefined–

useSettingsNavigate

Gibt eine Funktion zurück, die von überall in der Shell einen Bereich öffnet, etwa den Open-Button eines Banners. Sie respektiert ungespeicherte Änderungen genauso wie die Seitenleiste.

PropTypStandard
navigateÖffnet den Bereich oder lässt die Speicherleiste wackeln, wenn etwas ungespeichert ist.
(id: string) => void–
TasteAktion
TabWechselt durch die Navigation, dann den Bereich, dann die Speicherleiste, wenn sie offen ist.
EnterÖffnet den fokussierten Bereich.
↑↓Ändert in einem Stepper die Zahl um einen Schritt. Shift bewegt um zehn.
EnterÖffnet im Suchfeld den ersten passenden Bereich. Escape leert die Suche.
⌘SSpeichert, solange etwas ungespeichert ist. Strg+S unter Windows und Linux.
  • Die Navigation ist eine Landmark, und der geöffnete Bereich ist als aktuelle Seite markiert.
  • Jeder Bereich ist eine Region, benannt nach seiner Überschrift. Auf Smartphones springt der Fokus beim Öffnen eines Bereichs zur Überschrift und beim Zurückgehen zu seiner Zeile.
  • Zeilen nutzen Field, sodass Labels, Beschreibungen und Fehler mit dem Steuerelement verbunden sind.
  • Blockierte Navigation wird höflich angesagt, und ein fehlgeschlagenes Speichern wird als Alert angesagt.
  • Die Speicherleiste und jedes verborgene Panel sind inert, sodass sie aus der Tab-Reihenfolge und vor Screenreadern verborgen sind.
  • Bei reduzierter Bewegung blenden Panels aus, statt zu gleiten, und das Wackeln der Speicherleiste wird zu einem Ring.

Gebaut mit

Die kostenlosen HextaUI-Komponenten, aus denen Settings besteht. Jede lässt sich einzeln installieren.

Code

6 Dateien, hinzugefügt zu components/blocks/settings.