# Settings Notifications

Per-app notification settings with a live lock screen preview that reflects each choice.

Settings Notifications is an open-source block from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/blocks/settings-notifications

**React**

**Example**

```tsx title="components/blocks/settings-notifications.tsx"
"use client"

import { ChatCircleIcon, CheckIcon } from "@phosphor-icons/react"
import * as React from "react"

import {
  Item,
  ItemActions,
  ItemContent,
  ItemGroup,
  ItemSeparator,
  ItemTitle,
} from "@/components/ui/item"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Toggle } from "@/components/ui/toggle"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

const wallpaper =
  "https://images.unsplash.com/photo-1707324148764-99647364afa3?w=600&q=80&auto=format&fit=crop"

type Alert = "lock" | "center" | "banner"

const alerts: { value: Alert; label: string }[] = [
  { value: "lock", label: "Lock Screen" },
  { value: "center", label: "Notification Center" },
  { value: "banner", label: "Banners" },
]

const previewOptions = ["Always", "When Unlocked", "Never"]
const groupingOptions = ["Automatic", "By App", "Off"]

const miniScreenBars: Record<Alert, number[]> = {
  lock: [58, 74],
  center: [12, 29, 46, 63],
  banner: [8],
}

function bannerFootnote(allow: boolean, banner: boolean, style: string) {
  if (!allow) return "Notifications are off"
  if (!banner) return "No banners"
  return style === "persistent"
    ? "Banner stays until dismissed"
    : "Banner shows briefly"
}

function MiniScreen({ kind }: { kind: Alert }) {
  const bars = miniScreenBars[kind]
  return (
    <span
      aria-hidden
      className="relative block h-23 w-16 overflow-hidden rounded-xl bg-linear-170 from-[oklch(0.75_0.08_250)] to-[oklch(0.6_0.1_290)] ring-2 ring-foreground ring-inset"
    >
      {kind === "lock" && (
        <span className="absolute inset-x-0 top-3.5 text-center text-sm font-semibold text-white">
          9:41
        </span>
      )}
      {bars.map((top, index) => (
        <span
          key={top}
          style={{ top, opacity: 1 - index * 0.15 }}
          className="absolute inset-x-2 h-3.25 rounded-sm bg-white shadow-[0_1px_3px_rgb(0_0_0/0.15)]"
        />
      ))}
    </span>
  )
}

function Notification({ body, stacked }: { body: string; stacked?: boolean }) {
  return (
    <div className="relative transition-[opacity,translate] duration-800 ease-[cubic-bezier(0.32,0.72,0,1)] starting:-translate-y-6 starting:opacity-0 motion-reduce:transition-none">
      {stacked && (
        <span className="absolute inset-x-2.5 -bottom-1.5 h-5 rounded-2xl bg-white/55" />
      )}
      <div className="relative flex gap-2.5 rounded-[1.125rem] bg-white/82 px-3 py-2.5 text-black/88 backdrop-blur-xl backdrop-saturate-180">
        <span className="flex size-7.5 shrink-0 items-center justify-center rounded-[7px] bg-green-500 text-white">
          <ChatCircleIcon className="size-4.5 fill-current" />
        </span>
        <div className="flex min-w-0 flex-1 flex-col gap-px">
          <div className="flex items-center gap-1.5">
            <span className="flex-1 text-xs font-semibold">Riley Morgan</span>
            <span className="text-xs text-black/55">now</span>
          </div>
          <p className="text-xs leading-snug">{body}</p>
        </div>
      </div>
    </div>
  )
}

export function SettingsNotifications() {
  const idPrefix = React.useId()
  const [allow, setAllow] = React.useState(true)
  const [enabled, setEnabled] = React.useState<Record<Alert, boolean>>({
    lock: true,
    center: true,
    banner: true,
  })
  const [bannerStyle, setBannerStyle] = React.useState("temporary")
  const [sounds, setSounds] = React.useState(true)
  const [badges, setBadges] = React.useState(true)
  const [previews, setPreviews] = React.useState("Always")
  const [grouping, setGrouping] = React.useState("Automatic")
  const [bannerKey, setBannerKey] = React.useState(0)
  const [bannerVisible, setBannerVisible] = React.useState(true)

  React.useEffect(() => {
    if (bannerStyle !== "temporary" || !bannerVisible) return
    const timer = window.setTimeout(() => setBannerVisible(false), 2600)
    return () => window.clearTimeout(timer)
  }, [bannerStyle, bannerVisible])

  const replay = () => {
    setBannerKey((key) => key + 1)
    setBannerVisible(true)
  }

  const body =
    previews === "Never"
      ? "Notification"
      : "Is the weekend trip to the coast still on?"
  const footnote = bannerFootnote(allow, enabled.banner, bannerStyle)

  return (
    <section className="grid min-h-140 grid-cols-1 bg-background md:grid-cols-[minmax(0,1fr)_18.75rem]">
      <div className="flex flex-col gap-5 px-5 py-6 md:px-8">
        <div className="flex items-center gap-3">
          <span className="flex size-10 items-center justify-center rounded-[0.625rem] bg-green-500 text-white">
            <ChatCircleIcon className="size-6 fill-current" />
          </span>
          <div className="flex flex-col">
            <h1 className="text-2xl font-bold tracking-tight">Messages</h1>
            <p className="text-sm text-muted-foreground">Notifications</p>
          </div>
        </div>
        <ItemGroup variant="inset">
          <Item size="sm" role="listitem">
            <ItemContent>
              <ItemTitle className="text-base font-normal">
                <label htmlFor={`${idPrefix}-allow`}>Allow Notifications</label>
              </ItemTitle>
            </ItemContent>
            <ItemActions>
              <Switch
                id={`${idPrefix}-allow`}
                checked={allow}
                onCheckedChange={(value) => {
                  setAllow(value)
                  replay()
                }}
              />
            </ItemActions>
          </Item>
        </ItemGroup>
        <div
          inert={!allow}
          data-disabled={allow ? undefined : ""}
          className="flex flex-col gap-5 transition-opacity duration-300 data-disabled:opacity-35"
        >
          <h2 className="-mb-3 px-1 text-sm font-semibold">Alerts</h2>
          <ItemGroup variant="inset">
            <div role="listitem" className="flex px-2">
              {alerts.map((alert) => (
                <Toggle
                  key={alert.value}
                  pressed={enabled[alert.value]}
                  onPressedChange={(pressed) => {
                    setEnabled((current) => ({
                      ...current,
                      [alert.value]: pressed,
                    }))
                    replay()
                  }}
                  className="group/alert h-auto flex-1 flex-col gap-2 rounded-xl pt-3.5 pb-3 hover:bg-transparent aria-pressed:bg-transparent aria-pressed:text-foreground aria-pressed:hover:bg-transparent aria-pressed:hover:text-foreground"
                >
                  <MiniScreen kind={alert.value} />
                  <span className="text-sm">{alert.label}</span>
                  <span className="flex size-5.5 items-center justify-center rounded-full border-[1.5px] border-input text-white transition-[background-color,border-color] duration-250 group-aria-pressed/alert:border-transparent group-aria-pressed/alert:bg-primary">
                    <CheckIcon
                      className="size-3 scale-0 transition-[scale] duration-250 ease-[cubic-bezier(0.3,1.25,0.5,1)] group-aria-pressed/alert:scale-100"
                    />
                  </span>
                </Toggle>
              ))}
            </div>
            {enabled.banner && (
              <>
                <ItemSeparator />
                <Item size="sm" role="listitem">
                  <ItemContent>
                    <ItemTitle className="text-base font-normal">
                      Banner Style
                    </ItemTitle>
                  </ItemContent>
                  <ItemActions>
                    <ToggleGroup
                      spacing={0}
                      value={[bannerStyle]}
                      onValueChange={(value: string[]) => {
                        if (!value[0]) return
                        setBannerStyle(value[0])
                        replay()
                      }}
                      aria-label="Banner style"
                    >
                      <ToggleGroupItem value="temporary" size="sm">
                        Temporary
                      </ToggleGroupItem>
                      <ToggleGroupItem value="persistent" size="sm">
                        Persistent
                      </ToggleGroupItem>
                    </ToggleGroup>
                  </ItemActions>
                </Item>
              </>
            )}
            <ItemSeparator />
            <Item size="sm" role="listitem">
              <ItemContent>
                <ItemTitle className="text-base font-normal">
                  <label htmlFor={`${idPrefix}-sounds`}>Sounds</label>
                </ItemTitle>
              </ItemContent>
              <ItemActions>
                <Switch
                  id={`${idPrefix}-sounds`}
                  checked={sounds}
                  onCheckedChange={setSounds}
                />
              </ItemActions>
            </Item>
            <ItemSeparator />
            <Item size="sm" role="listitem">
              <ItemContent>
                <ItemTitle className="text-base font-normal">
                  <label htmlFor={`${idPrefix}-badges`}>Badges</label>
                </ItemTitle>
              </ItemContent>
              <ItemActions>
                <Switch
                  id={`${idPrefix}-badges`}
                  checked={badges}
                  onCheckedChange={setBadges}
                />
              </ItemActions>
            </Item>
          </ItemGroup>
          <h2 className="-mb-3 px-1 text-sm font-semibold">Appearance</h2>
          <ItemGroup variant="inset">
            {[
              {
                label: "Show Previews",
                value: previews,
                options: previewOptions,
                onChange: (value: string) => {
                  setPreviews(value)
                  replay()
                },
              },
              {
                label: "Notification Grouping",
                value: grouping,
                options: groupingOptions,
                onChange: setGrouping,
              },
            ].map((row, index) => (
              <React.Fragment key={row.label}>
                {index > 0 && <ItemSeparator />}
                <Item size="sm" role="listitem">
                  <ItemContent>
                    <ItemTitle className="text-base font-normal">
                      {row.label}
                    </ItemTitle>
                  </ItemContent>
                  <ItemActions>
                    <Select
                      value={row.value}
                      onValueChange={(value) => row.onChange(value as string)}
                    >
                      <SelectTrigger
                        size="sm"
                        aria-label={row.label}
                        className="bg-transparent px-1 text-muted-foreground"
                      >
                        <SelectValue />
                      </SelectTrigger>
                      <SelectContent align="end">
                        {row.options.map((option) => (
                          <SelectItem key={option} value={option}>
                            {option}
                          </SelectItem>
                        ))}
                      </SelectContent>
                    </Select>
                  </ItemActions>
                </Item>
              </React.Fragment>
            ))}
          </ItemGroup>
        </div>
      </div>
      <div className="flex items-center justify-center bg-muted p-5">
        <div className="h-117.5 w-57.5 rounded-[2.625rem] border-8 border-black shadow-[0_0_0_1.5px_var(--color-muted-foreground),0_20px_50px_-20px_rgb(0_0_0/0.4)]">
          <div
            role="img"
            aria-label={`Lock screen preview. ${footnote}.`}
            style={{ backgroundImage: `url(${wallpaper})` }}
            className="relative size-full overflow-hidden rounded-[2.125rem] bg-cover bg-center text-white"
          >
            <span className="absolute inset-0 bg-linear-to-b from-black/15 to-black/35" />
            <span className="absolute top-2 left-1/2 h-5.5 w-18.5 -translate-x-1/2 rounded-full bg-black" />
            <div className="absolute inset-x-0 top-12.5 text-center">
              <p className="text-xs font-semibold opacity-90">
                Tuesday, October 6
              </p>
              <p className="text-6xl leading-none font-semibold tracking-tighter">
                9:41
              </p>
            </div>
            {allow && enabled.banner && bannerVisible && (
              <div key={bannerKey} className="absolute inset-x-2 top-9.5">
                <Notification body={body} />
              </div>
            )}
            {allow && enabled.lock && (
              <div className="absolute inset-x-2 top-75">
                <Notification body={body} stacked={grouping !== "Off"} />
              </div>
            )}
            <p className="absolute inset-x-0 bottom-3.5 text-center text-xs opacity-85">
              {footnote}
            </p>
          </div>
        </div>
      </div>
    </section>
  )
}
```

