# Settings page

> A settings screen in tabs for profile, notifications and account, with a save bar that appears on change and a confirmed danger zone.

A shadcn/ui block from the [@fibo](https://fibo.toribryan.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/fibo/settings-page).

- Page, with a live preview: https://shadcn.exchange/r/fibo/settings-page
- Screenshot: https://shadcn.exchange/shot/fibo/settings-page/light.png
- Registry item: https://fibo.toribryan.com/r/settings-page.json
- Categories: blocks, pages
- Listed: 2026-10-17, source last changed 2026-10-17

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/fibo/settings-page.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `https://fibo.toribryan.com/r/alert-dialog.json`, `https://fibo.toribryan.com/r/avatar.json`, `https://fibo.toribryan.com/r/button.json`, `https://fibo.toribryan.com/r/field.json`, `https://fibo.toribryan.com/r/input.json`, `https://fibo.toribryan.com/r/radio-group.json`, `https://fibo.toribryan.com/r/select.json`, `https://fibo.toribryan.com/r/switch.json`, `https://fibo.toribryan.com/r/textarea.json`, `https://fibo.toribryan.com/r/kbd.json`, `https://fibo.toribryan.com/r/tabs.json`, `https://fibo.toribryan.com/r/toast.json`, `utils`

## Notes from the registry

Installed into components/blocks/settings-page. Render <SettingsPage /> where the screen goes, then edit it: it's yours. Docs: https://fibo.toribryan.com/?path=/docs/blocks-settings-page--docs

## Files

### `registry/blocks/settings-page/data.ts`

```ts
// Placeholder settings for the Settings page block. Swap `initialSettings`
// for what your app loads, and save where `onSave` is called.

type PushSetting = "all" | "mentions" | "none"

type Settings = {
  name: string
  username: string
  bio: string
  email: string
  language: string
  mentionEmails: boolean
  weeklyDigest: boolean
  productUpdates: boolean
  push: PushSetting
}

const initialSettings: Settings = {
  name: "Ada Lovelace",
  username: "ada",
  bio: "Writes the first programs for engines that don't exist yet.",
  email: "ada@northwind.dev",
  language: "en",
  mentionEmails: true,
  weeklyDigest: false,
  productUpdates: true,
  push: "mentions",
}

const languages = [
  { value: "en", label: "English" },
  { value: "es", label: "Español" },
  { value: "fr", label: "Français" },
  { value: "de", label: "Deutsch" },
  { value: "ja", label: "日本語" },
]

const pushOptions: {
  value: PushSetting
  label: string
  description: string
}[] = [
  {
    value: "all",
    label: "Everything",
    description: "Comments, mentions and updates.",
  },
  {
    value: "mentions",
    label: "Mentions only",
    description: "When someone mentions you or replies.",
  },
  {
    value: "none",
    label: "Nothing",
    description: "Turn push notifications off.",
  },
]

export { initialSettings, languages, pushOptions }
export type { PushSetting, Settings }
```

### `registry/blocks/settings-page/sections.tsx`

```tsx
import * as React from "react"

import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Field,
  FieldContent,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldItem,
  FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
  RadioGroup,
  RadioGroupItem,
} from "@/components/ui/radio-group"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"

import {
  languages,
  pushOptions,
  type PushSetting,
  type Settings,
} from "./data"

type SectionProps = {
  settings: Settings
  update: <Key extends keyof Settings>(key: Key, value: Settings[Key]) => void
}

/** A titled group of settings, with a rule between groups. */
function SettingsGroup({
  title,
  description,
  children,
}: {
  title: string
  description: string
  children: React.ReactNode
}) {
  return (
    <section className="grid gap-6 border-b border-border py-8 first:pt-0 last:border-b-0 @3xl/settings:grid-cols-[16rem_1fr] @3xl/settings:gap-12">
      <div className="flex flex-col gap-1">
        <h2 className="m-0 text-base font-semibold text-foreground">{title}</h2>
        <p className="m-0 text-sm text-muted-foreground">{description}</p>
      </div>
      <div className="flex min-w-0 flex-col gap-6">{children}</div>
    </section>
  )
}

function ProfileSection({
  settings,
  update,
  nameMissing,
}: SectionProps & { nameMissing: boolean }) {
  const initials = settings.name
    .split(/\s+/)
    .filter(Boolean)
    .map((word) => word[0])
    .join("")
    .slice(0, 2)
    .toUpperCase()
  return (
    <SettingsGroup
      title="Profile"
      description="How you appear to the people in your workspace."
    >
      <div className="flex items-center gap-4">
        <Avatar size="lg">
          <AvatarFallback>{initials || "?"}</AvatarFallback>
        </Avatar>
        <p className="m-0 text-sm text-muted-foreground">
          Your initials stand in until you add a photo.
        </p>
      </div>
      <FieldGroup>
        <Field invalid={nameMissing}>
          <FieldLabel>Name</FieldLabel>
          <Input
            value={settings.name}
            onChange={(event) => update("name", event.target.value)}
            autoComplete="name"
          />
          <FieldError>Enter your name.</FieldError>
        </Field>
        <Field>
          <FieldLabel>Username</FieldLabel>
          <Input
            value={settings.username}
            onChange={(event) => update("username", event.target.value)}
            autoComplete="username"
          />
          <FieldDescription>
            People mention you with @{settings.username || "username"}.
          </FieldDescription>
        </Field>
        <Field>
          <FieldLabel>Bio</FieldLabel>
          <Textarea
            value={settings.bio}
            onChange={(event) => update("bio", event.target.value)}
            maxLength={160}
          />
          <FieldDescription>
            {160 - settings.bio.length} characters left.
          </FieldDescription>
        </Field>
      </FieldGroup>
    </SettingsGroup>
  )
}

function NotificationsSection({ settings, update }: SectionProps) {
  const toggles = [
    {
      key: "mentionEmails",
      label: "Mentions and replies",
      description: "An email when someone mentions you or replies to you.",
    },
    {
      key: "weeklyDigest",
      label: "Weekly digest",
      description: "A Monday summary of what changed in your projects.",
    },
    {
      key: "productUpdates",
      label: "Product updates",
      description: "New features, at most once a month.",
    },
  ] as const
  return (
    <>
      <SettingsGroup title="Email" description="What lands in your inbox.">
        <FieldGroup>
          {toggles.map((toggle) => (
            <Field key={toggle.key} orientation="horizontal">
              <FieldContent>
                <FieldLabel>{toggle.label}</FieldLabel>
                <FieldDescription>{toggle.description}</FieldDescription>
              </FieldContent>
              <Switch
                checked={settings[toggle.key]}
                onCheckedChange={(checked) => update(toggle.key, checked)}
              />
            </Field>
          ))}
        </FieldGroup>
      </SettingsGroup>
      <SettingsGroup
        title="Push"
        description="What reaches your phone and desktop."
      >
        <Field>
          <RadioGroup
            aria-label="Push notifications"
            value={settings.push}
            onValueChange={(value) => update("push", value as PushSetting)}
            className="gap-4"
          >
            {pushOptions.map((option) => (
              <FieldItem key={option.value}>
                <RadioGroupItem value={option.value} className="mt-0.5" />
                <FieldContent>
                  <FieldLabel>{option.label}</FieldLabel>
                  <FieldDescription>{option.description}</FieldDescription>
                </FieldContent>
              </FieldItem>
            ))}
          </RadioGroup>
        </Field>
      </SettingsGroup>
    </>
  )
}

function AccountSection({
  settings,
  update,
  container,
  onDelete,
}: SectionProps & {
  /** Where the confirmation portals to. Defaults to the body. */
  container?: HTMLElement | null
  onDelete: () => void
}) {
  return (
    <>
      <SettingsGroup
        title="Account"
        description="Where we reach you, and the language you read in."
      >
        <FieldGroup>
          <Field>
            <FieldLabel>Email</FieldLabel>
            <Input
              type="email"
              value={settings.email}
              onChange={(event) => update("email", event.target.value)}
              autoComplete="email"
            />
          </Field>
          <Field>
            <FieldLabel>Language</FieldLabel>
            <Select
              items={languages}
              value={settings.language}
              onValueChange={(value) => update("language", value as string)}
            >
              <SelectTrigger className="w-full @lg/settings:w-64">
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                {languages.map((language) => (
                  <SelectItem key={language.value} value={language.value}>
                    {language.label}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </Field>
        </FieldGroup>
      </SettingsGroup>
      <SettingsGroup
        title="Danger zone"
        description="Changes here can't be undone."
      >
        <div className="flex flex-col gap-4 rounded-xl border border-destructive-ring p-4 @lg/settings:flex-row @lg/settings:items-center">
          <div className="flex min-w-0 flex-1 flex-col gap-1">
            <span className="text-sm font-medium text-foreground">
              Delete account
            </span>
            <span className="text-sm text-muted-foreground">
              Removes your profile and every project you own, for everyone.
            </span>
          </div>
          <AlertDialog>
            <AlertDialogTrigger
              render={<Button variant="destructive" type="button" />}
            >
              Delete account
            </AlertDialogTrigger>
            <AlertDialogContent container={container}>
              <AlertDialogHeader>
                <AlertDialogTitle>Delete your account?</AlertDialogTitle>
                <AlertDialogDescription>
                  Your profile and the projects you own are deleted for everyone
                  in the workspace. This can&apos;t be undone.
                </AlertDialogDescription>
              </AlertDialogHeader>
              <AlertDialogFooter>
                <AlertDialogCancel>Cancel</AlertDialogCancel>
                <AlertDialogAction variant="destructive" onClick={onDelete}>
                  Delete account
                </AlertDialogAction>
              </AlertDialogFooter>
            </AlertDialogContent>
          </AlertDialog>
        </div>
      </SettingsGroup>
    </>
  )
}

export { AccountSection, NotificationsSection, ProfileSection }
export type { SectionProps }
```

### `registry/blocks/settings-page/settings-page.tsx`

```tsx
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { Kbd, KbdGroup } from "@/components/ui/kbd"
import {
  Tabs,
  TabsContent,
  TabsList,
  TabsTrigger,
} from "@/components/ui/tabs"
import { toast, Toaster } from "@/components/ui/toast"
import { cn } from "@/lib/utils"

import { initialSettings, type Settings } from "./data"
import {
  AccountSection,
  NotificationsSection,
  ProfileSection,
} from "./sections"

type Tab = "profile" | "notifications" | "account"

type SettingsPageProps = Omit<
  React.ComponentProps<"form">,
  "children" | "onSubmit"
> & {
  /** The settings the page opens with. Defaults to the placeholder in `data.ts`. */
  initialValues?: Settings
  /** Called with every setting when they're saved. Persist them here. */
  onSave?: (settings: Settings) => void
  /** Where the delete confirmation portals to. Defaults to the body. */
  container?: HTMLElement | null
}

const noSubscription = () => () => {}

// Matches the key the shortcut listens for: Cmd on Apple devices, Ctrl
// elsewhere. The server snapshot keeps the first render hydration-safe.
function useIsMac() {
  return React.useSyncExternalStore(
    noSubscription,
    () => /mac|iphone|ipad/i.test(navigator.platform || navigator.userAgent),
    () => false
  )
}

const changed = (a: Settings, b: Settings) =>
  (Object.keys(a) as (keyof Settings)[]).some((key) => a[key] !== b[key])

/**
 * A settings screen in three tabs. Edits collect in one form, a save bar
 * appears once anything differs from what was last saved, and Cmd+S or
 * Ctrl+S saves from any field.
 */
function SettingsPage({
  initialValues = initialSettings,
  onSave,
  container,
  className,
  ...props
}: SettingsPageProps) {
  const [saved, setSaved] = React.useState(initialValues)
  const [settings, setSettings] = React.useState(initialValues)
  const [tab, setTab] = React.useState<Tab>("profile")
  const [nameMissing, setNameMissing] = React.useState(false)
  const mac = useIsMac()
  const dirty = changed(settings, saved)

  const update = <Key extends keyof Settings>(
    key: Key,
    value: Settings[Key]
  ) => {
    setSettings((current) => ({ ...current, [key]: value }))
    if (key === "name" && String(value).trim()) setNameMissing(false)
  }

  const save = () => {
    if (!settings.name.trim()) {
      setNameMissing(true)
      setTab("profile")
      return
    }
    // onSave first: if it throws, the values stay unsaved and the bar stays.
    onSave?.(settings)
    setSaved(settings)
    toast.add({ title: "Settings saved" })
  }

  return (
    <form
      data-slot="settings-page"
      noValidate
      onSubmit={(event) => {
        event.preventDefault()
        save()
      }}
      onKeyDown={(event) => {
        if (event.key === "s" && (event.metaKey || event.ctrlKey)) {
          event.preventDefault()
          if (dirty) event.currentTarget.requestSubmit()
        }
      }}
      className={cn(
        "@container/settings flex min-h-svh flex-col bg-background text-foreground",
        className
      )}
      {...props}
    >
      <div className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-6 px-4 pt-10 pb-28 @2xl/settings:px-8">
        <div className="flex flex-col gap-1">
          <h1 className="m-0 text-2xl font-semibold tracking-tight">
            Settings
          </h1>
          <p className="m-0 text-sm text-muted-foreground">
            Manage your profile, what you hear about and your account.
          </p>
        </div>

        <Tabs value={tab} onValueChange={(value) => setTab(value as Tab)}>
          <TabsList variant="line">
            <TabsTrigger value="profile">Profile</TabsTrigger>
            <TabsTrigger value="notifications">Notifications</TabsTrigger>
            <TabsTrigger value="account">Account</TabsTrigger>
          </TabsList>
          <TabsContent value="profile" className="pt-8">
            <ProfileSection
              settings={settings}
              update={update}
              nameMissing={nameMissing}
            />
          </TabsContent>
          <TabsContent value="notifications" className="pt-8">
            <NotificationsSection settings={settings} update={update} />
          </TabsContent>
          <TabsContent value="account" className="pt-8">
            <AccountSection
              settings={settings}
              update={update}
              container={container}
              onDelete={() =>
                toast.add({
                  title: "Account scheduled for deletion",
                  description: "We'll email you a link to confirm.",
                })
              }
            />
          </TabsContent>
        </Tabs>
      </div>

      {/* Mounted while clean too, so the change is announced when it comes. */}
      <p role="status" className="sr-only">
        {dirty ? "You have unsaved changes." : ""}
      </p>
      {dirty ? (
        <div className="sticky bottom-4 z-10 animate-in px-4 duration-150 fade-in-0 slide-in-from-bottom-2 motion-reduce:animate-none">
          <div className="mx-auto flex max-w-4xl flex-col gap-3 rounded-xl border border-border bg-popover p-3 shadow-lg @lg/settings:flex-row @lg/settings:items-center">
            <span className="flex flex-1 items-center gap-2 px-1 text-sm">
              Unsaved changes
              <KbdGroup className="hidden @lg/settings:inline-flex">
                <Kbd>{mac ? "⌘" : "Ctrl"}</Kbd>
                <Kbd>S</Kbd>
              </KbdGroup>
            </span>
            <div className="flex gap-2">
              <Button
                type="button"
                variant="outline"
                className="flex-1 @lg/settings:flex-none"
                onClick={() => {
                  setSettings(saved)
                  setNameMissing(false)
                }}
              >
                Discard
              </Button>
              <Button type="submit" className="flex-1 @lg/settings:flex-none">
                Save changes
              </Button>
            </div>
          </div>
        </div>
      ) : null}

      {/* Remove this if your app already mounts a Toaster near its root. */}
      <Toaster />
    </form>
  )
}

export { SettingsPage }
export type { SettingsPageProps }
```
