Settings & profile
Validation, live preview, dirty state, save failure recovery and discard.
Interactive local example. Changes reset on reload; connect onSave to your own service.
WORKSPACE / PREFERENCES
Make space for your best work.
Keep your profile current and choose what deserves your attention.
Install
Set up the base theme and Pretendard once using the installation guide. Adding these templates does not overwrite your theme, global CSS or page routes.
Installation: template-settings
npx shadcn@latest add @neumorphism-ui/template-settingsUsage
Usage
"use client";
import { SettingsPanel, type ProfileSettings } from "@/components/blocks/settings-panel";
export function Example({ profile, saveProfile }: {
profile: ProfileSettings;
saveProfile: (values: ProfileSettings) => Promise<void>;
}) {
return <SettingsPanel initialValues={profile} onSave={saveProfile} locale="en" />;
}Connect your application
The demos run in memory and reset on reload. Connect onSave and onDelete to your authenticated service. initialValues and initialRecords are initial snapshots; change the component key when switching data sets.
Connect the Example callbacks below to your real persistence handlers.
Installed source
@components/blocks/settings-panel.tsx
@components/blocks/settings-panel.tsx
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Field, FieldLabel, FieldControl, FieldDescription, FieldError } from "@/components/ui/field";
import { Form } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { ToastProvider, Toaster, useToast } from "@/components/ui/toast";
export type ProfileSettings = { displayName: string; email: string; bio: string; notifications: boolean; weeklyDigest: boolean };
export type SettingsPanelProps = { initialValues: ProfileSettings; onSave: (values: ProfileSettings) => Promise<void>; locale?: "ko" | "en" | "ja" | "zh" };
const copy = {
en: { kicker: "WORKSPACE / PREFERENCES", title: "Make space for your best work.", intro: "Keep your profile current and choose what deserves your attention.", profile: "Public profile", preference: "Notifications", name: "Display name", nameHint: "Use the name your teammates recognize.", email: "Email address", emailHint: "Used for account and workspace updates.", bio: "A little about you", bioHint: "A short introduction. Up to 240 characters.", required: "Enter a name, not only spaces.", emailError: "Enter a valid email address.", notificationTitle: "Project updates", notificationBody: "Mentions, assignments and important changes.", digestTitle: "Weekly digest", digestBody: "One summary, without the daily noise.", save: "Save changes", saving: "Saving…", discard: "Discard changes", dirty: "Unsaved changes", clean: "All changes saved", failure: "Changes were not saved", recovery: "Your edits are still here. Try saving again, or discard to restore the saved version.", saved: "Profile updated", savedBody: "The saved version now includes your changes.", about: "Your workspace identity", preview: "Live preview", navigation: "Settings sections", demo: "Interactive local example. Changes reset on reload; connect onSave to your own service.", fail: "Fail the next save", error: "Simulated save failure", dismiss: "Dismiss notification" },
ko: { kicker: "WORKSPACE / PREFERENCES", title: "나에게 맞는 작업 공간.", intro: "프로필을 정리하고 필요한 알림만 받아보세요.", profile: "공개 프로필", preference: "알림 설정", name: "표시 이름", nameHint: "팀원이 알아볼 수 있는 이름을 사용하세요.", email: "이메일 주소", emailHint: "계정과 작업 공간의 안내를 받습니다.", bio: "간단한 소개", bioHint: "240자 이내로 나를 소개하세요.", required: "공백이 아닌 이름을 입력하세요.", emailError: "올바른 이메일 주소를 입력하세요.", notificationTitle: "프로젝트 업데이트", notificationBody: "멘션, 할당 및 중요한 변경을 알립니다.", digestTitle: "주간 요약", digestBody: "매일의 알림 대신 한 번의 요약을 받습니다.", save: "변경 저장", saving: "저장 중…", discard: "변경 취소", dirty: "저장하지 않은 변경 사항", clean: "모든 변경 사항 저장됨", failure: "저장하지 못했습니다", recovery: "입력한 내용은 유지됩니다. 다시 저장하거나 취소해 저장된 상태로 돌아가세요.", saved: "프로필을 저장했습니다", savedBody: "변경 사항을 저장된 상태에 반영했습니다.", about: "작업 공간의 내 프로필", preview: "실시간 미리보기", navigation: "설정 섹션", demo: "로컬 작동 예제입니다. 새로고침하면 초기화됩니다. 실제 저장은 onSave에 연결하세요.", fail: "다음 저장 실패 재현", error: "저장 실패 예제", dismiss: "알림 닫기" },
ja: { kicker: "WORKSPACE / PREFERENCES", title: "自分らしいワークスペース。", intro: "プロフィールを整え、必要な通知だけを受け取りましょう。", profile: "公開プロフィール", preference: "通知設定", name: "表示名", nameHint: "チームにわかりやすい名前を使ってください。", email: "メールアドレス", emailHint: "アカウントとワークスペースのお知らせに使います。", bio: "自己紹介", bioHint: "240文字以内で紹介してください。", required: "空白以外の名前を入力してください。", emailError: "有効なメールアドレスを入力してください。", notificationTitle: "プロジェクトの更新", notificationBody: "メンション、担当と重要な変更を通知します。", digestTitle: "週間まとめ", digestBody: "日々の通知の代わりにまとめを受け取ります。", save: "変更を保存", saving: "保存中…", discard: "変更を取り消す", dirty: "未保存の変更", clean: "すべて保存済み", failure: "保存できませんでした", recovery: "編集内容は残っています。再試行するか、保存済みの内容に戻してください。", saved: "プロフィールを保存しました", savedBody: "変更を保存済みの内容に反映しました。", about: "ワークスペースのプロフィール", preview: "ライブプレビュー", navigation: "設定セクション", demo: "ローカル動作例です。再読み込みで初期化されます。実際の保存先を onSave に接続してください。", fail: "次の保存を失敗させる", error: "保存失敗の例", dismiss: "通知を閉じる" },
zh: { kicker: "WORKSPACE / PREFERENCES", title: "让工作空间更适合你。", intro: "完善个人资料,只接收值得关注的通知。", profile: "公开资料", preference: "通知设置", name: "显示名称", nameHint: "使用团队成员能认出的名称。", email: "电子邮箱", emailHint: "用于账户和工作空间通知。", bio: "个人简介", bioHint: "用不超过240个字符介绍自己。", required: "请输入非空白名称。", emailError: "请输入有效的邮箱地址。", notificationTitle: "项目更新", notificationBody: "接收提及、任务分配和重要变更。", digestTitle: "每周摘要", digestBody: "每周汇总,减少日常打扰。", save: "保存更改", saving: "正在保存…", discard: "放弃更改", dirty: "有未保存的更改", clean: "所有更改已保存", failure: "未能保存", recovery: "编辑内容仍然保留。请重试,或放弃更改以恢复已保存的版本。", saved: "资料已更新", savedBody: "更改已反映在保存的版本中。", about: "工作空间中的个人资料", preview: "实时预览", navigation: "设置区域", demo: "这是本地交互示例,刷新后重置。请将 onSave 连接到实际服务。", fail: "模拟下次保存失败", error: "模拟保存失败", dismiss: "关闭通知" },
} as const;
function SettingsEditor({ initialValues, onSave, locale = "en" }: SettingsPanelProps) {
const t = copy[locale];
const id = React.useId();
const toast = useToast();
const [saved, setSaved] = React.useState(() => ({ ...initialValues }));
const [draft, setDraft] = React.useState(() => ({ ...initialValues }));
const [pending, setPending] = React.useState(false);
const busy = React.useRef(false);
const [failed, setFailed] = React.useState(false);
const [revision, setRevision] = React.useState(0);
const dirty = Object.keys(saved).some(key => saved[key as keyof ProfileSettings] !== draft[key as keyof ProfileSettings]);
const update = <K extends keyof ProfileSettings>(key: K, value: ProfileSettings[K]) => { setDraft(current => ({ ...current, [key]: value })); setFailed(false); };
const discard = () => { if (busy.current) return; setDraft({ ...saved }); setFailed(false); setRevision(value => value + 1); };
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (busy.current || !dirty) return;
busy.current = true; setPending(true); setFailed(false);
const snapshot = { ...draft, displayName: draft.displayName.trim(), email: draft.email.trim() };
try { await onSave(snapshot); setSaved(snapshot); setDraft(snapshot); toast.add({ title: t.saved, description: t.savedBody, type: "success" }); }
catch { setFailed(true); }
finally { busy.current = false; setPending(false); }
}
return <div data-slot="settings-panel" className="mx-auto grid w-full max-w-5xl gap-8 font-sans text-[var(--foreground)]">
<header className="grid gap-3 border-b border-[var(--border)] pb-7"><p className="text-xs font-semibold tracking-[0.16em] text-[var(--muted-foreground)]">{t.kicker}</p><h2 className="max-w-xl text-3xl font-semibold leading-tight tracking-tight sm:text-4xl">{t.title}</h2><p className="max-w-xl text-sm leading-relaxed text-[var(--muted-foreground)]">{t.intro}</p></header>
<div className="grid min-w-0 gap-8 lg:grid-cols-[15rem_minmax(0,1fr)]">
<aside className="grid content-start gap-6"><nav aria-label={t.navigation} className="flex gap-2 lg:grid"><a href={`#${id}-profile`} className="rounded-[var(--neu-radius-control)] border border-transparent bg-[var(--neu-surface)] text-[var(--neu-accent-ink)] shadow-[var(--neu-shadow-inset)] px-4 py-3 text-sm font-semibold no-underline outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)]">{t.profile}</a><a href={`#${id}-notifications`} className="rounded-[var(--neu-radius-control)] px-4 py-3 text-sm text-[var(--muted-foreground)] no-underline outline-none hover:bg-[var(--neu-surface-soft)] focus-visible:ring-2 focus-visible:ring-[var(--ring)]">{t.preference}</a></nav>
<section aria-label={t.preview} className="grid gap-4 rounded-[var(--neu-radius-surface)] border border-[var(--border)] bg-[var(--neu-surface)] p-5"><span aria-hidden="true" className="grid size-14 place-items-center rounded-[var(--neu-radius-control)] bg-[var(--neu-surface)] text-xl font-semibold [box-shadow:var(--neu-shadow-raised-sm)]">{draft.displayName.trim().slice(0, 2).toUpperCase() || "—"}</span><div className="grid min-w-0 gap-1"><p className="font-semibold [overflow-wrap:anywhere]">{draft.displayName || t.name}</p><p className="text-xs leading-relaxed text-[var(--muted-foreground)] [overflow-wrap:anywhere]">{draft.email}</p></div><p className="text-xs text-[var(--muted-foreground)]">{t.about}</p></section>
</aside>
<Form key={revision} onSubmit={submit} aria-busy={pending} className="gap-7">
<fieldset disabled={pending} className="m-0 grid min-w-0 gap-7 border-0 p-0">
<section id={`${id}-profile`} className="grid scroll-mt-24 gap-5 rounded-[var(--neu-radius-surface)] border border-[color:var(--neu-edge)] bg-[var(--neu-surface)] p-5 [box-shadow:var(--neu-shadow-raised-sm)]"><h3 className="text-lg font-semibold">{t.profile}</h3>
<Field name="displayName" validate={value => String(value).trim() ? null : t.required}><FieldLabel>{t.name}</FieldLabel><FieldControl render={<Input />} value={draft.displayName} onValueChange={value => update("displayName", value)} required maxLength={60} autoComplete="name" /><FieldDescription>{t.nameHint}</FieldDescription><FieldError>{t.required}</FieldError></Field>
<Field name="email"><FieldLabel>{t.email}</FieldLabel><FieldControl render={<Input />} type="email" value={draft.email} onValueChange={value => update("email", value)} required autoComplete="email" /><FieldDescription>{t.emailHint}</FieldDescription><FieldError>{t.emailError}</FieldError></Field>
<div className="grid gap-2"><label htmlFor={`${id}-bio`} className="text-sm font-semibold">{t.bio}</label><Textarea id={`${id}-bio`} name="bio" value={draft.bio} onChange={event => update("bio", event.target.value)} maxLength={240} rows={4} aria-describedby={`${id}-bio-hint`} /><div className="flex justify-between gap-4 text-xs text-[var(--muted-foreground)]"><p id={`${id}-bio-hint`}>{t.bioHint}</p><span className="tabular-nums">{draft.bio.length}/240</span></div></div>
</section>
<section id={`${id}-notifications`} className="grid scroll-mt-24 gap-5 rounded-[var(--neu-radius-surface)] border border-[var(--border)] bg-transparent p-5"><h3 className="text-lg font-semibold">{t.preference}</h3>
{([['notifications', t.notificationTitle, t.notificationBody], ['weeklyDigest', t.digestTitle, t.digestBody]] as const).map(([key, label, description]) => <div key={key} className="flex items-center justify-between gap-5"><div className="grid gap-1"><label htmlFor={`${id}-${key}`} className="text-sm font-semibold">{label}</label><p id={`${id}-${key}-hint`} className="text-sm leading-relaxed text-[var(--muted-foreground)]">{description}</p></div><Switch id={`${id}-${key}`} name={key} checked={draft[key]} onCheckedChange={value => update(key, value)} aria-describedby={`${id}-${key}-hint`} /></div>)}
</section>
</fieldset>
{failed && <Alert variant="destructive"><AlertTitle>{t.failure}</AlertTitle><AlertDescription>{t.recovery}</AlertDescription></Alert>}
<footer className="flex flex-wrap items-center justify-between gap-4 border-t border-[var(--border)] pt-5"><p role="status" className="text-sm text-[var(--muted-foreground)]">{pending ? t.saving : dirty ? t.dirty : t.clean}</p><div className="flex flex-wrap gap-3"><Button onClick={discard} disabled={!dirty || pending}>{t.discard}</Button><Button type="submit" variant="primary" disabled={!dirty || pending}>{pending ? t.saving : t.save}</Button></div></footer>
</Form>
</div>
</div>;
}
export function SettingsPanel(props: SettingsPanelProps) {
const t = copy[props.locale ?? "en"];
return <ToastProvider><SettingsEditor {...props} /><Toaster label={t.preference} closeLabel={t.dismiss} /></ToastProvider>;
}
const exampleValues: ProfileSettings = { displayName: "Alex Kim", email: "alex@example.com", bio: "Designing useful things, one thoughtful detail at a time.", notifications: true, weeklyDigest: false };
export function SettingsExample({ locale = "en" }: { locale?: SettingsPanelProps["locale"] }) {
const [failNext, setFailNext] = React.useState(false);
const t = copy[locale];
return <div className="grid gap-6"><div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-dashed border-[var(--border)] p-4 text-xs leading-relaxed text-[var(--muted-foreground)]"><p className="max-w-xl">{t.demo}</p><label className="flex items-center gap-2"><input type="checkbox" checked={failNext} onChange={event => setFailNext(event.target.checked)} />{t.fail}</label></div><SettingsPanel initialValues={exampleValues} locale={locale} onSave={async () => { await new Promise(resolve => setTimeout(resolve, 350)); if (failNext) { setFailNext(false); throw new Error(t.error); } }} /></div>;
}