Per-app notification settings with a live lock screen preview: toggle notifications and choose alert styles, banner style, sounds, badges, preview text and grouping, and the preview updates to match. The preview sits beside the settings on desktop and below them on mobile. Use it for any settings screen where a live preview helps users understand their choices.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/settings-notifications
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add blocks settings-notifications
```

### Manual

1. Install the dependencies:

```bash title="Terminal"
npm install @phosphor-icons/react
```

2. Add the pieces it builds on: [Item](https://ui.suiss.app/docs/components/item), [Select](https://ui.suiss.app/docs/components/select), [Switch](https://ui.suiss.app/docs/components/switch), [Toggle](https://ui.suiss.app/docs/components/toggle), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group).

3. Copy the source into `components/blocks/settings-notifications.tsx`:

```tsx title="components/blocks/settings-notifications.tsx"
"use client"

import { ChatCircleIcon, CheckIcon } from "@phosphor-icons/react"
import * as React from "react"

import {
  Item,
  ItemActions,
  ItemContent,
  ItemGroup,
  ItemSeparator,
  ItemTitle,
} from "@/components/ui/item"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Toggle } from "@/components/ui/toggle"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

const wallpaper =
  "https://images.unsplash.com/photo-1707324148764-99647364afa3?w=600&q=80&auto=format&fit=crop"

type Alert = "lock" | "center" | "banner"

const alerts: { value: Alert; label: string }[] = [
  { value: "lock", label: "Lock Screen" },
  { value: "center", label: "Notification Center" },
  { value: "banner", label: "Banners" },
]

const previewOptions = ["Always", "When Unlocked", "Never"]
const groupingOptions = ["Automatic", "By App", "Off"]

const miniScreenBars: Record<Alert, number[]> = {
  lock: [58, 74],
  center: [12, 29, 46, 63],
  banner: [8],
}

function bannerFootnote(allow: boolean, banner: boolean, style: string) {
  if (!allow) return "Notifications are off"
  if (!banner) return "No banners"
  return style === "persistent"
    ? "Banner stays until dismissed"
    : "Banner shows briefly"
}

function MiniScreen({ kind }: { kind: Alert }) {
  const bars = miniScreenBars[kind]
  return (
    <span
      aria-hidden
      className="relative block h-23 w-16 overflow-hidden rounded-xl bg-linear-170 from-[oklch(0.75_0.08_250)] to-[oklch(0.6_0.1_290)] ring-2 ring-foreground ring-inset"
    >
      {kind === "lock" && (
        <span className="absolute inset-x-0 top-3.5 text-center text-sm font-semibold text-white">
          9:41
        </span>
      )}
      {bars.map((top, index) => (
        <span
          key={top}
          style={{ top, opacity: 1 - index * 0.15 }}
          className="absolute inset-x-2 h-3.25 rounded-sm bg-white shadow-[0_1px_3px_rgb(0_0_0/0.15)]"
        />
      ))}
    </span>
  )
}

function Notification({ body, stacked }: { body: string; stacked?: boolean }) {
  return (
    <div className="relative transition-[opacity,translate] duration-800 ease-[cubic-bezier(0.32,0.72,0,1)] starting:-translate-y-6 starting:opacity-0 motion-reduce:transition-none">
      {stacked && (
        <span className="absolute inset-x-2.5 -bottom-1.5 h-5 rounded-2xl bg-white/55" />
      )}
      <div className="relative flex gap-2.5 rounded-[1.125rem] bg-white/82 px-3 py-2.5 text-black/88 backdrop-blur-xl backdrop-saturate-180">
        <span className="flex size-7.5 shrink-0 items-center justify-center rounded-[7px] bg-green-500 text-white">
          <ChatCircleIcon className="size-4.5 fill-current" />
        </span>
        <div className="flex min-w-0 flex-1 flex-col gap-px">
          <div className="flex items-center gap-1.5">
            <span className="flex-1 text-xs font-semibold">Riley Morgan</span>
            <span className="text-xs text-black/55">now</span>
          </div>
          <p className="text-xs leading-snug">{body}</p>
        </div>
      </div>
    </div>
  )
}

