# 설정 패널

소리, 화면 모드, 시즌 테마, 글자 크기, 움직임을 한 서랍에 모은 설정 블록이에요. 언어나 계정 같은 묶음도 더할 수 있어요.

- Type: block
- Theme: base
- Tags: arc, block, settings, preferences, drawer, sound, theme, accessibility
- English title: Settings panel
- URL: https://dsui.vercel.app/items/settings-panel

## Used items

- [서랍](https://dsui.vercel.app/items/drawer): 화면 가장자리에서 밀려 나오는 패널이에요.
- [세그먼트 컨트롤](https://dsui.vercel.app/items/segmented-control): 한 번에 하나만 고르는 짧은 선택지를 나란히 보여 주는 컨트롤이에요.
- [슬라이더](https://dsui.vercel.app/items/slider): 끌거나 키보드로 값이나 범위를 고르는 슬라이더예요.
- [스위치](https://dsui.vercel.app/items/switch): 켜고 끄는 값을 바꾸는 스위치로, 누르는 동안 손잡이가 늘어나요.
- [버튼](https://dsui.vercel.app/items/button): 눌렀을 때 살짝 눌리고 소리가 나는 기본 버튼이에요.

## Controls

| Key | Type | Label | Default |
| --- | --- | --- | --- |
| side | select | 열리는 쪽 | right |
| showSeason | boolean | 시즌 테마 묶음 | true |
| showHints | boolean | 설명 문장 | true |
| title | text | 제목 | 설정 |

## Source

### settings-panel.module.css

```css
.panel { display: grid; align-content: start; gap: var(--space-6); padding: var(--space-2) 0 var(--space-6); }
.note { margin: 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
.group { display: grid; gap: var(--space-4); border-top: 1px solid var(--border); padding-top: var(--space-5); }
.heading { margin: 0; font-size: var(--text-sm); font-weight: 500; }
/* A control with the sentence that explains it; a switch's sentence lines up with its label (track 42px + the gap before the label). */
.setting { display: grid; gap: var(--space-1); }
.hint { margin: 0; color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.5; word-break: keep-all; }
.hintIndent { padding-left: calc(42px + var(--space-3)); }
.popover { width: min(24rem, calc(100vw - 20px)); max-height: var(--radix-popover-content-available-height); overflow-y: auto; }
.title { margin: 0; font-size: var(--text-base); font-weight: 500; }

```

### settings-panel.tsx

```tsx
"use client";

import { useId, useState } from "react";
import type { ReactNode } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { Settings02Icon } from "@hugeicons/core-free-icons";
import { Button } from "../../button/button";
import { Popover, PopoverContent, PopoverTrigger } from "../../popover/popover";
import SegmentedControl from "../../segmented-control/segmented-control";
import { Slider } from "../../slider/slider";
import { Switch } from "../../switch/switch";
import { playUiSound } from "../../sound/play";
import styles from "./settings-panel.module.css";

export type SettingsColorMode = "system" | "light" | "dark";
export type SettingsTextSize = "normal" | "large";

export interface SettingsPanelLabels {
  triggerLabel: string;
  title: string;
  description: string;
  sound: string;
  soundSwitch: string;
  volume: string;
  colorMode: string;
  system: string;
  light: string;
  dark: string;
  season: string;
  textSize: string;
  textNormal: string;
  textLarge: string;
  reduceMotion: string;
  reduceBlur: string;
  reset: string;
}

/**
 * One sentence under a setting that says what it does and what turning it off changes. All optional: a setting without a hint
 * shows no text. Write them for people, in the voice of the rest of the panel.
 */
export interface SettingsPanelHints {
  soundSwitch: string;
  volume: string;
  colorMode: string;
  season: string;
  textSize: string;
  reduceMotion: string;
  reduceBlur: string;
}

const defaultLabels: SettingsPanelLabels = {
  triggerLabel: "설정 열기",
  title: "설정",
  description: "이 브라우저에만 저장돼요.",
  sound: "소리",
  soundSwitch: "효과음",
  volume: "음량",
  colorMode: "화면",
  system: "시스템",
  light: "라이트",
  dark: "다크",
  season: "시즌 테마",
  textSize: "글자 크기",
  textNormal: "보통",
  textLarge: "크게",
  reduceMotion: "움직임 줄이기",
  reduceBlur: "흐림 효과 줄이기",
  reset: "설정 초기화하기",
};

export interface SettingsPanelProps {
  /** Sound on or off and the volume (0 to 1). Pass null to leave the group out. */
  sound?: { enabled: boolean; volume: number; onEnabledChange: (enabled: boolean) => void; onVolumeChange: (volume: number) => void } | null;
  /** System, light or dark. */
  colorMode?: { value: SettingsColorMode; onChange: (value: SettingsColorMode) => void } | null;
  /** A choice among season themes. The options come from the host, and the first is usually the plain site. */
  season?: { value: string; options: { value: string; label: string }[]; onChange: (value: string) => void } | null;
  textSize?: { value: SettingsTextSize; onChange: (value: SettingsTextSize) => void } | null;
  reduceMotion?: { value: boolean; onChange: (value: boolean) => void } | null;
  /** Frosted-glass blur off (backdrop-filter): cheaper to draw on slower machines. */
  reduceBlur?: { value: boolean; onChange: (value: boolean) => void } | null;
  /** More groups, such as language, account or a link to cookie settings. Wrap each in `SettingsGroup`. */
  children?: ReactNode;
  /** Puts every setting back to its default. Leave it out to hide the button. */
  onReset?: () => void;
  /** Replaces the default gear button. Needs to accept a ref and the Radix trigger props. */
  trigger?: ReactNode;
  side?: "left" | "right";
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  labels?: Partial<SettingsPanelLabels>;
  /** A short explanation under each setting. A switch is linked to its hint with `aria-describedby`. */
  hints?: Partial<SettingsPanelHints>;
  /** Class of the default gear button. */
  triggerClassName?: string;
}

/** One group of a settings panel: a quiet heading over its controls, with an optional sentence under the heading. */
export function SettingsGroup({ title, hint, children }: { title: string; hint?: string; children: ReactNode }) {
  const hintId = useId();
  return (
    <section className={styles.group} aria-label={title} aria-describedby={hint ? hintId : undefined}>
      <h3 className={styles.heading}>{title}</h3>
      {hint ? <p id={hintId} className={styles.hint}>{hint}</p> : null}
      {children}
    </section>
  );
}

/** One control with the sentence that explains it. `indent` lines the sentence up with a switch's label. */
function Setting({ hint, hintId, indent, children }: { hint?: string; hintId: string; indent?: boolean; children: ReactNode }) {
  return (
    <div className={styles.setting}>
      {children}
      {hint ? <p id={hintId} className={indent ? `${styles.hint} ${styles.hintIndent}` : styles.hint}>{hint}</p> : null}
    </div>
  );
}

/**
 * The site's settings in one popover: sound with a volume, color mode, a season theme, text size and reduced motion, plus any
 * groups the host adds (language, account, privacy). It owns no state: each group takes its value and a change handler, so the
 * host decides where a choice is kept. A group is left out when its prop is null or missing.
 */
export function SettingsPanel({
  sound,
  colorMode,
  season,
  textSize,
  reduceMotion,
  reduceBlur,
  children,
  onReset,
  trigger,
  side = "right",
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  labels: given,
  hints = {},
  triggerClassName,
}: SettingsPanelProps) {
  const labels = { ...defaultLabels, ...given };
  const base = useId();
  const hintId = (key: keyof SettingsPanelHints) => `${base}-${key}`;
  const [inner, setInner] = useState(defaultOpen);
  const open = openProp ?? inner;
  const setOpen = (next: boolean) => { setInner(next); onOpenChange?.(next); };
  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        {trigger ?? (
          <Button variant="ghost" size="sm" silent aria-label={labels.triggerLabel} className={triggerClassName}>
            <HugeiconsIcon icon={Settings02Icon} size={19} strokeWidth={1.75} aria-hidden="true" />
          </Button>
        )}
      </PopoverTrigger>
      <PopoverContent aria-label={labels.title} align={side === "left" ? "start" : "end"} className={styles.popover}>
        <div className={styles.panel}>
          <h2 className={styles.title}>{labels.title}</h2>
          <p className={styles.note}>{labels.description}</p>
          {sound ? (
            <SettingsGroup title={labels.sound}>
              <Setting hint={hints.soundSwitch} hintId={hintId("soundSwitch")} indent>
                <Switch
                  label={labels.soundSwitch}
                  aria-describedby={hints.soundSwitch ? hintId("soundSwitch") : undefined}
                  checked={sound.enabled}
                  onCheckedChange={(next) => { sound.onEnabledChange(next); if (next) playUiSound("toggle-on"); }}
                />
              </Setting>
              <Setting hint={hints.volume} hintId={hintId("volume")}>
                <Slider
                  label={labels.volume}
                  value={Math.round(sound.volume * 100)}
                  min={0}
                  max={100}
                  step={5}
                  disabled={!sound.enabled}
                  format={(value) => `${Math.round(value)}%`}
                  onValueChange={(value) => sound.onVolumeChange(value / 100)}
                  onValueCommit={() => playUiSound("tick")}
                />
              </Setting>
            </SettingsGroup>
          ) : null}
          {colorMode ? (
            <SettingsGroup title={labels.colorMode}>
              <Setting hint={hints.colorMode} hintId={hintId("colorMode")}>
                <SegmentedControl
                  label={labels.colorMode}
                  value={colorMode.value}
                  options={[{ value: "system", label: labels.system }, { value: "light", label: labels.light }, { value: "dark", label: labels.dark }]}
                  onValueChange={(next) => colorMode.onChange(next as SettingsColorMode)}
                />
              </Setting>
            </SettingsGroup>
          ) : null}
          {season ? (
            <SettingsGroup title={labels.season}>
              <Setting hint={hints.season} hintId={hintId("season")}>
                <SegmentedControl label={labels.season} value={season.value} options={season.options} onValueChange={season.onChange} />
              </Setting>
            </SettingsGroup>
          ) : null}
          {textSize || reduceMotion || reduceBlur ? (
            <SettingsGroup title={labels.textSize}>
              {textSize ? (
                <Setting hint={hints.textSize} hintId={hintId("textSize")}>
                  <SegmentedControl
                    label={labels.textSize}
                    value={textSize.value}
                    options={[{ value: "normal", label: labels.textNormal }, { value: "large", label: labels.textLarge }]}
                    onValueChange={(next) => textSize.onChange(next as SettingsTextSize)}
                  />
                </Setting>
              ) : null}
              {reduceMotion ? (
                <Setting hint={hints.reduceMotion} hintId={hintId("reduceMotion")} indent>
                  <Switch label={labels.reduceMotion} aria-describedby={hints.reduceMotion ? hintId("reduceMotion") : undefined} checked={reduceMotion.value} onCheckedChange={reduceMotion.onChange} />
                </Setting>
              ) : null}
              {reduceBlur ? (
                <Setting hint={hints.reduceBlur} hintId={hintId("reduceBlur")} indent>
                  <Switch label={labels.reduceBlur} aria-describedby={hints.reduceBlur ? hintId("reduceBlur") : undefined} checked={reduceBlur.value} onCheckedChange={reduceBlur.onChange} />
                </Setting>
              ) : null}
            </SettingsGroup>
          ) : null}
          {children}
          {onReset ? <Button variant="ghost" size="sm" onClick={onReset}>{labels.reset}</Button> : null}
        </div>
      </PopoverContent>
    </Popover>
  );
}

export default SettingsPanel;

```
