# Settings 2: notification preferences

> Notification settings that save as you go: digest frequency as radio cards, a grid of switches for each event and channel (cards on phones), and quiet hours with times and weekdays.

A shadcn/ui block from the [@ballmac](https://ui.ballmac.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ballmac/settings-2).

- Page, with a live preview: https://shadcn.exchange/r/ballmac/settings-2
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ballmac/settings-2-light-e454a2a0519bb8bb.png
- Registry item: https://ui.ballmac.com/r/settings-2.json
- Categories: blocks, settings
- Listed: 2026-10-14, source last changed 2026-10-14

## Install

```bash
npx shadcn@latest add https://ui.ballmac.com/r/settings-2.json
```

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

## Dependencies

- npm packages: `lucide-react`
- Registry items: `utils`, `https://ui.ballmac.com/r/input.json`, `https://ui.ballmac.com/r/switch.json`, `https://ui.ballmac.com/r/toggle-group.json`, `https://ui.ballmac.com/r/use-resource.json`, `https://ui.ballmac.com/r/state-view.json`, `https://ui.ballmac.com/r/skeleton.json`

## Files

### `registry/ballmac/components/blocks/settings-2/settings-2.tsx`

```tsx
// Ballmac UI: Settings 2. https://ui.ballmac.com/blocks/settings-2
"use client"

import * as React from "react"
import { BellRing, Check, Moon } from "lucide-react"

import { Input } from "@/components/ballmac/input"
import { Switch } from "@/components/ballmac/switch"
import { ToggleGroup, ToggleGroupItem } from "@/components/ballmac/toggle-group"
import { formPhase, type FormStateProps } from "@/lib/ballmac/use-resource"
import { Skeleton } from "@/components/ballmac/skeleton"
import { frameProps, StateView } from "@/components/ballmac/state-view"
import { cn } from "@/lib/utils"

type Settings2Channel = "email" | "push" | "inApp"

type Settings2Event = {
  id: string
  label: string
  description: string
}

type Settings2Values = {
  digest: string
  events: Record<string, Record<Settings2Channel, boolean>>
  quiet: { enabled: boolean; from: string; to: string; days: string[] }
}

type Settings2Props = Omit<React.ComponentProps<"section">, "title" | "onChange"> & FormStateProps & {
  /** Page heading. */
  title?: string
  /** One line under the heading. */
  description?: string
  /** Things people can be notified about. */
  events?: Settings2Event[]
  /** Starting values. */
  defaultValues?: Partial<Settings2Values>
  /** Called after every change, with all values. Changes save instantly, so there is no Save button. */
  onChange?: (values: Settings2Values) => void | Promise<void>
}

const channels: { id: Settings2Channel; label: string }[] = [
  { id: "email", label: "Email" },
  { id: "push", label: "Push" },
  { id: "inApp", label: "In-app" },
]

const digests = [
  { id: "realtime", label: "Real time", description: "An email as things happen" },
  { id: "daily", label: "Daily", description: "One summary each morning" },
  { id: "weekly", label: "Weekly", description: "A Monday recap" },
  { id: "never", label: "Never", description: "Only what you turned on below" },
]

const days = [
  ["mon", "M", "Monday"],
  ["tue", "T", "Tuesday"],
  ["wed", "W", "Wednesday"],
  ["thu", "T", "Thursday"],
  ["fri", "F", "Friday"],
  ["sat", "S", "Saturday"],
  ["sun", "S", "Sunday"],
] as const

const defaultEvents: Settings2Event[] = [
  { id: "mentions", label: "Mentions", description: "Someone @mentions you" },
  { id: "comments", label: "Comments", description: "Replies on things you own or follow" },
  { id: "assignments", label: "Assignments", description: "You are assigned a task" },
  { id: "invoices", label: "Invoices paid", description: "A customer pays an invoice" },
  { id: "product", label: "Product news", description: "New features and tips" },
]

function initialEvents(events: Settings2Event[]) {
  return Object.fromEntries(
    events.map((e, i) => [e.id, { email: i !== 4, push: i < 3, inApp: true }])
  ) as Settings2Values["events"]
}

function Settings2Inner({
  title = "Notifications",
  description = "Choose what you hear about and where. Changes save as you make them.",
  events = defaultEvents,
  defaultValues,
  onChange,
  state: stateProp,
  errorState,
  className,
  ...props
}: Settings2Props) {
  const [values, setValues] = React.useState<Settings2Values>({
    digest: "daily",
    events: initialEvents(events),
    quiet: { enabled: true, from: "22:00", to: "07:00", days: ["mon", "tue", "wed", "thu", "fri"] },
    ...defaultValues,
  })
  const [internal, setStatus] = React.useState<"idle" | "saving" | "saved" | "error">("idle")
  // a `state` prop drives the status; without it the settings run by themselves
  const phase = formPhase(stateProp)
  const status = phase === undefined ? internal : phase === "sending" ? "saving" : phase === "done" ? "saved" : phase
  const quietId = React.useId()

  function commit(next: Settings2Values) {
    setValues(next)
    setStatus("saving")
    Promise.resolve(onChange ? onChange(next) : new Promise((r) => setTimeout(r, 350)))
      .then(() => setStatus("saved"))
      .catch(() => setStatus("error"))
  }
  const toggle = (event: string, channel: Settings2Channel, on: boolean) =>
    commit({ ...values, events: { ...values.events, [event]: { ...values.events[event], [channel]: on } } })

  return (
    <section data-slot="settings-2" className={cn("mx-auto w-full max-w-4xl px-4 py-8 sm:px-6", className)} {...props}>
      <div className="flex flex-wrap items-start justify-between gap-3">
        <div>
          <h2 className="text-2xl font-semibold tracking-[-0.03em]">{title}</h2>
          <p className="text-muted-foreground mt-1 text-sm">{description}</p>
        </div>
        <p role="status" className="text-muted-foreground flex h-6 items-center gap-1.5 text-sm">
          {status === "saving" && "Saving…"}
          {status === "saved" && <><Check className="text-chart-2 size-4" aria-hidden="true" />Saved</>}
          {status === "error" && <span className="text-destructive">{errorState?.description ?? "Couldn’t save. Try again."}</span>}
        </p>
      </div>

      <div className="mt-8 space-y-8">
        <fieldset>
          <legend className="text-sm font-semibold">Email digest</legend>
          <p className="text-muted-foreground mt-1 text-sm">How often you get a summary of activity.</p>
          <div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
            {digests.map((d) => {
              const on = values.digest === d.id
              return (
                <label key={d.id} className="relative cursor-pointer">
                  <input type="radio" name="digest" value={d.id} checked={on} onChange={() => commit({ ...values, digest: d.id })} className="peer sr-only" />
                  <span className="peer-focus-visible:ring-ring/50 peer-checked:border-foreground peer-checked:bg-accent/60 hover:bg-accent/40 bg-card flex h-full flex-col rounded-2xl border p-4 transition-colors peer-focus-visible:ring-[3px]">
                    <span className="flex items-center justify-between text-sm font-medium">
                      {d.label}
                      <span aria-hidden="true" className={cn("flex size-4 items-center justify-center rounded-full border", on && "bg-foreground text-background border-transparent")}>{on && <Check className="size-3" />}</span>
                    </span>
                    <span className="text-muted-foreground mt-1 text-xs">{d.description}</span>
                  </span>
                </label>
              )
            })}
          </div>
        </fieldset>

        <div>
          <h3 className="flex items-center gap-2 text-sm font-semibold"><BellRing className="size-4" aria-hidden="true" />What to notify me about</h3>
          <p className="text-muted-foreground mt-1 text-sm">Turn on each place you want to hear about it.</p>

          {/* Tablets and up: a grid with one switch per event and channel. */}
          <div className="bg-card mt-4 hidden overflow-hidden rounded-2xl border sm:block">
            <table className="w-full text-start text-sm">
              <caption className="sr-only">Notification channels for each event</caption>
              <thead>
                <tr className="text-muted-foreground bg-muted/40 border-b text-xs">
                  <th scope="col" className="px-5 py-3 font-medium">Event</th>
                  {channels.map((c) => <th key={c.id} scope="col" className="w-24 px-3 py-3 text-center font-medium">{c.label}</th>)}
                </tr>
              </thead>
              <tbody className="divide-y">
                {events.map((e) => (
                  <tr key={e.id}>
                    <th scope="row" className="px-5 py-4 font-normal">
                      <span className="block font-medium">{e.label}</span>
                      <span className="text-muted-foreground block text-xs">{e.description}</span>
                    </th>
                    {channels.map((c) => (
                      <td key={c.id} className="px-3 py-4 text-center">
                        <Switch aria-label={`${e.label} by ${c.label}`} checked={values.events[e.id]?.[c.id] ?? false} onCheckedChange={(on) => toggle(e.id, c.id, on)} />
                      </td>
                    ))}
                  </tr>
                ))}
              </tbody>
            </table>
          </div>

          {/* Phones: one card per event with its three switches. */}
          <ul className="mt-4 space-y-3 sm:hidden">
            {events.map((e) => (
              <li key={e.id} className="bg-card rounded-2xl border p-4">
                <p className="text-sm font-medium">{e.label}</p>
                <p className="text-muted-foreground text-xs">{e.description}</p>
                <div className="mt-3 grid grid-cols-3 gap-2">
                  {channels.map((c) => (
                    <label key={c.id} className="flex flex-col items-center gap-2 rounded-xl border py-2.5 text-xs">
                      {c.label}
                      <Switch aria-label={`${e.label} by ${c.label}`} checked={values.events[e.id]?.[c.id] ?? false} onCheckedChange={(on) => toggle(e.id, c.id, on)} />
                    </label>
                  ))}
                </div>
              </li>
            ))}
          </ul>
        </div>

        <div className="bg-card rounded-2xl border p-5 sm:p-6">
          <div className="flex items-start justify-between gap-4">
            <div>
              <h3 className="flex items-center gap-2 text-sm font-semibold"><Moon className="size-4" aria-hidden="true" />Quiet hours</h3>
              <p id={`${quietId}-d`} className="text-muted-foreground mt-1 text-sm">Pause push notifications at night and on days off.</p>
            </div>
            <Switch aria-label="Quiet hours" aria-describedby={`${quietId}-d`} checked={values.quiet.enabled} onCheckedChange={(enabled) => commit({ ...values, quiet: { ...values.quiet, enabled } })} />
          </div>
          <div className={cn("mt-5 grid gap-5 transition-opacity duration-200 motion-reduce:transition-none", !values.quiet.enabled && "pointer-events-none opacity-50")} aria-disabled={!values.quiet.enabled}>
            <div className="grid max-w-md grid-cols-2 gap-4">
              <label className="grid gap-1.5 text-sm font-medium">From<Input type="time" disabled={!values.quiet.enabled} value={values.quiet.from} onChange={(e) => commit({ ...values, quiet: { ...values.quiet, from: e.target.value } })} /></label>
              <label className="grid gap-1.5 text-sm font-medium">Until<Input type="time" disabled={!values.quiet.enabled} value={values.quiet.to} onChange={(e) => commit({ ...values, quiet: { ...values.quiet, to: e.target.value } })} /></label>
            </div>
            <div>
              <p className="mb-2 text-sm font-medium">On these days</p>
              <ToggleGroup type="multiple" variant="outline" aria-label="Days with quiet hours" disabled={!values.quiet.enabled} value={values.quiet.days} onValueChange={(d) => commit({ ...values, quiet: { ...values.quiet, days: d } })}>
                {days.map(([id, short, long]) => (
                  <ToggleGroupItem key={id} value={id} aria-label={long} className="w-9 px-0">{short}</ToggleGroupItem>
                ))}
              </ToggleGroup>
            </div>
          </div>
        </div>
      </div>
    </section>
  )
}


/** The same shapes as the ready form, so nothing jumps when the values arrive. */
function Settings2Skeleton() {
  return (
    <div className="space-y-8" aria-hidden="true">
      <div>
        <Skeleton className="h-4 w-28" />
        <Skeleton className="mt-2 h-3.5 w-64" />
        <div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
          {[0, 1, 2, 3].map((i) => (
            <Skeleton key={i} className="h-16 w-full rounded-xl" />
          ))}
        </div>
      </div>
      <div>
        <Skeleton className="h-4 w-24" />
        <ul className="mt-4 divide-y rounded-2xl border">
          {[0, 1, 2, 3, 4].map((i) => (
            <li key={i} className="flex items-center justify-between gap-4 p-4">
              <div className="grid min-w-0 flex-1 gap-2">
                <Skeleton className="h-4 w-32 max-w-full" />
                <Skeleton className="h-3.5 w-52 max-w-full" />
              </div>
              <div className="flex shrink-0 gap-3">
                {[0, 1, 2].map((c) => (
                  <Skeleton key={c} className="h-5 w-9 rounded-full" />
                ))}
              </div>
            </li>
          ))}
        </ul>
      </div>
    </div>
  )
}

/**
 * With `state="loading"` this shows a skeleton of the form under the real heading while its values are fetched. The other states
 * drive the form itself: `submitting` is a save in flight, `success` the confirmation, `error` the failure.
 */
function Settings2({ state, title = "Notifications", description = "Choose what you hear about and where. Changes save as you make them.", className, ...props }: Settings2Props) {
  if (state !== "loading") return <Settings2Inner state={state} title={title} description={description} className={className} {...props} />
  return (
    <section data-slot="settings-2" data-state="loading" className={cn("mx-auto w-full max-w-4xl px-4 py-8 sm:px-6", className)} {...frameProps(props)}>
      <div>
        <h2 className="text-2xl font-semibold tracking-[-0.03em]">{title}</h2>
        <p className="text-muted-foreground mt-1 text-sm">{description}</p>
      </div>
      <StateView state="loading" className="mt-8" loading={<Settings2Skeleton />} label="Loading your notification settings">
        {null}
      </StateView>
    </section>
  )
}

export { Settings2, type Settings2Props, type Settings2Values, type Settings2Event, type Settings2Channel }
```