export function SettingsNotifications() {
  const idPrefix = React.useId()
  const [allow, setAllow] = React.useState(true)
  const [enabled, setEnabled] = React.useState<Record<Alert, boolean>>({
    lock: true,
    center: true,
    banner: true,
  })
  const [bannerStyle, setBannerStyle] = React.useState("temporary")
  const [sounds, setSounds] = React.useState(true)
  const [badges, setBadges] = React.useState(true)
  const [previews, setPreviews] = React.useState("Always")
  const [grouping, setGrouping] = React.useState("Automatic")
  const [bannerKey, setBannerKey] = React.useState(0)
  const [bannerVisible, setBannerVisible] = React.useState(true)

  React.useEffect(() => {
    if (bannerStyle !== "temporary" || !bannerVisible) return
    const timer = window.setTimeout(() => setBannerVisible(false), 2600)
    return () => window.clearTimeout(timer)
  }, [bannerStyle, bannerVisible])

  const replay = () => {
    setBannerKey((key) => key + 1)
    setBannerVisible(true)
  }

  const body =
    previews === "Never"
      ? "Notification"
      : "Is the weekend trip to the coast still on?"
  const footnote = bannerFootnote(allow, enabled.banner, bannerStyle)

  return (
    <section className="grid min-h-140 grid-cols-1 bg-background md:grid-cols-[minmax(0,1fr)_18.75rem]">
      <div className="flex flex-col gap-5 px-5 py-6 md:px-8">
        <div className="flex items-center gap-3">
          <span className="flex size-10 items-center justify-center rounded-[0.625rem] bg-green-500 text-white">
            <ChatCircleIcon className="size-6 fill-current" />
          </span>
          <div className="flex flex-col">
            <h1 className="text-2xl font-bold tracking-tight">Messages</h1>
            <p className="text-sm text-muted-foreground">Notifications</p>
          </div>
        </div>
        <ItemGroup variant="inset">
          <Item size="sm" role="listitem">
            <ItemContent>
              <ItemTitle className="text-base font-normal">
                <label htmlFor={`${idPrefix}-allow`}>Allow Notifications</label>
              </ItemTitle>
            </ItemContent>
            <ItemActions>
              <Switch
                id={`${idPrefix}-allow`}
                checked={allow}
                onCheckedChange={(value) => {
                  setAllow(value)
                  replay()
                }}
              />
            </ItemActions>
          </Item>
        </ItemGroup>
        <div
          inert={!allow}
          data-disabled={allow ? undefined : ""}
          className="flex flex-col gap-5 transition-opacity duration-300 data-disabled:opacity-35"
        >
          <h2 className="-mb-3 px-1 text-sm font-semibold">Alerts</h2>
          <ItemGroup variant="inset">
            <div role="listitem" className="flex px-2">
              {alerts.map((alert) => (
                <Toggle
                  key={alert.value}
                  pressed={enabled[alert.value]}
                  onPressedChange={(pressed) => {
                    setEnabled((current) => ({
                      ...current,
                      [alert.value]: pressed,
                    }))
                    replay()
                  }}
                  className="group/alert h-auto flex-1 flex-col gap-2 rounded-xl pt-3.5 pb-3 hover:bg-transparent aria-pressed:bg-transparent aria-pressed:text-foreground aria-pressed:hover:bg-transparent aria-pressed:hover:text-foreground"
                >
                  <MiniScreen kind={alert.value} />
                  <span className="text-sm">{alert.label}</span>
                  <span className="flex size-5.5 items-center justify-center rounded-full border-[1.5px] border-input text-white transition-[background-color,border-color] duration-250 group-aria-pressed/alert:border-transparent group-aria-pressed/alert:bg-primary">
                    <CheckIcon
                      className="size-3 scale-0 transition-[scale] duration-250 ease-[cubic-bezier(0.3,1.25,0.5,1)] group-aria-pressed/alert:scale-100"
                    />
                  </span>
                </Toggle>
              ))}
            </div>
            {enabled.banner && (
              <>
                <ItemSeparator />
                <Item size="sm" role="listitem">
                  <ItemContent>
                    <ItemTitle className="text-base font-normal">
                      Banner Style
                    </ItemTitle>
                  </ItemContent>
                  <ItemActions>
                    <ToggleGroup
                      spacing={0}
                      value={[bannerStyle]}
                      onValueChange={(value: string[]) => {
                        if (!value[0]) return
                        setBannerStyle(value[0])
                        replay()
                      }}
                      aria-label="Banner style"
                    >
                      <ToggleGroupItem value="temporary" size="sm">
                        Temporary
                      </ToggleGroupItem>
                      <ToggleGroupItem value="persistent" size="sm">
                        Persistent
                      </ToggleGroupItem>
                    </ToggleGroup>
                  </ItemActions>
                </Item>
              </>
            )}
            <ItemSeparator />
            <Item size="sm" role="listitem">
              <ItemContent>
                <ItemTitle className="text-base font-normal">
                  <label htmlFor={`${idPrefix}-sounds`}>Sounds</label>
                </ItemTitle>
              </ItemContent>
              <ItemActions>
                <Switch
                  id={`${idPrefix}-sounds`}
                  checked={sounds}
                  onCheckedChange={setSounds}
                />
              </ItemActions>
            </Item>
            <ItemSeparator />
            <Item size="sm" role="listitem">
              <ItemContent>
                <ItemTitle className="text-base font-normal">
                  <label htmlFor={`${idPrefix}-badges`}>Badges</label>
                </ItemTitle>
              </ItemContent>
              <ItemActions>
                <Switch
                  id={`${idPrefix}-badges`}
                  checked={badges}
                  onCheckedChange={setBadges}
                />
              </ItemActions>
            </Item>
          </ItemGroup>
          <h2 className="-mb-3 px-1 text-sm font-semibold">Appearance</h2>
          <ItemGroup variant="inset">
            {[
              {
                label: "Show Previews",
                value: previews,
                options: previewOptions,
                onChange: (value: string) => {
                  setPreviews(value)
                  replay()
                },
              },
              {
                label: "Notification Grouping",
                value: grouping,
                options: groupingOptions,
                onChange: setGrouping,
              },
            ].map((row, index) => (
              <React.Fragment key={row.label}>
                {index > 0 && <ItemSeparator />}
                <Item size="sm" role="listitem">
                  <ItemContent>
                    <ItemTitle className="text-base font-normal">
                      {row.label}
                    </ItemTitle>
                  </ItemContent>
                  <ItemActions>
                    <Select
                      value={row.value}
                      onValueChange={(value) => row.onChange(value as string)}
                    >
                      <SelectTrigger
                        size="sm"
                        aria-label={row.label}
                        className="bg-transparent px-1 text-muted-foreground"
                      >
                        <SelectValue />
                      </SelectTrigger>
                      <SelectContent align="end">
                        {row.options.map((option) => (
                          <SelectItem key={option} value={option}>
                            {option}
                          </SelectItem>
                        ))}
                      </SelectContent>
                    </Select>
                  </ItemActions>
                </Item>
              </React.Fragment>
            ))}
          </ItemGroup>
        </div>
      </div>
      <div className="flex items-center justify-center bg-muted p-5">
        <div className="h-117.5 w-57.5 rounded-[2.625rem] border-8 border-black shadow-[0_0_0_1.5px_var(--color-muted-foreground),0_20px_50px_-20px_rgb(0_0_0/0.4)]">
          <div
            role="img"
            aria-label={`Lock screen preview. ${footnote}.`}
            style={{ backgroundImage: `url(${wallpaper})` }}
            className="relative size-full overflow-hidden rounded-[2.125rem] bg-cover bg-center text-white"
          >
            <span className="absolute inset-0 bg-linear-to-b from-black/15 to-black/35" />
            <span className="absolute top-2 left-1/2 h-5.5 w-18.5 -translate-x-1/2 rounded-full bg-black" />
            <div className="absolute inset-x-0 top-12.5 text-center">
              <p className="text-xs font-semibold opacity-90">
                Tuesday, October 6
              </p>
              <p className="text-6xl leading-none font-semibold tracking-tighter">
                9:41
              </p>
            </div>
            {allow && enabled.banner && bannerVisible && (
              <div key={bannerKey} className="absolute inset-x-2 top-9.5">
                <Notification body={body} />
              </div>
            )}
            {allow && enabled.lock && (
              <div className="absolute inset-x-2 top-75">
                <Notification body={body} stacked={grouping !== "Off"} />
              </div>
            )}
            <p className="absolute inset-x-0 bottom-3.5 text-center text-xs opacity-85">
              {footnote}
            </p>
          </div>
        </div>
      </div>
    </section>
  )
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { SettingsNotifications } from "@/components/blocks/settings-notifications"
```

```tsx
<SettingsNotifications />
```

Every setting is local state, so persist each one to your settings store. `alerts`, `previewOptions` and `groupingOptions` define the choices. Replace the `wallpaper` image and the sample sender and message in the preview.

## Notes

When notifications are off, the dependent settings are dimmed and skipped by keyboard, and the preview has a text summary for screen readers. The wallpaper is an Unsplash placeholder.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/blocks-settings-notifications--default.light.webp)

```tsx title="uniwind/components/blocks/settings-notifications.tsx"
import { cn } from "cn"
import { ChatCircleIcon, CheckIcon } from "phosphor-react-native"
import * as React from "react"
import { Image, ScrollView, View } from "react-native"
import { FadeInUp, ReduceMotion } from "react-native-reanimated"
import { Picker, PickerItem } from "@/components/patterns/picker"
import { AnimatedView } from "@/components/ui/animated-view"
import { Icon } from "@/components/ui/icon"
import {
  Item,
  ItemActions,
  ItemContent,
  ItemGroup,
  ItemSeparator,
  ItemTitle,
} from "@/components/ui/item"
import { Screen } from "@/components/ui/screen"
import { Switch } from "@/components/ui/switch"
import { Text } from "@/components/ui/text"
import { Toggle } from "@/components/ui/toggle"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

const WALLPAPER =
  "https://images.unsplash.com/photo-1707324148764-99647364afa3?w=600&q=80&auto=format&fit=crop"

type Alert = "lock" | "center" | "banner"

const ALERTS: { value: Alert; label: string }[] = [
  { value: "lock", label: "Lock Screen" },
  { value: "center", label: "Notification Center" },
  { value: "banner", label: "Banners" },
]

const PREVIEW_OPTIONS = ["Always", "When Unlocked", "Never"]

const GROUPING_OPTIONS = ["Automatic", "By App", "Off"]

const MINI_SCREEN_BARS: Record<Alert, number[]> = {
  lock: [58, 74],
  center: [12, 29, 46, 63],
  banner: [8],
}

function bannerFootnote(allow: boolean, banner: boolean, style: string) {
  if (!allow) return "Notifications are off"
  if (!banner) return "No banners"
  return style === "persistent"
    ? "Banner stays until dismissed"
    : "Banner shows briefly"
}

function MiniScreen({ kind }: { kind: Alert }) {
  return (
    <View
      accessible={false}
      importantForAccessibility="no-hide-descendants"
      className="h-23 w-16 overflow-hidden rounded-xl border-2 border-foreground bg-indigo-500"
    >
      {kind === "lock" ? (
        <Text className="absolute inset-x-0 top-3 text-center text-sm font-semibold text-white">
          9:41
        </Text>
      ) : null}
      {MINI_SCREEN_BARS[kind].map((top, index) => (
        <View
          key={top}
          style={{ top: top - 2, opacity: 1 - index * 0.15 }}
          className="absolute inset-x-1.5 h-3.25 rounded-sm bg-white"
        />
      ))}
    </View>
  )
}

function Notification({ body, stacked }: { body: string; stacked?: boolean }) {
  return (
    <AnimatedView
      entering={FadeInUp.duration(600).reduceMotion(ReduceMotion.System)}
    >
      {stacked ? (
        <View className="absolute inset-x-2.5 -bottom-1.5 h-5 rounded-2xl bg-card opacity-55" />
      ) : null}
      <View className="flex-row gap-2.5 rounded-[1.125rem] bg-card px-3 py-2.5 opacity-95">
        <View className="size-7.5 shrink-0 items-center justify-center rounded-[7px] bg-green-500">
          <Icon
            as={ChatCircleIcon}
            weight="fill"
            className="size-4.5 text-primary-foreground"
          />
        </View>
        <View className="min-w-0 flex-1 gap-px">
          <View className="flex-row items-center gap-1.5">
            <Text className="flex-1 text-xs font-semibold">Riley Morgan</Text>
            <Text className="text-xs text-muted-foreground">now</Text>
          </View>
          <Text className="text-xs">{body}</Text>
        </View>
      </View>
    </AnimatedView>
  )
}

function SwitchRow({
  id,
  label,
  checked,
  onCheckedChange,
}: {
  id: string
  label: string
  checked: boolean
  onCheckedChange: (checked: boolean) => void
}) {
  return (
    <Item size="sm">
      <ItemContent>
        <ItemTitle nativeID={id} className="font-normal">
          {label}
        </ItemTitle>
      </ItemContent>
      <ItemActions>
        <Switch
          checked={checked}
          onCheckedChange={onCheckedChange}
          accessibilityLabelledBy={id}
        />
      </ItemActions>
    </Item>
  )
}

function SettingsNotifications() {
  const [allow, setAllow] = React.useState(true)
  const [enabled, setEnabled] = React.useState<Record<Alert, boolean>>({
    lock: true,
    center: true,
    banner: true,
  })
  const [bannerStyle, setBannerStyle] = React.useState("temporary")
  const [sounds, setSounds] = React.useState(true)
  const [badges, setBadges] = React.useState(true)
  const [previews, setPreviews] = React.useState("Always")
  const [grouping, setGrouping] = React.useState("Automatic")
  const [bannerKey, setBannerKey] = React.useState(0)
  const [bannerVisible, setBannerVisible] = React.useState(true)

  React.useEffect(() => {
    if (bannerStyle !== "temporary" || !bannerVisible) return
    const timer = setTimeout(() => setBannerVisible(false), 2600)
    return () => clearTimeout(timer)
  }, [bannerStyle, bannerVisible])

  const replay = () => {
    setBannerKey((key) => key + 1)
    setBannerVisible(true)
  }

  const body =
    previews === "Never"
      ? "Notification"
      : "Is the weekend trip to the coast still on?"
  const footnote = bannerFootnote(allow, enabled.banner, bannerStyle)

  const selects = [
    {
      label: "Show Previews",
      value: previews,
      options: PREVIEW_OPTIONS,
      onChange: (value: string) => {
        setPreviews(value)
        replay()
      },
    },
    {
      label: "Notification Grouping",
      value: grouping,
      options: GROUPING_OPTIONS,
      onChange: setGrouping,
    },
  ]

  return (
    <Screen edges={["top", "right", "left"]}>
      <ScrollView contentContainerClassName="gap-5 px-4 pt-4 pb-8">
        <View className="flex-row items-center gap-3">
          <View className="size-10 items-center justify-center rounded-[0.625rem] bg-green-500">
            <Icon
              as={ChatCircleIcon}
              weight="fill"
              className="size-6 text-primary-foreground"
            />
          </View>
          <View>
            <Text role="heading" className="text-2xl font-bold">
              Messages
            </Text>
            <Text className="text-sm text-muted-foreground">Notifications</Text>
          </View>
        </View>
        <View
          accessible
          accessibilityRole="image"
          accessibilityLabel={`Lock screen preview. ${footnote}.`}
          className="h-75 overflow-hidden rounded-3xl bg-black"
        >
          <Image
            source={{ uri: WALLPAPER }}
            resizeMode="cover"
            accessibilityIgnoresInvertColors
            className="absolute inset-0 size-full"
          />
          <View className="absolute inset-0 bg-black/50 opacity-40" />
          <View className="absolute inset-x-0 top-10 items-center">
            <Text className="text-xs font-semibold text-white opacity-90">
              Tuesday, October 6
            </Text>
            <Text className="text-3xl font-semibold text-white">9:41</Text>
          </View>
          {allow && enabled.banner && bannerVisible ? (
            <View key={bannerKey} className="absolute inset-x-2 top-2">
              <Notification body={body} />
            </View>
          ) : null}
          {allow && enabled.lock ? (
            <View className="absolute inset-x-2 top-40">
              <Notification body={body} stacked={grouping !== "Off"} />
            </View>
          ) : null}
          <Text className="absolute inset-x-0 bottom-3.5 text-center text-xs text-white opacity-85">
            {footnote}
          </Text>
        </View>
        <ItemGroup variant="inset">
          <SwitchRow
            id="settings-notifications-allow"
            label="Allow Notifications"
            checked={allow}
            onCheckedChange={(value) => {
              setAllow(value)
              replay()
            }}
          />
        </ItemGroup>
        <View
          style={{ pointerEvents: allow ? "auto" : "none" }}
          accessibilityState={{ disabled: !allow }}
          className={cn("gap-5", !allow && "opacity-35")}
        >
          <Text role="heading" className="-mb-3 px-1 text-sm font-semibold">
            Alerts
          </Text>
          <ItemGroup variant="inset">
            <View className="flex-row px-2">
              {ALERTS.map((alert) => {
                const on = enabled[alert.value]
                return (
                  <Toggle
                    key={alert.value}
                    pressed={on}
                    onPressedChange={(pressed) => {
                      setEnabled((current) => ({
                        ...current,
                        [alert.value]: pressed,
                      }))
                      replay()
                    }}
                    accessibilityLabel={alert.label}
                    className="h-auto flex-1 flex-col gap-2 rounded-xl bg-transparent pt-3.5 pb-3 active:bg-transparent"
                  >
                    <MiniScreen kind={alert.value} />
                    <Text className="text-center text-sm text-foreground">
                      {alert.label}
                    </Text>
                    <View
                      className={cn(
                        "size-5.5 items-center justify-center rounded-full border-[1.5px]",
                        on ? "border-transparent bg-primary" : "border-input"
                      )}
                    >
                      {on ? (
                        <Icon
                          as={CheckIcon}
                          weight="bold"
                          className="size-3 text-primary-foreground"
                        />
                      ) : null}
                    </View>
                  </Toggle>
                )
              })}
            </View>
            {enabled.banner ? (
              <>
                <ItemSeparator />
                <Item size="sm">
                  <ItemContent>
                    <ItemTitle className="font-normal">Banner Style</ItemTitle>
                  </ItemContent>
                  <ItemActions>
                    <ToggleGroup
                      spacing={0}
                      size="sm"
                      value={[bannerStyle]}
                      onValueChange={(value) => {
                        if (!value[0]) return
                        setBannerStyle(value[0])
                        replay()
                      }}
                      accessibilityLabel="Banner style"
                    >
                      <ToggleGroupItem value="temporary">
                        <Text>Temporary</Text>
                      </ToggleGroupItem>
                      <ToggleGroupItem value="persistent">
                        <Text>Persistent</Text>
                      </ToggleGroupItem>
                    </ToggleGroup>
                  </ItemActions>
                </Item>
              </>
            ) : null}
            <ItemSeparator />
            <SwitchRow
              id="settings-notifications-sounds"
              label="Sounds"
              checked={sounds}
              onCheckedChange={setSounds}
            />
            <ItemSeparator />
            <SwitchRow
              id="settings-notifications-badges"
              label="Badges"
              checked={badges}
              onCheckedChange={setBadges}
            />
          </ItemGroup>
          <Text role="heading" className="-mb-3 px-1 text-sm font-semibold">
            Appearance
          </Text>
          <ItemGroup variant="inset">
            {selects.map((row, index) => (
              <React.Fragment key={row.label}>
                {index > 0 ? <ItemSeparator /> : null}
                <Item size="sm" className="py-1.5">
                  <ItemContent>
                    <ItemTitle className="font-normal">{row.label}</ItemTitle>
                  </ItemContent>
                  <ItemActions>
                    <Picker
                      size="sm"
                      value={row.value}
                      onValueChange={row.onChange}
                      accessibilityLabel={row.label}
                      className="bg-transparent px-1"
                    >
                      {row.options.map((option) => (
                        <PickerItem key={option} value={option}>
                          {option}
                        </PickerItem>
                      ))}
                    </Picker>
                  </ItemActions>
                </Item>
              </React.Fragment>
            ))}
          </ItemGroup>
        </View>
      </ScrollView>
    </Screen>
  )
}

export { SettingsNotifications }
```

```swift title="swift/Blocks/SettingsNotifications.swift"
import PhosphorSwift
import SwiftUI

public struct SettingsNotifications: View {
  enum Alert: String, CaseIterable, Sendable {
    case lock, center, banner

    var label: LocalizedStringKey {
      switch self {
      case .lock: "Lock Screen"
      case .center: "Notification Center"
      case .banner: "Banners"
      }
    }

    var bars: [CGFloat] {
      switch self {
      case .lock: [58, 74]
      case .center: [12, 29, 46, 63]
      case .banner: [8]
      }
    }
  }

  static let wallpaper = URL(string: "https://images.unsplash.com/photo-1707324148764-99647364afa3?w=600&q=80&auto=format&fit=crop")
  static var previewOptions: [String] { ["Always", "When Unlocked", "Never"] }
  static var groupingOptions: [String] { ["Automatic", "By App", "Off"] }

  @State private var allow: Bool
  @State private var enabled: Set<Alert> = Set(Alert.allCases)
  @State private var bannerStyle: String? = "temporary"
  @State private var sounds = true
  @State private var badges = true
  @State private var previews: String? = "Always"
  @State private var grouping: String? = "Automatic"
  @State private var bannerKey = 0
  @State private var bannerVisible = true
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init() {
    self.init(allow: true)
  }

  init(allow: Bool) {
    _allow = State(initialValue: allow)
  }

  private var footnote: LocalizedStringKey {
    if !allow { return "Notifications are off" }
    if !enabled.contains(.banner) { return "No banners" }
    return bannerStyle == "persistent" ? "Banner stays until dismissed" : "Banner shows briefly"
  }

  private var message: LocalizedStringKey {
    previews == "Never" ? "Notification" : "Is the weekend trip to the coast still on?"
  }

  public var body: some View {
    Screen {
      KeyboardScrollView {
        VStack(alignment: .leading, spacing: 20) {
          heading
          ItemGroup(.inset) {
            switchRow("Allow Notifications", isOn: Binding(
              get: { allow },
              set: { value in
                allow = value
                replay()
              }
            ))
          }
          VStack(alignment: .leading, spacing: 20) {
            sectionTitle("Alerts")
            ItemGroup(.inset) {
              HStack(spacing: 0) {
                ForEach(Alert.allCases, id: \.self) { alert in
                  SettingsNotificationsAlert(alert: alert, on: enabled.contains(alert)) {
                    if enabled.contains(alert) {
                      enabled.remove(alert)
                    } else {
                      enabled.insert(alert)
                    }
                    replay()
                  }
                }
              }
              .padding(.horizontal, 8)
              if enabled.contains(.banner) {
                ItemSeparator()
                Item(size: .sm) {
                  ItemContent {
                    ItemTitle { Text("Banner Style").fontWeight(.regular) }
                  }
                  ItemActions {
                    ToggleGroup(selection: Binding(
                      get: { bannerStyle },
                      set: { value in
                        guard let value else { return }
                        bannerStyle = value
                        replay()
                      }
                    ), size: .sm, spacing: 0) {
                      ToggleGroupItem("Temporary", value: "temporary")
                      ToggleGroupItem("Persistent", value: "persistent")
                    }
                    .accessibilityLabel(Text("Banner style"))
                  }
                }
              }
              ItemSeparator()
              switchRow("Sounds", isOn: $sounds)
              ItemSeparator()
              switchRow("Badges", isOn: $badges)
            }
            sectionTitle("Appearance")
            ItemGroup(.inset) {
              pickerRow("Show Previews", selection: Binding(
                get: { previews },
                set: { value in
                  previews = value
                  replay()
                }
              ), options: Self.previewOptions)
              ItemSeparator()
              pickerRow("Notification Grouping", selection: $grouping, options: Self.groupingOptions)
            }
          }
          .disabled(!allow)
          .opacity(allow ? 1 : 0.35)
          .animation(.easeInOut(duration: 0.3), value: allow)
          lockScreen
            .frame(maxWidth: .infinity)
            .padding(.vertical, 20)
            .background(EsuissColor.muted, in: RoundedRectangle(cornerRadius: Radius.xl3, style: .continuous))
        }
        .padding(.horizontal, 20)
        .padding(.vertical, 24)
      }
    }
    .task(id: [bannerStyle == "temporary", bannerVisible, bannerKey != 0]) {
      guard bannerStyle == "temporary", bannerVisible else { return }
      try? await Task.sleep(for: .milliseconds(2600))
      guard !Task.isCancelled else { return }
      withAnimation(reduceMotion ? nil : .easeOut(duration: 0.4)) { bannerVisible = false }
    }
  }

  private var heading: some View {
    HStack(spacing: 12) {
      Ph.chatCircle.fill
        .frame(width: 24, height: 24)
        .foregroundStyle(EsuissColor.primaryForeground)
        .frame(width: 40, height: 40)
        .background(Palette.green500, in: RoundedRectangle(cornerRadius: Radius.lg, style: .continuous))
        .accessibilityHidden(true)
      VStack(alignment: .leading, spacing: 0) {
        Text("Messages")
          .scaledFont(.xl2, weight: .bold)
          .accessibilityAddTraits(.isHeader)
        Text("Notifications")
          .scaledFont(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
      }
    }
  }

  private func sectionTitle(_ title: LocalizedStringKey) -> some View {
    Text(title)
      .scaledFont(.sm, weight: .semibold)
      .padding(.horizontal, 4)
      .padding(.bottom, -12)
      .accessibilityAddTraits(.isHeader)
  }

  private func switchRow(_ title: LocalizedStringKey, isOn: Binding<Bool>) -> some View {
    Item(size: .sm) {
      ItemContent {
        ItemTitle { Text(title).fontWeight(.regular) }
      }
      ItemActions {
        Toggle(isOn: isOn) { Text(title) }
          .labelsHidden()
          .accessibilityLabel(Text(title))
          .toggleStyle(SwitchStyle())
      }
    }
  }

  private func pickerRow(_ title: LocalizedStringKey, selection: Binding<String?>, options: [String]) -> some View {
    Item(size: .sm) {
      ItemContent {
        ItemTitle { Text(title).fontWeight(.regular) }
      }
      ItemActions {
        OptionPicker(selection: selection, size: .sm) {
          ForEach(options, id: \.self) { option in
            OptionPickerItem(LocalizedStringKey(option), value: option)
          }
        }
        .fixedSize()
        .accessibilityLabel(Text(title))
      }
    }
  }

  private var lockScreen: some View {
    Palette.black
      .overlay {
        AsyncImage(url: Self.wallpaper) { phase in
          if let image = phase.image {
            image.resizable().scaledToFill()
          }
        }
      }
      .overlay {
        LinearGradient(colors: [Palette.black.opacity(0.15), Palette.black.opacity(0.35)], startPoint: .top, endPoint: .bottom)
      }
      .overlay(alignment: .top) {
        ZStack(alignment: .top) {
          Capsule()
            .fill(Palette.black)
            .frame(width: 74, height: 22)
            .padding(.top, 8)
          VStack(spacing: 0) {
            Text("Tuesday, October 6")
              .scaledFont(.xs, weight: .semibold)
              .opacity(0.9)
            Text(verbatim: "9:41")
              .scaledFont(.xl3, weight: .semibold)
          }
          .padding(.top, 50)
          if allow && enabled.contains(.lock) {
            SettingsNotificationsCard(message: message, stacked: grouping != "Off")
              .padding(.horizontal, 8)
              .padding(.top, 300)
          }
          if allow && enabled.contains(.banner) && bannerVisible {
            SettingsNotificationsCard(message: message, stacked: false)
              .id(bannerKey)
              .padding(.horizontal, 8)
              .padding(.top, 38)
              .transition(reduceMotion ? .opacity : .move(edge: .top).combined(with: .opacity))
          }
          Text(footnote)
            .scaledFont(.xs)
            .opacity(0.85)
            .frame(maxHeight: .infinity, alignment: .bottom)
            .padding(.bottom, 14)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
      }
      .foregroundStyle(Palette.white)
      .frame(width: 214, height: 454)
      .clipShape(RoundedRectangle(cornerRadius: Radius.xl3, style: .continuous))
      .padding(8)
      .background(Palette.black, in: RoundedRectangle(cornerRadius: Radius.xl3 + 8, style: .continuous))
      .accessibilityElement(children: .ignore)
      .accessibilityLabel(Text("Lock screen preview. \(Text(footnote))."))
  }

  private func replay() {
    bannerKey += 1
    withAnimation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.8)) { bannerVisible = true }
  }
}

private struct SettingsNotificationsAlert: View {
  let alert: SettingsNotifications.Alert
  let on: Bool
  let action: () -> Void

  var body: some View {
    Button(action: action) {
      VStack(spacing: 8) {
        SettingsNotificationsMini(alert: alert)
        Text(alert.label)
          .scaledFont(.sm)
          .foregroundStyle(EsuissColor.foreground)
          .lineLimit(1)
          .minimumScaleFactor(0.8)
        ZStack {
          Circle()
            .strokeBorder(on ? Color.clear : EsuissColor.input, lineWidth: 1.5)
            .background(on ? EsuissColor.primary : .clear, in: Circle())
          Ph.check.bold
            .frame(width: 12, height: 12)
            .foregroundStyle(EsuissColor.primaryForeground)
            .scaleEffect(on ? 1 : 0)
        }
        .frame(width: 22, height: 22)
        .animation(.spring(response: 0.25, dampingFraction: 0.6), value: on)
      }
      .padding(.top, 14)
      .padding(.bottom, 12)
      .frame(maxWidth: .infinity)
      .contentShape(Rectangle())
    }
    .buttonStyle(.plain)
    .accessibilityElement(children: .ignore)
    .accessibilityLabel(Text(alert.label))
    .accessibilityAddTraits(on ? [.isButton, .isSelected] : .isButton)
  }
}

private struct SettingsNotificationsMini: View {
  let alert: SettingsNotifications.Alert

  var body: some View {
    let shape = RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
    ZStack(alignment: .top) {
      Palette.indigo500
      if alert == .lock {
        Text(verbatim: "9:41")
          .scaledFont(.sm, weight: .semibold)
          .foregroundStyle(Palette.white)
          .padding(.top, 14)
      }
      ForEach(Array(alert.bars.enumerated()), id: \.offset) { index, top in
        RoundedRectangle(cornerRadius: Radius.xs, style: .continuous)
          .fill(Palette.white)
          .frame(height: 13)
          .opacity(1 - Double(index) * 0.15)
          .shadow(Shadow(color: Palette.black.opacity(0.15), radius: 1.5, y: 1))
          .padding(.horizontal, 8)
          .padding(.top, top)
      }
    }
    .frame(width: 64, height: 92)
    .clipShape(shape)
    .overlay { shape.strokeBorder(EsuissColor.foreground, lineWidth: 2) }
    .accessibilityHidden(true)
  }
}

private struct SettingsNotificationsCard: View {
  let message: LocalizedStringKey
  let stacked: Bool

  var body: some View {
    ZStack(alignment: .bottom) {
      if stacked {
        RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
          .fill(Palette.white.opacity(0.55))
          .frame(height: 20)
          .padding(.horizontal, 10)
          .offset(y: 6)
      }
      HStack(alignment: .top, spacing: 10) {
        Ph.chatCircle.fill
          .frame(width: 18, height: 18)
          .foregroundStyle(Palette.white)
          .frame(width: 30, height: 30)
          .background(Palette.green500, in: RoundedRectangle(cornerRadius: Radius.md, style: .continuous))
        VStack(alignment: .leading, spacing: 1) {
          HStack {
            Text(verbatim: "Riley Morgan")
              .scaledFont(.xs, weight: .semibold)
              .frame(maxWidth: .infinity, alignment: .leading)
            Text("now")
              .scaledFont(.xs)
              .foregroundStyle(Palette.black.opacity(0.55))
          }
          Text(message)
            .scaledFont(.xs)
            .fixedSize(horizontal: false, vertical: true)
        }
      }
      .foregroundStyle(Palette.black.opacity(0.88))
      .padding(.horizontal, 12)
      .padding(.vertical, 10)
      .background(Palette.white.opacity(0.82), in: RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous))
    }
  }
}
```

```kt title="kotlin/blocks/SettingsNotifications.kt"
package com.esuiss.ui.blocks

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.bold.Check
import com.adamglin.phosphoricons.fill.ChatCircle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Item
import com.esuiss.ui.components.ItemActions
import com.esuiss.ui.components.ItemContent
import com.esuiss.ui.components.ItemGroup
import com.esuiss.ui.components.ItemGroupVariant
import com.esuiss.ui.components.ItemSeparator
import com.esuiss.ui.components.ItemSize
import com.esuiss.ui.components.ItemTitle
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.SelectSize
import com.esuiss.ui.components.Switch
import com.esuiss.ui.components.ToggleGroup
import com.esuiss.ui.components.ToggleGroupItem
import com.esuiss.ui.components.ToggleSize
import com.esuiss.ui.patterns.Picker
import com.esuiss.ui.patterns.PickerItemData
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Shadow
import com.esuiss.ui.theme.Typography
import com.esuiss.ui.theme.shadow
import kotlinx.coroutines.delay

