Ajustes

Ajustes para un producto de IA, con un diseño como el de Cursor y Claude. Una barra lateral rellena con búsqueda, grupos y enlaces externos, tarjetas de filas con selectores discretos y opciones anidadas, una isla de guardado oscura que sube solo cuando algo cambió, ⌘S para guardar, errores de campo desde tus comprobaciones o tu servidor y estados de carga con la forma del contenido.

Cursor, Claude y Codex han acabado en la misma página de ajustes: una barra lateral rellena con búsqueda y un puñado de secciones agrupadas, y a la derecha, tarjetas de filas con una etiqueta y una descripción a la izquierda y un control discreto a la derecha. Settings es esa página. Contiene tus secciones y se ocupa de lo que toda página de ajustes hace mal: perder ediciones, guardar dos veces y el salto de una barra lateral en escritorio a una lista en un móvil.

Las filas aceptan cualquier control. SettingsSelect es el selector de valor compacto que usan esas apps, un pequeño botón con contorno que abre un menú de opciones, y SettingsNumber es un stepper que puedes mantener pulsado para repetir. Ambos se nombran con su fila, para que los lectores de pantalla oigan «Chat font, Serif». SettingsLink es una fila que abre otra cosa, con un chevron o una flecha para enlaces que salen de la app. SettingsNested despliega opciones dependientes bajo un switch, como el acceso a la red bajo Run code. La búsqueda filtra la barra lateral por etiqueta, descripción y palabras clave, y Enter abre la primera coincidencia. SettingsChoice convierte una elección en tarjetas con imagen, para que la gente elija un tema o una densidad por su aspecto.

Nada se guarda hasta que lo dices. En cuanto un valor difiere de lo guardado, una isla oscura sube desde abajo con Discard y Save, y la entrada de la sección en la barra lateral recibe un punto. Cámbialo de nuevo y la barra desaparece. Si intentas abrir otra sección, volver atrás en un móvil o cerrar la pestaña, el cambio se bloquea: la barra se sacude e indica que primero guardes o descartes, y el navegador pregunta antes de cerrar la pestaña. ⌘S o Ctrl+S guarda desde cualquier parte.

Guardar muestra su progreso en el botón, luego la isla se reduce a una marca Saved y se desliza fuera. Si tus comprobaciones fallan, los campos muestran sus errores, el foco pasa al primero y la barra indica cuántos hay que corregir. Si el servidor dice que no, devuelve errores para los campos o lanza un error, y el borrador queda exactamente como se escribió. Sigue escribiendo mientras guarda y la barra permanece para las ediciones más recientes.

En un móvil la barra lateral se convierte en una lista agrupada con descripciones y chevrons. Al tocar una sección, esta se desliza sobre la lista con un botón de volver, y el foco pasa a su encabezado. Mientras cargan los datos de una sección, muestra un skeleton con forma de filas de switches, o el tuyo mediante la prop skeleton, y un error con Try again si la carga falla.

  1. Añade el registro Pro a components.json

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

    Crea un token en tu página de cuenta y colócalo en .env.local como HEXTAUI_PRO_TOKEN.

  3. Añade el bloque

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

Conecta una sección a tu API

useSettingsForm mantiene un borrador de los valores que pasas. Devuelve errores de campo desde onSave para mostrarlos bajo el campo, o lanza un error para mostrar el mensaje en la barra de guardado. En ambos casos el borrador se conserva.

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

Una ruta por sección

Controla la sección activa con value y onValueChange para dar a cada sección su propia URL. El shell sigue bloqueando el cambio mientras algo está sin guardar, así que onValueChange solo se dispara cuando es seguro salir.

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

Carga y errores

Pasa status mientras cargan los datos de una sección. El skeleton espera 150ms para que las cargas rápidas nunca parpadeen, y un estado de error ofrece Try again mediante 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>
  )
}

Anatomía

Las partes que compones, de fuera hacia dentro.

