Paramètres

Paramètres pour un produit d’IA, présentés comme dans Cursor et Claude. Une barre latérale pleine avec recherche, groupes et liens externes, des cartes de lignes avec sélecteurs discrets et options imbriquées, un îlot d’enregistrement sombre qui n’apparaît que si quelque chose a changé, ⌘S pour enregistrer, des erreurs de champ issues de vos contrôles ou de votre serveur, et des états de chargement qui épousent la forme du contenu.

Cursor, Claude et Codex ont tous adopté la même page de paramètres : une barre latérale pleine avec recherche et quelques sections groupées, et à droite des cartes de lignes avec un libellé et une description à gauche et un contrôle discret à droite. Settings est cette page. Il contient vos sections et gère ce que toutes les pages de paramètres ratent : modifications perdues, double enregistrement, et le passage d’une barre latérale sur ordinateur à une liste sur mobile.

Les lignes acceptent n’importe quel contrôle. SettingsSelect est le sélecteur de valeur compact qu’utilisent ces applications, un petit bouton à contour qui ouvre un menu de choix, et SettingsNumber est un stepper que l’on peut maintenir pour répéter. Les deux sont nommés par leur ligne, donc les lecteurs d’écran entendent « Chat font, Serif ». SettingsLink est une ligne qui ouvre autre chose, avec un chevron ou une flèche pour les liens qui quittent l’application. SettingsNested ouvre en glissant des options dépendantes sous un switch, comme l’accès réseau sous Run code. La recherche filtre la barre latérale par libellé, description et mots-clés, et Enter ouvre la première correspondance. SettingsChoice transforme un choix en cartes illustrées, pour que l’on choisisse un thème ou une densité d’après son apparence.

Rien n’est enregistré tant que vous ne le dites pas. Dès qu’une valeur diffère de ce qui est enregistré, un îlot sombre monte du bas avec Discard et Save, et l’entrée de la section dans la barre latérale reçoit un point. Remettez la valeur d’origine et la barre disparaît. Essayez d’ouvrir une autre section, de revenir en arrière sur mobile ou de fermer l’onglet, et le changement est bloqué : la barre vibre et indique d’enregistrer ou d’abandonner d’abord, et le navigateur demande confirmation avant la fermeture de l’onglet. ⌘S ou Ctrl+S enregistre de n’importe où.

L’enregistrement affiche sa progression dans le bouton, puis l’îlot se réduit en coche Saved et glisse hors de vue. Si vos vérifications échouent, les champs affichent leurs erreurs, le focus passe au premier et la barre indique combien en corriger. Si le serveur refuse, retournez des erreurs pour les champs ou levez une erreur, et le brouillon reste exactement tel que saisi. Continuez à écrire pendant l’enregistrement et la barre reste affichée pour les modifications plus récentes.

Sur mobile, la barre latérale devient une liste groupée avec descriptions et chevrons. Toucher une section la fait glisser par-dessus la liste avec un bouton de retour, et le focus passe à son titre. Pendant le chargement des données d’une section, elle affiche un skeleton en forme de lignes de switches, ou le vôtre via la prop skeleton, et une erreur avec Try again si le chargement échoue.

  1. Ajouter le registre Pro à components.json

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

    Créez un token sur votre page de compte et placez-le dans .env.local sous le nom HEXTAUI_PRO_TOKEN.

  3. Ajouter le block

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

Reliez une section à votre API

useSettingsForm conserve un brouillon des valeurs que vous passez. Retournez des erreurs de champ depuis onSave pour les afficher sous le champ, ou levez une erreur pour afficher le message dans la barre d’enregistrement. Dans les deux cas, le brouillon est conservé.

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

Une route par section

Contrôlez la section active avec value et onValueChange pour donner à chaque section sa propre URL. La coque bloque toujours le changement tant que quelque chose n’est pas enregistré, donc onValueChange ne se déclenche que lorsqu’il est sûr de partir.

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

Chargement et erreurs

Passez status pendant le chargement des données d’une section. Le skeleton attend 150ms pour que les chargements rapides ne clignotent jamais, et un état d’erreur propose Try again via 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>
  )
}

Anatomie

Les parties à composer, de l’extérieur vers l’intérieur.