private val PopEasing = CubicBezierEasing(0.3f, 1.25f, 0.5f, 1f)

private val RevealEasing = CubicBezierEasing(0.32f, 0.72f, 0f, 1f)

private enum class AlertKind(val label: String, val bars: List<Int>) {
    Lock("Lock Screen", listOf(58, 74)),
    Center("Notification Center", listOf(12, 29, 46, 63)),
    Banner("Banners", listOf(8)),
}

private val PreviewOptions = listOf("Always", "When Unlocked", "Never").map { PickerItemData(it, it) }

private val GroupingOptions = listOf("Automatic", "By App", "Off").map { PickerItemData(it, it) }

private fun bannerFootnote(allow: Boolean, banner: Boolean, style: String): String = when {
    !allow -> "Notifications are off"
    !banner -> "No banners"
    style == "persistent" -> "Banner stays until dismissed"
    else -> "Banner shows briefly"
}

@Composable
fun SettingsNotifications(modifier: Modifier = Modifier) {
    val colors = EsuissTheme.colors
    var allow by rememberSaveable { mutableStateOf(true) }
    val enabled = remember { mutableStateMapOf(AlertKind.Lock to true, AlertKind.Center to true, AlertKind.Banner to true) }
    var bannerStyle by rememberSaveable { mutableStateOf("temporary") }
    var sounds by rememberSaveable { mutableStateOf(true) }
    var badges by rememberSaveable { mutableStateOf(true) }
    var previews by rememberSaveable { mutableStateOf("Always") }
    var grouping by rememberSaveable { mutableStateOf("Automatic") }
    var bannerKey by remember { mutableIntStateOf(0) }
    var bannerVisible by remember { mutableStateOf(true) }

    LaunchedEffect(bannerStyle, bannerVisible, bannerKey) {
        if (bannerStyle != "temporary" || !bannerVisible) return@LaunchedEffect
        delay(2600)
        bannerVisible = false
    }

    fun replay() {
        bannerKey += 1
        bannerVisible = true
    }

    val body = if (previews == "Never") "Notification" else "Is the weekend trip to the coast still on?"
    val footnote = bannerFootnote(allow, enabled.getValue(AlertKind.Banner), bannerStyle)
    val dim by animateFloatAsState(if (allow) 1f else 0.35f, tween(300), label = "dim")

    Screen(modifier) {
        Column(
            Modifier
                .fillMaxSize()
                .verticalScroll(rememberScrollState())
                .padding(horizontal = 20.dp, vertical = 24.dp),
            verticalArrangement = Arrangement.spacedBy(20.dp),
        ) {
            Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
                Box(
                    Modifier
                        .size(40.dp)
                        .background(Palette.green500, RoundedCornerShape(10.dp)),
                    contentAlignment = Alignment.Center,
                ) {
                    Icon(PhosphorIcons.Fill.ChatCircle, contentDescription = null, size = 24.dp, tint = colors.primaryForeground)
                }
                Column {
                    BasicText(
                        "Messages",
                        modifier = Modifier.semantics { heading() },
                        style = Typography.xl2.copy(color = colors.foreground, fontWeight = FontWeight.Bold),
                    )
                    BasicText("Notifications", style = Typography.sm.copy(color = colors.mutedForeground))
                }
            }
            ItemGroup(variant = ItemGroupVariant.Inset) {
                SettingRow("Allow Notifications") {
                    Switch(
                        checked = allow,
                        onCheckedChange = {
                            allow = it
                            replay()
                        },
                    )
                }
            }
            Column(
                modifier = Modifier
                    .graphicsLayer { alpha = dim }
                    .then(if (allow) Modifier else Modifier.clearAndSetSemantics { disabled() }),
                verticalArrangement = Arrangement.spacedBy(8.dp),
            ) {
                SectionTitle("Alerts")
                ItemGroup(variant = ItemGroupVariant.Inset) {
                    Row(Modifier.padding(horizontal = 8.dp)) {
                        AlertKind.entries.forEach { kind ->
                            AlertTile(
                                kind = kind,
                                selected = enabled.getValue(kind),
                                enabled = allow,
                                modifier = Modifier.weight(1f),
                            ) {
                                enabled[kind] = it
                                replay()
                            }
                        }
                    }
                    if (enabled.getValue(AlertKind.Banner)) {
                        ItemSeparator()
                        SettingRow("Banner Style") {
                            ToggleGroup(
                                value = bannerStyle,
                                onValueChange = { value ->
                                    if (value != null) {
                                        bannerStyle = value
                                        replay()
                                    }
                                },
                                size = ToggleSize.Sm,
                                spacing = 0,
                                enabled = allow,
                            ) {
                                ToggleGroupItem("Temporary", "temporary")
                                ToggleGroupItem("Persistent", "persistent")
                            }
                        }
                    }
                    ItemSeparator()
                    SettingRow("Sounds") { Switch(checked = sounds, onCheckedChange = { sounds = it }, enabled = allow) }
                    ItemSeparator()
                    SettingRow("Badges") { Switch(checked = badges, onCheckedChange = { badges = it }, enabled = allow) }
                }
                SectionTitle("Appearance", Modifier.padding(top = 12.dp))
                ItemGroup(variant = ItemGroupVariant.Inset) {
                    SettingRow("Show Previews") {
                        Picker(
                            value = previews,
                            onValueChange = {
                                previews = it
                                replay()
                            },
                            items = PreviewOptions,
                            size = SelectSize.Sm,
                            enabled = allow,
                        )
                    }
                    ItemSeparator()
                    SettingRow("Notification Grouping") {
                        Picker(
                            value = grouping,
                            onValueChange = { grouping = it },
                            items = GroupingOptions,
                            size = SelectSize.Sm,
                            enabled = allow,
                        )
                    }
                }
            }
            LockScreenPreview(
                showBanner = allow && enabled.getValue(AlertKind.Banner) && bannerVisible,
                showLock = allow && enabled.getValue(AlertKind.Lock),
                stacked = grouping != "Off",
                bannerKey = bannerKey,
                body = body,
                footnote = footnote,
                modifier = Modifier
                    .align(Alignment.CenterHorizontally)
                    .padding(top = 8.dp),
            )
        }
    }
}

