Configurações

Configurações para um produto de IA, organizadas como no Cursor e no Claude. Uma barra lateral preenchida com busca, grupos e links externos, cards de linhas com seletores discretos e opções aninhadas, uma ilha de salvar escura que só sobe quando algo muda, ⌘S para salvar, erros de campo vindos das suas verificações ou do seu servidor e estados de carregamento com o formato do conteúdo.

Cursor, Claude e Codex adotaram a mesma página de configurações: uma barra lateral preenchida com busca e um punhado de seções agrupadas e, à direita, cards de linhas com rótulo e descrição à esquerda e um controle discreto à direita. O Settings é essa página. Ele guarda suas seções e cuida das partes que toda página de configurações erra: perder edições, salvar duas vezes e a passagem de uma barra lateral no desktop para uma lista no celular.

As linhas aceitam qualquer controle. SettingsSelect é o seletor de valor compacto que esses apps usam, um pequeno botão com contorno que abre um menu de opções, e SettingsNumber é um stepper que você pode segurar para repetir. Ambos são nomeados pela sua linha, então os leitores de tela anunciam "Fonte do chat, Serifada". SettingsLink é uma linha que abre outra coisa, com um chevron ou uma seta para links que saem do app. SettingsNested abre deslizando as opções dependentes sob um switch, como o acesso à rede sob Run code. A busca filtra a barra lateral por rótulo, descrição e palavras-chave, e Enter abre a primeira correspondência. SettingsChoice transforma uma escolha em cards com imagem, para que as pessoas escolham um tema ou uma densidade pela aparência.

Nada é salvo até você mandar. Assim que um valor difere do que está salvo, uma ilha escura sobe da parte de baixo com Discard e Save, e a entrada da seção na barra lateral ganha um ponto. Mude de volta e a barra some. Tente abrir outra seção, voltar no celular ou fechar a aba, e a troca é bloqueada: a barra sacode e diz para salvar ou descartar primeiro, e o navegador pergunta antes de a aba fechar. ⌘S ou Ctrl+S salva de qualquer lugar.

Salvar mostra seu progresso no botão, depois a ilha encolhe em um check de Saved e desliza para fora. Se as suas verificações falharem, os campos mostram seus erros, o foco vai para o primeiro e a barra diz quantos corrigir. Se o servidor disser não, retorne erros para os campos ou lance um erro, e o rascunho permanece exatamente como foi digitado. Continue digitando enquanto salva e a barra permanece para as edições mais novas.

No celular, a barra lateral vira uma lista agrupada com descrições e chevrons. Tocar em uma seção a desliza para dentro sobre a lista, com um botão de voltar, e o foco vai para o seu título. Enquanto os dados de uma seção carregam, ela mostra um skeleton com formato de linhas de switches, ou o seu próprio pela prop skeleton, e um erro com Try again se o carregamento falhar.

  1. Adicione o registro Pro ao components.json

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

    Crie um token na sua página de conta e coloque-o em .env.local como HEXTAUI_PRO_TOKEN.

  3. Adicione o bloco

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

Conecte uma seção à sua API

useSettingsForm mantém um rascunho dos valores que você passa. Retorne erros de campo de onSave para mostrá-los sob o campo, ou lance um erro para mostrar a mensagem na barra de salvar. De qualquer forma, o rascunho permanece.

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

Uma rota por seção

Controle a seção ativa com value e onValueChange para dar a cada seção sua própria URL. O shell ainda bloqueia a troca enquanto algo não está salvo, então onValueChange só dispara quando é seguro sair.

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

Carregamento e erros

Passe status enquanto os dados de uma seção carregam. O skeleton espera 150ms para que carregamentos rápidos nunca pisquem, e um estado de erro oferece Try again por meio de 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>
  )
}

Anatomia

As partes que você compõe, de fora para dentro.

ParteDescrição
SettingsShellA página: a navegação das seções, a coluna de conteúdo, a barra de salvar e a proteção contra sair com alterações não salvas.
SettingsSectionUma seção. Só é renderizada enquanto está aberta, com seu título, ações opcionais e estados de carregamento ou erro.
SettingsGroupUm card com título de linhas, com um rodapé opcional para uma nota sobre o grupo.
SettingsRowUm rótulo, uma descrição e um controle, ligados entre si para leitores de tela, com o erro do campo abaixo.
SettingsSelectUm seletor discreto para um valor de uma lista curta.
SettingsLinkUma linha que abre uma página, um diálogo ou um link externo.
SettingsNestedOpções dependentes que se abrem deslizando enquanto um switch pai está ligado.
SettingsNumberUm stepper numérico com − e + que se repetem enquanto pressionados, construído sobre o Number Field do Base UI.
SettingsChoiceCards com imagem para escolher uma opção, como um tema ou uma densidade, com semântica de radio.
SettingsSkeletonO placeholder de carregamento, configurável por linhas por grupo e formato do controle.
useSettingsFormO rascunho de uma seção. Acompanha o que mudou, valida, salva e conecta a seção à barra de salvar.
useSettingsNavigateAbre uma seção de dentro do conteúdo, protegida como a barra lateral.

SettingsShell

Também aceita todas as props de div.

PropTipoPadrão
sections{ id, label, description?, icon?, group?, keywords?, href? }. Itens consecutivos com o mesmo group compartilham um título. keywords ajudam a busca a encontrar uma seção, e href faz do item um link externo.
SettingsSectionItem[]–
valueA seção aberta, quando você a controla.
string–
defaultValueA seção aberta inicialmente.
stringfirst section
onValueChangeChamado quando alguém abre outra seção. Nunca é chamado enquanto algo não está salvo ou está sendo salvo.
(value: string) => void–
titleO título da página acima da navegação e o rótulo do botão de voltar no celular.
ReactNode"Settings"
descriptionUma linha sob o título.
ReactNode–
navHeaderConteúdo no topo da barra lateral, como um link Back para o app.
ReactNode–
searchableAdiciona um campo de busca acima das seções.
booleanfalse
navFooterConteúdo fixado na parte de baixo da barra lateral, como o usuário conectado.
ReactNode–
groupLabelsMostra o nome de cada grupo acima dele. Desligue para separar os grupos apenas por espaço; os nomes ainda rotulam os grupos para leitores de tela.
booleantrue