PartieDescription
SettingsShellLa page : la navigation des sections, la colonne de contenu, la barre d’enregistrement et la protection contre le départ avec des modifications non enregistrées.
SettingsSectionUne section. Ne s’affiche que lorsqu’elle est ouverte, avec son titre, des actions facultatives et des états de chargement ou d’erreur.
SettingsGroupUne carte titrée de lignes, avec un pied de page facultatif pour une note sur le groupe.
SettingsRowUn libellé, une description et un contrôle, reliés pour les lecteurs d’écran, avec l’erreur du champ en dessous.
SettingsSelectUn sélecteur discret pour choisir une valeur dans une courte liste.
SettingsLinkUne ligne qui ouvre une page, une boîte de dialogue ou un lien externe.
SettingsNestedDes options dépendantes qui s’ouvrent en glissant tant qu’un switch parent est activé.
SettingsNumberUn stepper numérique avec − et + qui se répètent lorsqu’on les maintient, construit sur le Number Field de Base UI.
SettingsChoiceDes cartes illustrées pour choisir une option, comme un thème ou une densité, avec une sémantique radio.
SettingsSkeletonLe placeholder de chargement, configurable par lignes par groupe et forme du contrôle.
useSettingsFormLe brouillon d’une section. Suit ce qui a changé, valide, enregistre et relie la section à la barre d’enregistrement.
useSettingsNavigateOuvre une section depuis l’intérieur du contenu, avec la même protection que la barre latérale.

SettingsShell

Accepte aussi toutes les props de div.

PropTypePar défaut
sections{ id, label, description?, icon?, group?, keywords?, href? }. Les éléments consécutifs ayant le même group partagent un titre. keywords aide la recherche à trouver une section, et href fait de l’élément un lien externe.
SettingsSectionItem[]–
valueLa section ouverte, lorsque vous la contrôlez.
string–
defaultValueLa section ouverte au départ.
stringfirst section
onValueChangeAppelé quand quelqu’un ouvre une autre section. Jamais appelé tant que quelque chose n’est pas enregistré ou en cours d’enregistrement.
(value: string) => void–
titleLe titre de la page au-dessus de la navigation, et le libellé du bouton de retour sur mobile.
ReactNode"Settings"
descriptionUne ligne sous le titre.
ReactNode–
navHeaderContenu en haut de la barre latérale, comme un lien Back vers l’application.
ReactNode–
searchableAjoute un champ de recherche au-dessus des sections.
booleanfalse
navFooterContenu épinglé en bas de la barre latérale, comme l’utilisateur connecté.
ReactNode–
groupLabelsAffiche le nom de chaque groupe au-dessus de lui. Désactivez pour séparer les groupes par l’espace seul ; les noms continuent de libeller les groupes pour les lecteurs d’écran.
booleantrue

SettingsSection

Accepte aussi toutes les props de section.

PropTypePar défaut
idCorrespond à un id de sections.
string–
titleLe titre.
ReactNodethe section's label
descriptionLa ligne sous le titre.
ReactNodethe section's description
actionsBoutons à côté du titre.
ReactNode–
statusAffiche un skeleton ou une erreur à la place des enfants.
"ready" | "loading" | "error""ready"
skeletonCe qu’il faut afficher tant que status vaut loading.
ReactNode<SettingsSkeleton />
errorLe message de l’état d’erreur.
ReactNode–
onRetryAjoute Try again à l’état d’erreur.
() => void–
PropTypePar défaut
titleTitre au-dessus de la carte.
ReactNode–
descriptionUne ligne discrète sous le titre, pour dire de quoi parle le groupe.
ReactNode–
footerUne bande discrète en bas de la carte, pour des notes comme ce qu’un changement affecte.
ReactNode–
PropTypePar défaut
labelLibelle le contrôle dans la ligne.
ReactNode–
descriptionTexte d’aide, lu avec le contrôle.
ReactNode–
errorMarque le contrôle comme invalide et affiche le message sous la ligne.
string–
layoutauto place le contrôle à côté du libellé quand la carte est large et en dessous quand elle est étroite. inline le garde à côté du libellé, pour les switches. stacked le place toujours en dessous, pour les zones de texte.
"auto" | "inline" | "stacked""auto"
disabledDésactive le champ de la ligne.
booleanfalse

SettingsSelect