@Composable
private fun SectionTitle(text: String, modifier: Modifier = Modifier) {
    BasicText(
        text,
        modifier = modifier
            .padding(horizontal = 4.dp)
            .semantics { heading() },
        style = Typography.sm.copy(color = EsuissTheme.colors.foreground, fontWeight = FontWeight.SemiBold),
    )
}

@Composable
private fun SettingRow(title: String, action: @Composable () -> Unit) {
    Item(size = ItemSize.Sm) {
        ItemContent {
            ItemTitle {
                BasicText(
                    title,
                    style = Typography.base.copy(color = EsuissTheme.colors.foreground),
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis,
                )
            }
        }
        ItemActions { action() }
    }
}

@Composable
private fun AlertTile(
    kind: AlertKind,
    selected: Boolean,
    enabled: Boolean,
    modifier: Modifier = Modifier,
    onSelectedChange: (Boolean) -> Unit,
) {
    val colors = EsuissTheme.colors
    val fill by animateColorAsState(if (selected) colors.primary else Color.Transparent, tween(250), label = "fill")
    val edge by animateColorAsState(if (selected) Color.Transparent else colors.input, tween(250), label = "edge")
    val check by animateFloatAsState(if (selected) 1f else 0f, tween(250, easing = PopEasing), label = "check")
    Column(
        modifier = modifier
            .clip(RoundedCornerShape(12.dp))
            .toggleable(
                value = selected,
                interactionSource = remember { MutableInteractionSource() },
                indication = null,
                enabled = enabled,
                role = Role.Checkbox,
                onValueChange = onSelectedChange,
            )
            .padding(top = 14.dp, bottom = 12.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        MiniScreen(kind)
        BasicText(
            kind.label,
            modifier = Modifier.padding(horizontal = 2.dp),
            style = Typography.xs.copy(color = colors.foreground, textAlign = TextAlign.Center),
            minLines = 2,
            maxLines = 2,
            overflow = TextOverflow.Ellipsis,
        )
        Box(
            Modifier
                .size(22.dp)
                .background(fill, CircleShape)
                .border(1.5.dp, edge, CircleShape),
            contentAlignment = Alignment.Center,
        ) {
            Icon(
                PhosphorIcons.Bold.Check,
                contentDescription = null,
                modifier = Modifier.graphicsLayer {
                    scaleX = check
                    scaleY = check
                },
                size = 12.dp,
                tint = colors.primaryForeground,
            )
        }
    }
}

@Composable
private fun MiniScreen(kind: AlertKind) {
    val colors = EsuissTheme.colors
    val shape = RoundedCornerShape(12.dp)
    Box(
        Modifier
            .size(width = 64.dp, height = 92.dp)
            .clip(shape)
            .background(Palette.indigo500, shape)
            .border(2.dp, colors.foreground, shape)
            .clearAndSetSemantics {},
    ) {
        if (kind == AlertKind.Lock) {
            BasicText(
                "9:41",
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(top = 14.dp),
                style = Typography.sm.copy(color = Palette.white, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center),
            )
        }
        kind.bars.forEachIndexed { index, top ->
            Box(
                Modifier
                    .offset(y = top.dp)
                    .padding(horizontal = 8.dp)
                    .fillMaxWidth()
                    .height(13.dp)
                    .graphicsLayer { alpha = 1f - index * 0.15f }
                    .background(Palette.white, RoundedCornerShape(4.dp)),
            )
        }
    }
}

@Composable
private fun LockScreenPreview(
    showBanner: Boolean,
    showLock: Boolean,
    stacked: Boolean,
    bannerKey: Int,
    body: String,
    footnote: String,
    modifier: Modifier = Modifier,
) {
    val colors = EsuissTheme.colors
    val outer = RoundedCornerShape(42.dp)
    val inner = RoundedCornerShape(34.dp)
    Box(
        modifier = modifier
            .size(width = 230.dp, height = 470.dp)
            .shadow(Shadow(0.dp, 20.dp, 50.dp, Palette.black.copy(alpha = 0.4f)), outer)
            .background(Palette.black, outer)
            .border(1.5.dp, colors.mutedForeground, outer)
            .padding(8.dp)
            .clip(inner)
            .background(Palette.indigo500)
            .semantics { contentDescription = "Lock screen preview. $footnote." },
    ) {
        Box(
            Modifier
                .align(Alignment.TopCenter)
                .padding(top = 8.dp)
                .size(width = 74.dp, height = 22.dp)
                .background(Palette.black, CircleShape),
        )
        Column(
            Modifier
                .fillMaxWidth()
                .padding(top = 50.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
        ) {
            BasicText(
                "Tuesday, October 6",
                style = Typography.xs.copy(color = Palette.white.copy(alpha = 0.9f), fontWeight = FontWeight.SemiBold),
            )
            BasicText(
                "9:41",
                style = Typography.xl3.copy(
                    color = Palette.white,
                    fontWeight = FontWeight.SemiBold,
                    fontSize = 60.sp,
                    lineHeight = 60.sp,
                    letterSpacing = (-0.05).sp,
                ),
            )
        }
        key(bannerKey) {
            AnimatedVisibility(
                visible = showBanner,
                modifier = Modifier
                    .padding(top = 38.dp, start = 8.dp, end = 8.dp)
                    .fillMaxWidth(),
                enter = fadeIn(tween(800)) + slideInVertically(tween(800, easing = RevealEasing)) { -it },
                exit = fadeOut(tween(300)),
            ) {
                Notification(body, stacked = false)
            }
        }
        if (showLock) {
            Box(
                Modifier
                    .padding(top = 300.dp, start = 8.dp, end = 8.dp)
                    .fillMaxWidth(),
            ) {
                Notification(body, stacked = stacked)
            }
        }
        BasicText(
            footnote,
            modifier = Modifier
                .align(Alignment.BottomCenter)
                .padding(bottom = 14.dp),
            style = Typography.xs.copy(color = Palette.white.copy(alpha = 0.85f), textAlign = TextAlign.Center),
        )
    }
}

@Composable
private fun Notification(body: String, stacked: Boolean) {
    val colors = EsuissTheme.colors
    val ink = Palette.black
    val shape = RoundedCornerShape(18.dp)
    Box {
        if (stacked) {
            Box(
                Modifier
                    .matchParentSize()
                    .padding(horizontal = 10.dp)
                    .offset(y = 6.dp)
                    .background(Palette.white.copy(alpha = 0.55f), RoundedCornerShape(16.dp)),
            )
        }
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .background(Palette.white, shape)
                .padding(horizontal = 12.dp, vertical = 10.dp),
            horizontalArrangement = Arrangement.spacedBy(10.dp),
        ) {
            Box(
                Modifier
                    .size(30.dp)
                    .background(Palette.green500, RoundedCornerShape(7.dp)),
                contentAlignment = Alignment.Center,
            ) {
                Icon(PhosphorIcons.Fill.ChatCircle, contentDescription = null, size = 18.dp, tint = Palette.white)
            }
            Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
                Row(verticalAlignment = Alignment.CenterVertically) {
                    BasicText(
                        "Riley Morgan",
                        modifier = Modifier.weight(1f),
                        style = Typography.xs.copy(color = ink.copy(alpha = 0.88f), fontWeight = FontWeight.SemiBold),
                    )
                    BasicText("now", style = Typography.xs.copy(color = ink.copy(alpha = 0.55f)))
                }
                BasicText(body, style = Typography.xs.copy(color = ink.copy(alpha = 0.88f)))
            }
        }
    }
}
```

A wallpaper preview shows a sample notification that reflects the current settings: hidden previews, stacking when grouping is on, and banners that fade out when set to temporary. Below it, `Switch` rows control notifications, sounds and badges, `Toggle` tiles choose Lock Screen, Notification Center and Banners, a `ToggleGroup` picks the banner style, and `Picker` rows set previews and grouping.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/settings-notifications
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add settings-notifications
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add settings-notifications --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add settings-notifications --platform kotlin
```

