블록
설정 패널
소리, 화면 모드, 시즌 테마, 글자 크기, 움직임을 한 서랍에 모은 설정 블록이에요. 언어나 계정 같은 묶음도 더할 수 있어요.
- #arc
- #block
- #settings
- #preferences
- #drawer
- #sound
- #theme
- #accessibility
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/settings-panel.json파일
components/arc/blocks/settings-panel/settings-panel.module.csscomponents/arc/blocks/settings-panel/settings-panel.tsx
npm 패키지
@hugeicons/core-free-icons@^4.3.5@hugeicons/react@^1.1.10
settings-panel.module.csssettings-panel.module.csssettings-panel.module.csscss
.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.tsxsettings-panel.tsxsettings-panel.tsxtsx
"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;