Accepte aussi toutes les props de Button.

PropTypePar défaut
valueLa valeur choisie.
string–
onValueChangeAppelé avec la nouvelle valeur.
(value: string) => void–
optionsLes choix, dans l’ordre.
{ value, label }[]–

SettingsChoice

Un radio group, donc les flèches passent d’une carte à l’autre. Accepte aussi toutes les props de RadioGroup de Base UI.

PropTypePar défaut
valueL’option choisie.
string–
onValueChangeAppelé avec la nouvelle option.
(value: string) => void–
optionsL’image de chaque carte et le nom en dessous.
{ value, label, preview }[]–
columnsCartes par ligne. 4 passe à 2 quand la ligne est étroite.
2 | 3 | 43
ratioAperçus en 16:10, ou 2:1 pour des aperçus plus courts.
"card" | "wide""card"

SettingsNumber

Accepte aussi toutes les props de NumberField.Root de Base UI, comme format et smallStep.

PropTypePar défaut
valueLe nombre actuel.
number | null–
onValueChangeAppelé à chaque changement du nombre.
(value: number | null) => void–
minValeur minimale. Le bouton − s’y désactive.
number–
maxValeur maximale. Le bouton + s’y désactive.
number–
stepDe combien chaque appui ou chaque flèche la modifie.
number1

Accepte aussi toutes les props d’ancre. Rend un bouton quand il n’y a pas de href.

PropTypePar défaut
labelLe titre de la ligne.
ReactNode–
descriptionUne ligne sous le titre.
ReactNode–
externalOuvre href dans un nouvel onglet et affiche une flèche au lieu d’un chevron.
booleanfalse
PropTypePar défaut
openAffiche les options. Généralement la valeur du switch parent.
boolean–
PropTypePar défaut
groupsNombre de lignes de chaque groupe de placeholders.
number[][3, 2]
controlLa forme à droite de chaque ligne.
"switch" | "select" | "input""switch"

useSettingsForm

Renvoie { values, setValue, errors, dirty, status, save, discard }.

PropTypePar défaut
valuesCe qui est enregistré actuellement. Quand cela change et qu’il n’y a pas de modifications, le brouillon le suit.
Values–
onSaveEnregistre le brouillon. Retournez { field: message } pour afficher des erreurs de champ, ou levez une erreur pour afficher le message dans la barre d’enregistrement.
(values) => void | errors | Promise<void | errors>–
validateS’exécute avant onSave. Toute erreur arrête l’enregistrement et place le focus sur le premier champ invalide.
(values) => errors | undefined–

useSettingsNavigate

Renvoie une fonction qui ouvre une section depuis n’importe où dans la coque, comme le bouton Open d’une bannière. Elle respecte les modifications non enregistrées comme le fait la barre latérale.

PropTypePar défaut
navigateOuvre la section, ou fait vibrer la barre d’enregistrement si quelque chose n’est pas enregistré.
(id: string) => void–
ToucheAction
TabParcourt la navigation, puis la section, puis la barre d’enregistrement quand elle est ouverte.
EnterOuvre la section ayant le focus.
↑↓Dans un stepper, change le nombre d’un pas. Shift le change de dix.
EnterDans le champ de recherche, ouvre la première section correspondante. Escape efface la recherche.
⌘SEnregistre tant que quelque chose n’est pas enregistré. Ctrl+S sous Windows et Linux.
  • La navigation est un landmark, et la section ouverte est marquée comme page courante.
  • Chaque section est une région nommée par son titre. Sur mobile, le focus passe au titre à l’ouverture d’une section et revient à sa ligne au retour.
  • Les lignes utilisent Field, donc libellés, descriptions et erreurs sont attachés au contrôle.
  • La navigation bloquée est annoncée poliment, et un enregistrement échoué est annoncé comme une alerte.
  • La barre d’enregistrement et tout panneau masqué sont inertes, donc ils sortent de l’ordre de tabulation et sont cachés aux lecteurs d’écran.
  • Avec réduction des animations, les panneaux s’estompent au lieu de glisser et la vibration de la barre d’enregistrement devient un anneau.

Construit avec

Les composants HextaUI gratuits dont Settings est constitué. Chacun s’installe séparément.

Code

6 fichiers, ajoutés à components/blocks/settings.