The CLI also adds what it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Item](https://ui.suiss.app/docs/components/item), [Picker](https://ui.suiss.app/docs/patterns/picker), [Screen](https://ui.suiss.app/docs/components/screen), [Switch](https://ui.suiss.app/docs/components/switch), [Text](https://ui.suiss.app/docs/components/text), [Toggle](https://ui.suiss.app/docs/components/toggle), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group). On React Native it also adds the animated view `components/ui/animated-view.tsx`.

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Item](https://ui.suiss.app/docs/components/item), [Picker](https://ui.suiss.app/docs/patterns/picker), [Screen](https://ui.suiss.app/docs/components/screen), [Switch](https://ui.suiss.app/docs/components/switch), [Text](https://ui.suiss.app/docs/components/text), [Toggle](https://ui.suiss.app/docs/components/toggle), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `uniwind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/uniwind/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/settings-notifications.tsx`:

- `uniwind/components/blocks/settings-notifications.tsx`: shown above

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Item](https://ui.suiss.app/docs/components/item), [Picker](https://ui.suiss.app/docs/patterns/picker), [Screen](https://ui.suiss.app/docs/components/screen), [Switch](https://ui.suiss.app/docs/components/switch), [Text](https://ui.suiss.app/docs/components/text), [Toggle](https://ui.suiss.app/docs/components/toggle), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `nativewind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/nativewind/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/settings-notifications.tsx`:

```tsx title="nativewind/components/blocks/settings-notifications.tsx"
import { cn } from "cn"
import { ChatCircleIcon, CheckIcon } from "phosphor-react-native"
import * as React from "react"
import { Image, ScrollView, View } from "react-native"
import { FadeInUp, ReduceMotion } from "react-native-reanimated"
import { Picker, PickerItem } from "@/components/patterns/picker"
import { AnimatedView } from "@/components/ui/animated-view"
import { Icon } from "@/components/ui/icon"
import {
  Item,
  ItemActions,
  ItemContent,
  ItemGroup,
  ItemSeparator,
  ItemTitle,
} from "@/components/ui/item"
import { Screen } from "@/components/ui/screen"
import { Switch } from "@/components/ui/switch"
import { Text } from "@/components/ui/text"
import { Toggle } from "@/components/ui/toggle"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

const WALLPAPER =
  "https://images.unsplash.com/photo-1707324148764-99647364afa3?w=600&q=80&auto=format&fit=crop"

type Alert = "lock" | "center" | "banner"

const ALERTS: { value: Alert; label: string }[] = [
  { value: "lock", label: "Lock Screen" },
  { value: "center", label: "Notification Center" },
  { value: "banner", label: "Banners" },
]

const PREVIEW_OPTIONS = ["Always", "When Unlocked", "Never"]

const GROUPING_OPTIONS = ["Automatic", "By App", "Off"]

const MINI_SCREEN_BARS: Record<Alert, number[]> = {
  lock: [58, 74],
  center: [12, 29, 46, 63],
  banner: [8],
}

function bannerFootnote(allow: boolean, banner: boolean, style: string) {
  if (!allow) return "Notifications are off"
  if (!banner) return "No banners"
  return style === "persistent"
    ? "Banner stays until dismissed"
    : "Banner shows briefly"
}

function MiniScreen({ kind }: { kind: Alert }) {
  return (
    <View
      accessible={false}
      importantForAccessibility="no-hide-descendants"
      className="h-[5.75rem] w-16 overflow-hidden rounded-xl border-2 border-foreground bg-indigo-500"
    >
      {kind === "lock" ? (
        <Text className="absolute inset-x-0 top-3 text-center text-sm font-semibold text-white">
          9:41
        </Text>
      ) : null}
      {MINI_SCREEN_BARS[kind].map((top, index) => (
        <View
          key={top}
          style={{ top: top - 2, opacity: 1 - index * 0.15 }}
          className="absolute inset-x-1.5 h-[0.8125rem] rounded-sm bg-white"
        />
      ))}
    </View>
  )
}

function Notification({ body, stacked }: { body: string; stacked?: boolean }) {
  return (
    <AnimatedView
      entering={FadeInUp.duration(600).reduceMotion(ReduceMotion.System)}
    >
      {stacked ? (
        <View className="absolute inset-x-2.5 -bottom-1.5 h-5 rounded-2xl bg-card opacity-55" />
      ) : null}
      <View className="flex-row gap-2.5 rounded-[1.125rem] bg-card px-3 py-2.5 opacity-95">
        <View className="size-[1.875rem] shrink-0 items-center justify-center rounded-[7px] bg-green-500">
          <Icon
            as={ChatCircleIcon}
            weight="fill"
            className="size-[1.125rem] text-primary-foreground"
          />
        </View>
        <View className="min-w-0 flex-1 gap-px">
          <View className="flex-row items-center gap-1.5">
            <Text className="flex-1 text-xs font-semibold">Riley Morgan</Text>
            <Text className="text-xs text-muted-foreground">now</Text>
          </View>
          <Text className="text-xs">{body}</Text>
        </View>
      </View>
    </AnimatedView>
  )
}

function SwitchRow({
  id,
  label,
  checked,
  onCheckedChange,
}: {
  id: string
  label: string
  checked: boolean
  onCheckedChange: (checked: boolean) => void
}) {
  return (
    <Item size="sm">
      <ItemContent>
        <ItemTitle nativeID={id} className="font-normal">
          {label}
        </ItemTitle>
      </ItemContent>
      <ItemActions>
        <Switch
          checked={checked}
          onCheckedChange={onCheckedChange}
          accessibilityLabelledBy={id}
        />
      </ItemActions>
    </Item>
  )
}

function SettingsNotifications() {
  const [allow, setAllow] = React.useState(true)
  const [enabled, setEnabled] = React.useState<Record<Alert, boolean>>({
    lock: true,
    center: true,
    banner: true,
  })
  const [bannerStyle, setBannerStyle] = React.useState("temporary")
  const [sounds, setSounds] = React.useState(true)
  const [badges, setBadges] = React.useState(true)
  const [previews, setPreviews] = React.useState("Always")
  const [grouping, setGrouping] = React.useState("Automatic")
  const [bannerKey, setBannerKey] = React.useState(0)
  const [bannerVisible, setBannerVisible] = React.useState(true)

  React.useEffect(() => {
    if (bannerStyle !== "temporary" || !bannerVisible) return
    const timer = setTimeout(() => setBannerVisible(false), 2600)
    return () => clearTimeout(timer)
  }, [bannerStyle, bannerVisible])

  const replay = () => {
    setBannerKey((key) => key + 1)
    setBannerVisible(true)
  }

  const body =
    previews === "Never"
      ? "Notification"
      : "Is the weekend trip to the coast still on?"
  const footnote = bannerFootnote(allow, enabled.banner, bannerStyle)

  const selects = [
    {
      label: "Show Previews",
      value: previews,
      options: PREVIEW_OPTIONS,
      onChange: (value: string) => {
        setPreviews(value)
        replay()
      },
    },
    {
      label: "Notification Grouping",
      value: grouping,
      options: GROUPING_OPTIONS,
      onChange: setGrouping,
    },
  ]

  return (
    <Screen edges={["top", "right", "left"]}>
      <ScrollView contentContainerClassName="gap-5 px-4 pt-4 pb-8">
        <View className="flex-row items-center gap-3">
          <View className="size-10 items-center justify-center rounded-[0.625rem] bg-green-500">
            <Icon
              as={ChatCircleIcon}
              weight="fill"
              className="size-6 text-primary-foreground"
            />
          </View>
          <View>
            <Text role="heading" className="text-2xl font-bold">
              Messages
            </Text>
            <Text className="text-sm text-muted-foreground">Notifications</Text>
          </View>
        </View>
        <View
          accessible
          accessibilityRole="image"
          accessibilityLabel={`Lock screen preview. ${footnote}.`}
          className="h-[18.75rem] overflow-hidden rounded-3xl bg-black"
        >
          <Image
            source={{ uri: WALLPAPER }}
            resizeMode="cover"
            accessibilityIgnoresInvertColors
            className="absolute inset-0 size-full"
          />
          <View className="absolute inset-0 bg-black/50 opacity-40" />
          <View className="absolute inset-x-0 top-10 items-center">
            <Text className="text-xs font-semibold text-white opacity-90">
              Tuesday, October 6
            </Text>
            <Text className="text-3xl font-semibold text-white">9:41</Text>
          </View>
          {allow && enabled.banner && bannerVisible ? (
            <View key={bannerKey} className="absolute inset-x-2 top-2">
              <Notification body={body} />
            </View>
          ) : null}
          {allow && enabled.lock ? (
            <View className="absolute inset-x-2 top-40">
              <Notification body={body} stacked={grouping !== "Off"} />
            </View>
          ) : null}
          <Text className="absolute inset-x-0 bottom-3.5 text-center text-xs text-white opacity-85">
            {footnote}
          </Text>
        </View>
        <ItemGroup variant="inset">
          <SwitchRow
            id="settings-notifications-allow"
            label="Allow Notifications"
            checked={allow}
            onCheckedChange={(value) => {
              setAllow(value)
              replay()
            }}
          />
        </ItemGroup>
        <View
          style={{ pointerEvents: allow ? "auto" : "none" }}
          accessibilityState={{ disabled: !allow }}
          className={cn("gap-5", !allow && "opacity-35")}
        >
          <Text role="heading" className="-mb-3 px-1 text-sm font-semibold">
            Alerts
          </Text>
          <ItemGroup variant="inset">
            <View className="flex-row px-2">
              {ALERTS.map((alert) => {
                const on = enabled[alert.value]
                return (
                  <Toggle
                    key={alert.value}
                    pressed={on}
                    onPressedChange={(pressed) => {
                      setEnabled((current) => ({
                        ...current,
                        [alert.value]: pressed,
                      }))
                      replay()
                    }}
                    accessibilityLabel={alert.label}
                    className="h-auto flex-1 flex-col gap-2 rounded-xl bg-transparent pt-3.5 pb-3 active:bg-transparent"
                  >
                    <MiniScreen kind={alert.value} />
                    <Text className="text-center text-sm text-foreground">
                      {alert.label}
                    </Text>
                    <View
                      className={cn(
                        "size-[1.375rem] items-center justify-center rounded-full border-[1.5px]",
                        on ? "border-transparent bg-primary" : "border-input"
                      )}
                    >
                      {on ? (
                        <Icon
                          as={CheckIcon}
                          weight="bold"
                          className="size-3 text-primary-foreground"
                        />
                      ) : null}
                    </View>
                  </Toggle>
                )
              })}
            </View>
            {enabled.banner ? (
              <>
                <ItemSeparator />
                <Item size="sm">
                  <ItemContent>
                    <ItemTitle className="font-normal">Banner Style</ItemTitle>
                  </ItemContent>
                  <ItemActions>
                    <ToggleGroup
                      spacing={0}
                      size="sm"
                      value={[bannerStyle]}
                      onValueChange={(value) => {
                        if (!value[0]) return
                        setBannerStyle(value[0])
                        replay()
                      }}
                      accessibilityLabel="Banner style"
                    >
                      <ToggleGroupItem value="temporary">
                        <Text>Temporary</Text>
                      </ToggleGroupItem>
                      <ToggleGroupItem value="persistent">
                        <Text>Persistent</Text>
                      </ToggleGroupItem>
                    </ToggleGroup>
                  </ItemActions>
                </Item>
              </>
            ) : null}
            <ItemSeparator />
            <SwitchRow
              id="settings-notifications-sounds"
              label="Sounds"
              checked={sounds}
              onCheckedChange={setSounds}
            />
            <ItemSeparator />
            <SwitchRow
              id="settings-notifications-badges"
              label="Badges"
              checked={badges}
              onCheckedChange={setBadges}
            />
          </ItemGroup>
          <Text role="heading" className="-mb-3 px-1 text-sm font-semibold">
            Appearance
          </Text>
          <ItemGroup variant="inset">
            {selects.map((row, index) => (
              <React.Fragment key={row.label}>
                {index > 0 ? <ItemSeparator /> : null}
                <Item size="sm" className="py-1.5">
                  <ItemContent>
                    <ItemTitle className="font-normal">{row.label}</ItemTitle>
                  </ItemContent>
                  <ItemActions>
                    <Picker
                      size="sm"
                      value={row.value}
                      onValueChange={row.onChange}
                      accessibilityLabel={row.label}
                      className="bg-transparent px-1"
                    >
                      {row.options.map((option) => (
                        <PickerItem key={option} value={option}>
                          {option}
                        </PickerItem>
                      ))}
                    </Picker>
                  </ItemActions>
                </Item>
              </React.Fragment>
            ))}
          </ItemGroup>
        </View>
      </ScrollView>
    </Screen>
  )
}

export { SettingsNotifications }
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Item](https://ui.suiss.app/docs/components/item), [Picker](https://ui.suiss.app/docs/patterns/picker), [Screen](https://ui.suiss.app/docs/components/screen), [Switch](https://ui.suiss.app/docs/components/switch), [Text](https://ui.suiss.app/docs/components/text), [Toggle](https://ui.suiss.app/docs/components/toggle), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `stylesheet/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/stylesheet/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/settings-notifications.tsx`:

```tsx title="stylesheet/components/blocks/settings-notifications.tsx"
import { ChatCircleIcon, CheckIcon } from "phosphor-react-native"
import * as React from "react"
import { Image, ScrollView, StyleSheet, View } from "react-native"
import { FadeInUp, ReduceMotion } from "react-native-reanimated"
import { Picker, PickerItem } from "@/components/patterns/picker"
import { AnimatedView } from "@/components/ui/animated-view"
import { Icon } from "@/components/ui/icon"
import {
  Item,
  ItemActions,
  ItemContent,
  ItemGroup,
  ItemSeparator,
  ItemTitle,
} from "@/components/ui/item"
import { Screen } from "@/components/ui/screen"
import { Switch } from "@/components/ui/switch"
import { Text } from "@/components/ui/text"
import { fontSize, palette, radius, useTheme } from "@/components/ui/theme"
import { Toggle } from "@/components/ui/toggle"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

const WALLPAPER =
  "https://images.unsplash.com/photo-1707324148764-99647364afa3?w=600&q=80&auto=format&fit=crop"

type Alert = "lock" | "center" | "banner"

const ALERTS: { value: Alert; label: string }[] = [
  { value: "lock", label: "Lock Screen" },
  { value: "center", label: "Notification Center" },
  { value: "banner", label: "Banners" },
]

const PREVIEW_OPTIONS = ["Always", "When Unlocked", "Never"]

const GROUPING_OPTIONS = ["Automatic", "By App", "Off"]

const MINI_SCREEN_BARS: Record<Alert, number[]> = {
  lock: [58, 74],
  center: [12, 29, 46, 63],
  banner: [8],
}

const styles = StyleSheet.create({
  mini: {
    height: 92,
    width: 64,
    overflow: "hidden",
    borderRadius: radius.xl,
    borderWidth: 2,
  },
  miniTime: {
    ...fontSize.sm,
    position: "absolute",
    left: 0,
    right: 0,
    top: 12,
    textAlign: "center",
    fontWeight: "600",
  },
  miniBar: {
    position: "absolute",
    left: 6,
    right: 6,
    height: 13,
    borderRadius: radius.sm,
  },
  stack: {
    position: "absolute",
    left: 10,
    right: 10,
    bottom: -6,
    height: 20,
    borderRadius: radius["2xl"],
    opacity: 0.55,
  },
  card: {
    flexDirection: "row",
    gap: 10,
    borderRadius: 18,
    paddingHorizontal: 12,
    paddingVertical: 10,
    opacity: 0.95,
  },
  appIcon: {
    width: 30,
    height: 30,
    flexShrink: 0,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: 7,
  },
  cardBody: { minWidth: 0, flex: 1, gap: 1 },
  cardHeader: { flexDirection: "row", alignItems: "center", gap: 6 },
  content: {
    gap: 20,
    paddingHorizontal: 16,
    paddingTop: 16,
    paddingBottom: 32,
  },
  appHeader: { flexDirection: "row", alignItems: "center", gap: 12 },
  appTile: {
    width: 40,
    height: 40,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: 10,
  },
  preview: { height: 300, overflow: "hidden", borderRadius: radius["3xl"] },
  clock: {
    position: "absolute",
    left: 0,
    right: 0,
    top: 40,
    alignItems: "center",
  },
  footnote: {
    ...fontSize.xs,
    position: "absolute",
    left: 0,
    right: 0,
    bottom: 14,
    textAlign: "center",
    opacity: 0.85,
  },
  section: { ...fontSize.sm, marginBottom: -12, paddingHorizontal: 4 },
  alerts: { flexDirection: "row", paddingHorizontal: 8 },
  alert: {
    height: "auto",
    flex: 1,
    flexDirection: "column",
    gap: 8,
    borderRadius: radius.xl,
    paddingTop: 14,
    paddingBottom: 12,
    backgroundColor: "transparent",
  },
  checkCircle: {
    width: 22,
    height: 22,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.full,
    borderWidth: 1.5,
  },
})

function bannerFootnote(allow: boolean, banner: boolean, style: string) {
  if (!allow) return "Notifications are off"
  if (!banner) return "No banners"
  return style === "persistent"
    ? "Banner stays until dismissed"
    : "Banner shows briefly"
}

function MiniScreen({ kind }: { kind: Alert }) {
  const { colors } = useTheme()
  return (
    <View
      accessible={false}
      importantForAccessibility="no-hide-descendants"
      style={[
        styles.mini,
        { borderColor: colors.foreground, backgroundColor: palette.indigo500 },
      ]}
    >
      {kind === "lock" ? (
        <Text style={[styles.miniTime, { color: palette.white }]}>9:41</Text>
      ) : null}
      {MINI_SCREEN_BARS[kind].map((top, index) => (
        <View
          key={top}
          style={[
            styles.miniBar,
            {
              top: top - 2,
              opacity: 1 - index * 0.15,
              backgroundColor: palette.white,
            },
          ]}
        />
      ))}
    </View>
  )
}

function Notification({ body, stacked }: { body: string; stacked?: boolean }) {
  const { colors } = useTheme()
  return (
    <AnimatedView
      entering={FadeInUp.duration(600).reduceMotion(ReduceMotion.System)}
    >
      {stacked ? (
        <View style={[styles.stack, { backgroundColor: colors.card }]} />
      ) : null}
      <View style={[styles.card, { backgroundColor: colors.card }]}>
        <View style={[styles.appIcon, { backgroundColor: palette.green500 }]}>
          <Icon
            as={ChatCircleIcon}
            weight="fill"
            size={18}
            color={colors.primaryForeground}
          />
        </View>
        <View style={styles.cardBody}>
          <View style={styles.cardHeader}>
            <Text style={[fontSize.xs, { flex: 1, fontWeight: "600" }]}>
              Riley Morgan
            </Text>
            <Text style={[fontSize.xs, { color: colors.mutedForeground }]}>
              now
            </Text>
          </View>
          <Text style={fontSize.xs}>{body}</Text>
        </View>
      </View>
    </AnimatedView>
  )
}

function SwitchRow({
  id,
  label,
  checked,
  onCheckedChange,
}: {
  id: string
  label: string
  checked: boolean
  onCheckedChange: (checked: boolean) => void
}) {
  return (
    <Item size="sm">
      <ItemContent>
        <ItemTitle nativeID={id} style={{ fontWeight: "400" }}>
          {label}
        </ItemTitle>
      </ItemContent>
      <ItemActions>
        <Switch
          checked={checked}
          onCheckedChange={onCheckedChange}
          accessibilityLabelledBy={id}
        />
      </ItemActions>
    </Item>
  )
}

function SettingsNotifications() {
  const { colors } = useTheme()
  const [allow, setAllow] = React.useState(true)
  const [enabled, setEnabled] = React.useState<Record<Alert, boolean>>({
    lock: true,
    center: true,
    banner: true,
  })
  const [bannerStyle, setBannerStyle] = React.useState("temporary")
  const [sounds, setSounds] = React.useState(true)
  const [badges, setBadges] = React.useState(true)
  const [previews, setPreviews] = React.useState("Always")
  const [grouping, setGrouping] = React.useState("Automatic")
  const [bannerKey, setBannerKey] = React.useState(0)
  const [bannerVisible, setBannerVisible] = React.useState(true)

  React.useEffect(() => {
    if (bannerStyle !== "temporary" || !bannerVisible) return
    const timer = setTimeout(() => setBannerVisible(false), 2600)
    return () => clearTimeout(timer)
  }, [bannerStyle, bannerVisible])

  const replay = () => {
    setBannerKey((key) => key + 1)
    setBannerVisible(true)
  }

  const body =
    previews === "Never"
      ? "Notification"
      : "Is the weekend trip to the coast still on?"
  const footnote = bannerFootnote(allow, enabled.banner, bannerStyle)

  const selects = [
    {
      label: "Show Previews",
      value: previews,
      options: PREVIEW_OPTIONS,
      onChange: (value: string) => {
        setPreviews(value)
        replay()
      },
    },
    {
      label: "Notification Grouping",
      value: grouping,
      options: GROUPING_OPTIONS,
      onChange: setGrouping,
    },
  ]

  return (
    <Screen edges={["top", "right", "left"]}>
      <ScrollView contentContainerStyle={styles.content}>
        <View style={styles.appHeader}>
          <View style={[styles.appTile, { backgroundColor: palette.green500 }]}>
            <Icon
              as={ChatCircleIcon}
              weight="fill"
              size={24}
              color={colors.primaryForeground}
            />
          </View>
          <View>
            <Text
              role="heading"
              style={[fontSize["2xl"], { fontWeight: "700" }]}
            >
              Messages
            </Text>
            <Text style={[fontSize.sm, { color: colors.mutedForeground }]}>
              Notifications
            </Text>
          </View>
        </View>
        <View
          accessible
          accessibilityRole="image"
          accessibilityLabel={`Lock screen preview. ${footnote}.`}
          style={[styles.preview, { backgroundColor: palette.black }]}
        >
          <Image
            source={{ uri: WALLPAPER }}
            resizeMode="cover"
            accessibilityIgnoresInvertColors
            style={[StyleSheet.absoluteFill, { width: "100%", height: "100%" }]}
          />
          <View
            style={[
              StyleSheet.absoluteFill,
              { backgroundColor: "rgba(0, 0, 0, 0.5)", opacity: 0.4 },
            ]}
          />
          <View style={styles.clock}>
            <Text
              style={[
                fontSize.xs,
                { fontWeight: "600", color: palette.white, opacity: 0.9 },
              ]}
            >
              Tuesday, October 6
            </Text>
            <Text
              style={[
                fontSize["3xl"],
                { fontWeight: "600", color: palette.white },
              ]}
            >
              9:41
            </Text>
          </View>
          {allow && enabled.banner && bannerVisible ? (
            <View
              key={bannerKey}
              style={{ position: "absolute", left: 8, right: 8, top: 8 }}
            >
              <Notification body={body} />
            </View>
          ) : null}
          {allow && enabled.lock ? (
            <View style={{ position: "absolute", left: 8, right: 8, top: 160 }}>
              <Notification body={body} stacked={grouping !== "Off"} />
            </View>
          ) : null}
          <Text style={[styles.footnote, { color: palette.white }]}>
            {footnote}
          </Text>
        </View>
        <ItemGroup variant="inset">
          <SwitchRow
            id="settings-notifications-allow"
            label="Allow Notifications"
            checked={allow}
            onCheckedChange={(value) => {
              setAllow(value)
              replay()
            }}
          />
        </ItemGroup>
        <View
          accessibilityState={{ disabled: !allow }}
          style={[
            { gap: 20, pointerEvents: allow ? "auto" : "none" },
            !allow && { opacity: 0.35 },
          ]}
        >
          <Text role="heading" style={[styles.section, { fontWeight: "600" }]}>
            Alerts
          </Text>
          <ItemGroup variant="inset">
            <View style={styles.alerts}>
              {ALERTS.map((alert) => {
                const on = enabled[alert.value]
                return (
                  <Toggle
                    key={alert.value}
                    pressed={on}
                    onPressedChange={(pressed) => {
                      setEnabled((current) => ({
                        ...current,
                        [alert.value]: pressed,
                      }))
                      replay()
                    }}
                    accessibilityLabel={alert.label}
                    style={styles.alert}
                  >
                    <MiniScreen kind={alert.value} />
                    <Text
                      style={[
                        fontSize.sm,
                        { textAlign: "center", color: colors.foreground },
                      ]}
                    >
                      {alert.label}
                    </Text>
                    <View
                      style={[
                        styles.checkCircle,
                        on
                          ? {
                              borderColor: "transparent",
                              backgroundColor: colors.primary,
                            }
                          : { borderColor: colors.input },
                      ]}
                    >
                      {on ? (
                        <Icon
                          as={CheckIcon}
                          weight="bold"
                          size={12}
                          color={colors.primaryForeground}
                        />
                      ) : null}
                    </View>
                  </Toggle>
                )
              })}
            </View>
            {enabled.banner ? (
              <>
                <ItemSeparator />
                <Item size="sm">
                  <ItemContent>
                    <ItemTitle style={{ fontWeight: "400" }}>
                      Banner Style
                    </ItemTitle>
                  </ItemContent>
                  <ItemActions>
                    <ToggleGroup
                      spacing={0}
                      size="sm"
                      value={[bannerStyle]}
                      onValueChange={(value) => {
                        if (!value[0]) return
                        setBannerStyle(value[0])
                        replay()
                      }}
                      accessibilityLabel="Banner style"
                    >
                      <ToggleGroupItem value="temporary">
                        <Text>Temporary</Text>
                      </ToggleGroupItem>
                      <ToggleGroupItem value="persistent">
                        <Text>Persistent</Text>
                      </ToggleGroupItem>
                    </ToggleGroup>
                  </ItemActions>
                </Item>
              </>
            ) : null}
            <ItemSeparator />
            <SwitchRow
              id="settings-notifications-sounds"
              label="Sounds"
              checked={sounds}
              onCheckedChange={setSounds}
            />
            <ItemSeparator />
            <SwitchRow
              id="settings-notifications-badges"
              label="Badges"
              checked={badges}
              onCheckedChange={setBadges}
            />
          </ItemGroup>
          <Text role="heading" style={[styles.section, { fontWeight: "600" }]}>
            Appearance
          </Text>
          <ItemGroup variant="inset">
            {selects.map((row, index) => (
              <React.Fragment key={row.label}>
                {index > 0 ? <ItemSeparator /> : null}
                <Item size="sm" style={{ paddingVertical: 6 }}>
                  <ItemContent>
                    <ItemTitle style={{ fontWeight: "400" }}>
                      {row.label}
                    </ItemTitle>
                  </ItemContent>
                  <ItemActions>
                    <Picker
                      size="sm"
                      value={row.value}
                      onValueChange={row.onChange}
                      accessibilityLabel={row.label}
                      style={{
                        backgroundColor: "transparent",
                        paddingHorizontal: 4,
                      }}
                    >
                      {row.options.map((option) => (
                        <PickerItem key={option} value={option}>
                          {option}
                        </PickerItem>
                      ))}
                    </Picker>
                  </ItemActions>
                </Item>
              </React.Fragment>
            ))}
          </ItemGroup>
        </View>
      </ScrollView>
    </Screen>
  )
}

export { SettingsNotifications }
```

5. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

- `swift/Blocks/SettingsNotifications.swift`: shown above

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/Checkbox.swift`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `swift/Components/DropdownMenu.swift`: see [Dropdown Menu](https://ui.suiss.app/docs/components/dropdown-menu.md)

- `swift/Components/Field.swift`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `swift/Components/Haptics.swift`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `swift/Components/Item.swift`: see [Item](https://ui.suiss.app/docs/components/item.md)

- `swift/Components/KeyboardScrollView.swift`: see [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view.md)

- `swift/Components/Overlay.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/Overlay.swift)

- `swift/Components/Screen.swift`: see [Screen](https://ui.suiss.app/docs/components/screen.md)

- `swift/Components/Select.swift`: see [Select](https://ui.suiss.app/docs/components/select.md)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `swift/Components/Switch.swift`: see [Switch](https://ui.suiss.app/docs/components/switch.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

- `swift/Components/Toggle.swift`: see [Toggle](https://ui.suiss.app/docs/components/toggle.md)

- `swift/Components/ToggleGroup.swift`: see [Toggle Group](https://ui.suiss.app/docs/components/toggle-group.md)

- `swift/Patterns/Adaptive.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Patterns/Adaptive.swift)

- `swift/Patterns/OptionPicker.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Patterns/OptionPicker.swift)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

- `kotlin/blocks/SettingsNotifications.kt`: shown above

- `kotlin/components/Button.kt`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `kotlin/components/ButtonGroup.kt`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `kotlin/components/Checkbox.kt`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Field.kt`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `kotlin/components/FormSupport.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/FormSupport.kt)

- `kotlin/components/Haptics.kt`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `kotlin/components/Header.kt`: see [Header](https://ui.suiss.app/docs/components/header.md)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

- `kotlin/components/Item.kt`: see [Item](https://ui.suiss.app/docs/components/item.md)

- `kotlin/components/Label.kt`: see [Label](https://ui.suiss.app/docs/components/label.md)

- `kotlin/components/Overlay.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Overlay.kt)

- `kotlin/components/Screen.kt`: see [Screen](https://ui.suiss.app/docs/components/screen.md)

- `kotlin/components/Select.kt`: see [Select](https://ui.suiss.app/docs/components/select.md)

- `kotlin/components/Separator.kt`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `kotlin/components/Switch.kt`: see [Switch](https://ui.suiss.app/docs/components/switch.md)

- `kotlin/components/Toggle.kt`: see [Toggle](https://ui.suiss.app/docs/components/toggle.md)

- `kotlin/components/ToggleGroup.kt`: see [Toggle Group](https://ui.suiss.app/docs/components/toggle-group.md)

- `kotlin/patterns/Picker.kt`: see [Picker](https://ui.suiss.app/docs/patterns/picker.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { SettingsNotifications } from "@/components/blocks/settings-notifications"
```

```tsx
<SettingsNotifications />
```

The settings live in local state and the component takes no props. Replace `WALLPAPER`, `PREVIEW_OPTIONS` and `GROUPING_OPTIONS` and wire each control's change handler to your preferences store.