ParteDescripción
SettingsShellLa página: la navegación de secciones, la columna de contenido, la barra de guardado y la protección contra salir con cambios sin guardar.
SettingsSectionUna sección. Se renderiza solo mientras está abierta, con su encabezado, acciones opcionales y estados de carga o error.
SettingsGroupUna tarjeta con título que contiene filas, con un pie opcional para una nota sobre el grupo.
SettingsRowUna etiqueta, descripción y control, conectados entre sí para los lectores de pantalla, con el error del campo debajo.
SettingsSelectUn selector discreto para un valor de una lista corta.
SettingsLinkUna fila que abre una página, un diálogo o un enlace externo.
SettingsNestedOpciones dependientes que se despliegan mientras un switch padre está activado.
SettingsNumberUn stepper numérico con − y + que se repiten al mantener pulsado, construido sobre el Number Field de Base UI.
SettingsChoiceTarjetas con imagen para elegir una opción, como un tema o una densidad, con semántica de radio.
SettingsSkeletonEl marcador de carga, configurable por filas por grupo y forma del control.
useSettingsFormEl borrador de una sección. Registra lo que cambió, valida, guarda y conecta la sección con la barra de guardado.
useSettingsNavigateAbre una sección desde dentro del contenido, protegida como la barra lateral.

SettingsShell

También acepta todas las props de div.

PropTipoPredeterminado
sections{ id, label, description?, icon?, group?, keywords?, href? }. Los elementos consecutivos con el mismo group comparten un encabezado. keywords ayuda a la búsqueda a encontrar una sección, y href convierte el elemento en un enlace externo.
SettingsSectionItem[]–
valueLa sección abierta, cuando la controlas.
string–
defaultValueLa sección abierta al principio.
stringfirst section
onValueChangeSe llama cuando alguien abre otra sección. Nunca se llama mientras algo está sin guardar o guardándose.
(value: string) => void–
titleEl encabezado de la página sobre la navegación, y la etiqueta del botón de volver en móviles.
ReactNode"Settings"
descriptionUna línea bajo el título.
ReactNode–
navHeaderContenido en la parte superior de la barra lateral, como un enlace Back a la app.
ReactNode–
searchableAñade un campo de búsqueda sobre las secciones.
booleanfalse
navFooterContenido fijado al fondo de la barra lateral, como el usuario con sesión iniciada.
ReactNode–
groupLabelsMuestra el nombre de cada grupo sobre él. Desactívalo para separar los grupos solo con espacio; los nombres siguen etiquetando los grupos para los lectores de pantalla.
booleantrue

SettingsSection

También acepta todas las props de section.

PropTipoPredeterminado
idCoincide con un id de sections.
string–
titleEl encabezado.
ReactNodethe section's label
descriptionLa línea bajo el encabezado.
ReactNodethe section's description
actionsBotones junto al encabezado.
ReactNode–
statusMuestra un skeleton o un error en lugar de los children.
"ready" | "loading" | "error""ready"
skeletonQué mostrar mientras status es loading.
ReactNode<SettingsSkeleton />
errorEl mensaje del estado de error.
ReactNode–
onRetryAñade Try again al estado de error.
() => void–
PropTipoPredeterminado
titleEncabezado sobre la tarjeta.
ReactNode–
descriptionUna línea atenuada bajo el encabezado, para indicar de qué trata el grupo.
ReactNode–
footerUna franja atenuada al pie de la tarjeta, para notas como qué afecta un cambio.
ReactNode–
PropTipoPredeterminado
labelEtiqueta el control dentro de la fila.
ReactNode–
descriptionTexto de ayuda, leído junto con el control.
ReactNode–
errorMarca el control como inválido y muestra el mensaje bajo la fila.
string–
layoutauto coloca el control junto a la etiqueta cuando la tarjeta es ancha y debajo cuando es estrecha. inline lo mantiene junto a la etiqueta, para switches. stacked siempre lo coloca debajo, para áreas de texto.
"auto" | "inline" | "stacked""auto"
disabledDeshabilita el campo de la fila.
booleanfalse

SettingsSelect