SettingsSection

Também aceita todas as props de section.

PropTipoPadrão
idCorresponde a um id em sections.
string–
titleO título.
ReactNodethe section's label
descriptionA linha sob o título.
ReactNodethe section's description
actionsBotões ao lado do título.
ReactNode–
statusMostra um skeleton ou um erro no lugar dos children.
"ready" | "loading" | "error""ready"
skeletonO que mostrar enquanto status é loading.
ReactNode<SettingsSkeleton />
errorA mensagem do estado de erro.
ReactNode–
onRetryAdiciona Try again ao estado de erro.
() => void–
PropTipoPadrão
titleTítulo acima do card.
ReactNode–
descriptionUma linha discreta sob o título, sobre o assunto do grupo.
ReactNode–
footerUma faixa discreta na parte inferior do card, para notas como o que uma mudança afeta.
ReactNode–
PropTipoPadrão
labelRotula o controle dentro da linha.
ReactNode–
descriptionTexto de ajuda, lido junto com o controle.
ReactNode–
errorMarca o controle como inválido e mostra a mensagem sob a linha.
string–
layoutauto coloca o controle ao lado do rótulo quando o card é largo e abaixo dele quando é estreito. inline o mantém ao lado do rótulo, para switches. stacked sempre o coloca embaixo, para áreas de texto.
"auto" | "inline" | "stacked""auto"
disabledDesabilita o campo da linha.
booleanfalse

SettingsSelect

Também aceita todas as props do Button.

PropTipoPadrão
valueO valor escolhido.
string–
onValueChangeChamado com o novo valor.
(value: string) => void–
optionsAs opções, em ordem.
{ value, label }[]–

SettingsChoice

Um radio group, então as setas movem entre os cards. Também aceita todas as props do RadioGroup do Base UI.

PropTipoPadrão
valueA opção escolhida.
string–
onValueChangeChamado com a nova opção.
(value: string) => void–
optionsA imagem de cada card e o nome abaixo dela.
{ value, label, preview }[]–
columnsCards por linha. 4 cai para 2 quando a linha é estreita.
2 | 3 | 43
ratioPrévias 16:10, ou 2:1 para as mais baixas.
"card" | "wide""card"

SettingsNumber

Também aceita todas as props do NumberField.Root do Base UI, como format e smallStep.

PropTipoPadrão
valueO número atual.
number | null–
onValueChangeChamado conforme o número muda.
(value: number | null) => void–
minValor mínimo. O botão − é desabilitado ali.
number–
maxValor máximo. O botão + é desabilitado ali.
number–
stepO quanto cada pressionamento ou tecla de seta altera o valor.
number1

Também aceita todas as props de anchor. Renderiza um botão quando não há href.

PropTipoPadrão
labelO título da linha.
ReactNode–
descriptionUma linha sob o título.
ReactNode–
externalAbre o href em uma nova aba e mostra uma seta em vez de um chevron.
booleanfalse
PropTipoPadrão
openMostra as opções. Geralmente o valor do switch pai.
boolean–
PropTipoPadrão
groupsQuantas linhas cada grupo de placeholder tem.
number[][3, 2]
controlA forma à direita de cada linha.
"switch" | "select" | "input""switch"

useSettingsForm

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

PropTipoPadrão
valuesO que está salvo agora. Quando muda e não há edições, o rascunho o acompanha.
Values–
onSaveSalva o rascunho. Retorne { field: message } para mostrar erros de campo, ou lance um erro para mostrar a mensagem na barra de salvar.
(values) => void | errors | Promise<void | errors>–
validateExecuta antes de onSave. Qualquer erro interrompe o salvamento e foca o primeiro campo inválido.
(values) => errors | undefined–

useSettingsNavigate

Retorna uma função que abre uma seção de qualquer lugar dentro do shell, como o botão Open de um banner. Ela respeita as alterações não salvas da mesma forma que a barra lateral.

PropTipoPadrão
navigateAbre a seção, ou sacode a barra de salvar se algo não está salvo.
(id: string) => void–
TeclaAção
TabPercorre a navegação, depois a seção e depois a barra de salvar quando ela está aberta.
EnterAbre a seção em foco.
↑↓Em um stepper, altera o número em um passo. Shift move de dez em dez.
EnterNo campo de busca, abre a primeira seção correspondente. Escape limpa a busca.
⌘SSalva enquanto algo não está salvo. Ctrl+S no Windows e no Linux.
  • A navegação é um landmark, e a seção aberta é marcada como a página atual.
  • Cada seção é uma região nomeada pelo seu título. No celular, o foco vai para o título quando uma seção abre e volta para a sua linha quando você retorna.
  • As linhas usam o Field, então rótulos, descrições e erros ficam associados ao controle.
  • A navegação bloqueada é anunciada de forma polite, e um salvamento com falha é anunciado como alerta.
  • A barra de salvar e qualquer painel oculto são inert, então ficam fora da ordem de Tab e ocultos dos leitores de tela.
  • Com movimento reduzido, os painéis esmaecem em vez de deslizar e o sacudir da barra de salvar vira um anel.

Construído com

Os componentes gratuitos do HextaUI de que Settings é feito. Cada um é instalado separadamente.

Código

6 arquivos, adicionados a components/blocks/settings.