También acepta todas las props de Button.

PropTipoPredeterminado
valueEl valor elegido.
string–
onValueChangeSe llama con el nuevo valor.
(value: string) => void–
optionsLas opciones, en orden.
{ value, label }[]–

SettingsChoice

Un radio group, así que las teclas de flecha se mueven entre tarjetas. También acepta todas las props de RadioGroup de Base UI.

PropTipoPredeterminado
valueLa opción elegida.
string–
onValueChangeSe llama con la nueva opción.
(value: string) => void–
optionsLa imagen de cada tarjeta y el nombre debajo.
{ value, label, preview }[]–
columnsTarjetas por fila. 4 pasa a 2 cuando la fila es estrecha.
2 | 3 | 43
ratioVistas previas 16:10, o 2:1 para las más cortas.
"card" | "wide""card"

SettingsNumber

También acepta todas las props de NumberField.Root de Base UI, como format y smallStep.

PropTipoPredeterminado
valueEl número actual.
number | null–
onValueChangeSe llama cuando cambia el número.
(value: number | null) => void–
minValor mínimo. El botón − se deshabilita ahí.
number–
maxValor máximo. El botón + se deshabilita ahí.
number–
stepCuánto cambia con cada pulsación o tecla de flecha.
number1

También acepta todas las props de anchor. Renderiza un botón cuando no hay href.

PropTipoPredeterminado
labelEl título de la fila.
ReactNode–
descriptionUna línea bajo el título.
ReactNode–
externalAbre href en una pestaña nueva y muestra una flecha en lugar de un chevron.
booleanfalse
PropTipoPredeterminado
openMuestra las opciones. Normalmente el valor del switch padre.
boolean–
PropTipoPredeterminado
groupsCuántas filas tiene cada grupo de marcadores.
number[][3, 2]
controlLa forma a la derecha de cada fila.
"switch" | "select" | "input""switch"

useSettingsForm

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

PropTipoPredeterminado
valuesLo guardado ahora. Cuando cambia y no hay ediciones, el borrador lo sigue.
Values–
onSaveGuarda el borrador. Devuelve { field: message } para mostrar errores de campo, o lanza un error para mostrar el mensaje en la barra de guardado.
(values) => void | errors | Promise<void | errors>–
validateSe ejecuta antes de onSave. Cualquier error detiene el guardado y enfoca el primer campo inválido.
(values) => errors | undefined–

useSettingsNavigate

Devuelve una función que abre una sección desde cualquier parte dentro del shell, como el botón Open de un banner. Respeta los cambios sin guardar igual que la barra lateral.

PropTipoPredeterminado
navigateAbre la sección, o sacude la barra de guardado si algo está sin guardar.
(id: string) => void–
KeyAcción
TabRecorre la navegación, luego la sección y luego la barra de guardado cuando está abierta.
EnterAbre la sección enfocada.
↑↓En un stepper, cambia el número en un paso. Shift avanza de diez en diez.
EnterEn el campo de búsqueda, abre la primera sección coincidente. Escape borra la búsqueda.
⌘SGuarda mientras haya cambios sin guardar. Ctrl+S en Windows y Linux.
  • La navegación es un landmark, y la sección abierta se marca como la página actual.
  • Cada sección es una región con el nombre de su encabezado. En móviles, el foco pasa al encabezado cuando se abre una sección y vuelve a su fila al regresar.
  • Las filas usan Field, así que las etiquetas, descripciones y errores están asociados al control.
  • La navegación bloqueada se anuncia de forma polite, y un guardado fallido se anuncia como una alerta.
  • La barra de guardado y cualquier panel oculto son inert, así que quedan fuera del orden de tabulación y ocultos para los lectores de pantalla.
  • Con movimiento reducido, los paneles se desvanecen en lugar de deslizarse y la sacudida de la barra de guardado se convierte en un anillo.

Construido con

Los componentes gratuitos de HextaUI con los que está hecho Settings. Cada uno se instala por separado.

Código

6 archivos, añadidos a components/blocks/settings.