设置页
Claude 风格的设置页区块。这个示例是真的在工作:改动会作用于整个 MioUI 网站。
设置
这里的选择会立即作用于整个网站,并保存在本机浏览器。
颜色模式
主题色
只改变强调色。背景、文字、边框和状态色保持不变。
字体
默认:标题用衬线,界面和正文用系统字体。中文始终走苹方与系统字体。
界面大小
整体缩放文字与控件
动效
减弱动效
关闭弹簧与过渡动画
实时预览
组件只读取令牌,改设置即可看到全站效果。
'use client';
import { SettingsPanel } from '@/components/blocks/settings-panel';
export default function SettingsDemo() {
return <SettingsPanel />;
}
包含
- 颜色模式卡片(浅色 / 跟随系统 / 深色),带缩略图
- 主题色选择(只改强调色)
- 正文字体、界面大小、减弱动效
- 实时预览卡片
源码
'use client';
import { useEffect, useState } from 'react';
import { useTheme } from 'next-themes';
import { motion } from 'motion/react';
import {
Check,
Sun,
Moon,
Desktop,
PaintBrush,
TextAa,
Sparkle,
ArrowCounterClockwise,
MagnifyingGlass,
PaperPlaneRight,
} from '@phosphor-icons/react';
import { ACCENTS, useSettings, type FontPref, type ScalePref } from '@/lib/settings';
import { Button } from '@/components/ui/button';
import { Segmented } from '@/components/ui/segmented';
import { Switch } from '@/components/ui/switch';
import { Card, Badge } from '@/components/ui/card';
import { SPRING_GENTLE } from '@/lib/motion';
const COPY = {
zh: {
appearance_l: '外观',
type_l: '字体与大小',
motion_l: '动效',
fDefault: '默认',
fSerif: '衬线阅读',
fSans: '全无衬线',
title: '设置',
subtitle: '这里的选择会立即作用于整个网站,并保存在本机浏览器。',
reset: '恢复默认',
mode: '颜色模式',
light: '浅色',
system: '跟随系统',
dark: '深色',
accent: '主题色',
accentDesc: '只改变强调色。背景、文字、边框和状态色保持不变。',
font: '字体',
fontDesc: '默认:标题用衬线,界面和正文用系统字体。中文始终走苹方与系统字体。',
sample: 'Aa 字',
scale: '界面大小',
scaleDesc: '整体缩放文字与控件',
sm: '紧凑',
md: '标准',
lg: '宽松',
reduce: '减弱动效',
reduceDesc: '关闭弹簧与过渡动画',
preview: '实时预览',
previewDesc: '组件只读取令牌,改设置即可看到全站效果。',
badge: '新',
demo: '示例',
t1: '概览',
t2: '组件',
t3: '区块',
h: '温润、克制,让内容先说话。',
p: '强调色只出现在关键操作和状态上,其余层级靠 Surface 明度与发丝边建立。',
search: '搜索组件…',
send: '发送',
b1: '主要操作',
b2: '次要',
b3: '描边',
b4: '幽灵',
b5: '删除',
ok: '● 成功',
warn: '● 警告',
bad: '● 危险',
acc: '● 强调文字',
link: '文档链接'
},
en: {
appearance_l: 'Appearance',
type_l: 'Type & size',
motion_l: 'Motion',
fDefault: 'Default',
fSerif: 'Serif reading',
fSans: 'All sans',
title: 'Settings',
subtitle: 'Changes apply to the whole site instantly and are saved in this browser.',
reset: 'Reset to defaults',
mode: 'Color mode',
light: 'Light',
system: 'System',
dark: 'Dark',
accent: 'Accent color',
accentDesc: 'Only the accent changes. Backgrounds, text, borders and status colors stay fixed.',
font: 'Font',
fontDesc: 'Default: serif headings, system sans for UI and body. Chinese always uses PingFang and system fonts.',
sample: 'Aa',
scale: 'Interface size',
scaleDesc: 'Scale text and controls together',
sm: 'Compact',
md: 'Default',
lg: 'Roomy',
reduce: 'Reduce motion',
reduceDesc: 'Turn off springs and transitions',
preview: 'Live preview',
previewDesc: 'Components only read tokens, so every change shows up site-wide.',
badge: 'New',
demo: 'Example',
t1: 'Overview',
t2: 'Components',
t3: 'Blocks',
h: 'Warm and restrained. Let the content speak.',
p: 'The accent appears only on key actions and states; everything else is layered with surface tones and hairlines.',
search: 'Search components…',
send: 'Send',
b1: 'Primary',
b2: 'Secondary',
b3: 'Outline',
b4: 'Ghost',
b5: 'Delete',
ok: '● Success',
warn: '● Warning',
bad: '● Danger',
acc: '● Accent text',
link: 'Docs link'
},
};
const NAV = [
{ id: 'appearance', label: 'appearance_l' as const, icon: PaintBrush },
{ id: 'type', label: 'type_l' as const, icon: TextAa },
{ id: 'motion', label: 'motion_l' as const, icon: Sparkle },
];
function Section({ id, title, desc, children }: { id: string; title: string; desc?: string; children: React.ReactNode }) {
return (
<section id={id} className="scroll-mt-24 border-t border-hairline py-8 first:border-t-0 first:pt-0">
<h2 className="text-[15px] font-semibold text-text-primary">{title}</h2>
{desc && <p className="mt-1 text-sm text-text-secondary">{desc}</p>}
<div className="mt-5">{children}</div>
</section>
);
}
function ModeThumb({ mode }: { mode: 'light' | 'dark' | 'system' }) {
const L = { bg: '#f9f9f7', panel: '#ffffff', line: 'rgba(20,20,20,.12)', text: 'rgba(20,20,20,.55)' };
const D = { bg: '#0b0b0b', panel: '#1a1a19', line: 'rgba(255,255,255,.12)', text: 'rgba(255,255,255,.5)' };
const half = (c: typeof L) => (
<div className="flex h-full flex-1 flex-col gap-1.5 p-2" style={{ background: c.bg }}>
<div className="h-1.5 w-8 rounded-full" style={{ background: c.text }} />
<div className="flex-1 rounded-md p-1.5" style={{ background: c.panel, boxShadow: `0 0 0 1px ${c.line}` }}>
<div className="h-1 w-10 rounded-full" style={{ background: c.line }} />
<div className="mt-1 h-1 w-6 rounded-full" style={{ background: c.line }} />
<div className="mt-2 h-2 w-5 rounded-full" style={{ background: 'var(--cds-accent)' }} />
</div>
</div>
);
return (
<div className="flex h-[76px] w-full overflow-hidden rounded-lg">
{mode === 'light' && half(L)}
{mode === 'dark' && half(D)}
{mode === 'system' && (
<>
{half(L)}
{half(D)}
</>
)}
</div>
);
}
function ChoiceCard({
active,
onClick,
children,
label,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
label: React.ReactNode;
}) {
return (
<button onClick={onClick} aria-pressed={active} className="cds-focus group flex flex-col gap-2 rounded-xl text-left">
<div
className={
'w-full rounded-xl p-1 transition-shadow duration-[120ms] ' +
(active
? 'shadow-[0_0_0_1.5px_var(--cds-accent)]'
: 'shadow-[0_0_0_1px_var(--cds-border)] group-hover:shadow-[0_0_0_1px_var(--cds-border-strong)]')
}
>
{children}
</div>
<span className={'px-0.5 text-[13px] ' + (active ? 'font-medium text-text-primary' : 'text-text-secondary')}>{label}</span>
</button>
);
}
export function SettingsPanel({ lang = 'zh' }: { lang?: 'zh' | 'en' }) {
const t = COPY[lang];
const { settings, update, reset } = useSettings();
const { theme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const [active, setActive] = useState('appearance');
const [tab, setTab] = useState<'a' | 'b' | 'c'>('a');
useEffect(() => setMounted(true), []);
const mode = (mounted ? theme : 'system') as 'light' | 'dark' | 'system';
const fonts: { id: FontPref; label: string; family: string }[] = [
{ id: 'default', label: t.fDefault, family: 'var(--cds-font-ui)' },
{ id: 'serif', label: t.fSerif, family: 'var(--cds-font-serif)' },
{ id: 'system', label: t.fSans, family: 'var(--cds-font-system)' },
];
return (
<div className="w-full bg-surface-1 px-6 pb-10 pt-8 text-left">
<h1 className="font-title text-[30px] leading-tight text-text-primary">{t.title}</h1>
<p className="mt-2 text-sm text-text-secondary">{t.subtitle}</p>
<div className="mt-10 grid gap-10 md:grid-cols-[180px_1fr]">
<nav className="md:self-start">
<ul className="flex gap-1 md:flex-col">
{NAV.map((n) => {
const Icon = n.icon;
const on = active === n.id;
return (
<li key={n.id}>
<a
href={`#${n.id}`}
onClick={() => setActive(n.id)}
className={
'cds-focus flex h-8 items-center gap-2 rounded-lg px-2.5 text-sm transition-colors duration-[120ms] ' +
(on ? 'bg-ghost-selected font-medium text-text-primary' : 'text-text-secondary hover:bg-ghost-hover hover:text-text-primary')
}
>
<Icon size={16} weight={on ? 'fill' : 'regular'} />
{t[n.label]}
</a>
</li>
);
})}
</ul>
<div className="mt-4 hidden md:block">
<Button variant="ghost" size="sm" onClick={() => { reset(); setTheme('system'); }}>
<ArrowCounterClockwise /> {t.reset}
</Button>
</div>
</nav>
<div className="min-w-0">
<Section id="appearance" title={t.mode}>
<div className="grid max-w-[560px] grid-cols-3 gap-4">
{([
['light', t.light, Sun],
['system', t.system, Desktop],
['dark', t.dark, Moon],
] as const).map(([m, label, Icon]) => (
<ChoiceCard
key={m}
active={mode === m}
onClick={() => setTheme(m)}
label={<span className="inline-flex items-center gap-1.5"><Icon size={14} />{label}</span>}
>
<ModeThumb mode={m} />
</ChoiceCard>
))}
</div>
</Section>
<Section title={t.accent} id="accent" desc={t.accentDesc}>
<div className="flex flex-wrap gap-3">
{ACCENTS.map((a) => {
const on = settings.accent === a.id;
return (
<button
key={a.id}
onClick={() => update({ accent: a.id })}
aria-pressed={on}
className={
'cds-focus flex h-10 items-center gap-2.5 rounded-full pl-1.5 pr-4 text-[13px] transition-shadow duration-[120ms] ' +
(on
? 'bg-surface-2 font-medium text-text-primary shadow-[0_0_0_1.5px_var(--cds-accent)]'
: 'text-text-secondary shadow-[0_0_0_1px_var(--cds-border)] hover:shadow-[0_0_0_1px_var(--cds-border-strong)]')
}
>
<span className="relative grid size-7 place-items-center rounded-full">
<span className="absolute inset-0 rounded-full dark:hidden" style={{ background: a.light }} />
<span className="absolute inset-0 hidden rounded-full dark:block" style={{ background: a.dark }} />
{on && (
<motion.span initial={{ scale: 0.4, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} transition={SPRING_GENTLE} className="relative">
<Check size={14} weight="bold" color={a.id === 'ink' ? 'var(--cds-accent-foreground)' : '#fff'} />
</motion.span>
)}
</span>
{lang === 'en' ? a.name.split(' ')[0] : a.name}
</button>
);
})}
</div>
</Section>
<Section id="type" title={t.font} desc={t.fontDesc}>
<div className="grid max-w-[560px] grid-cols-3 gap-4">
{fonts.map((f) => (
<ChoiceCard key={f.id} active={settings.font === f.id} onClick={() => update({ font: f.id })} label={f.label}>
<div className="grid h-[76px] place-items-center rounded-lg bg-surface-0">
<span className="text-[26px] text-text-primary" style={{ fontFamily: f.family }}>{t.sample}</span>
</div>
</ChoiceCard>
))}
</div>
<div className="mt-6 flex items-center justify-between gap-4 max-w-[560px]">
<div>
<div className="text-sm font-medium text-text-primary">{t.scale}</div>
<div className="text-[13px] text-text-secondary">{t.scaleDesc}</div>
</div>
<Segmented<ScalePref>
ariaLabel={t.scale}
value={settings.scale}
onChange={(v) => update({ scale: v })}
options={[
{ value: 'sm', label: t.sm },
{ value: 'md', label: t.md },
{ value: 'lg', label: t.lg },
]}
/>
</div>
</Section>
<Section id="motion" title={t.motion_l}>
<div className="flex max-w-[560px] items-center justify-between gap-4">
<div>
<div className="text-sm font-medium text-text-primary">{t.reduce}</div>
<div className="text-[13px] text-text-secondary">{t.reduceDesc}</div>
</div>
<Switch label={t.reduce} checked={settings.motion === 'reduce'} onChange={(v) => update({ motion: v ? 'reduce' : 'full' })} />
</div>
</Section>
<Section id="preview" title={t.preview} desc={t.previewDesc}>
<Card className="max-w-[640px] p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="cds-dot-breathe size-2 rounded-full bg-accent-brand" />
<span className="text-sm font-medium">MioUI</span>
<Badge>{t.badge}</Badge>
</div>
<Segmented
ariaLabel={t.demo}
value={tab}
onChange={setTab}
options={[
{ value: 'a', label: t.t1 },
{ value: 'b', label: t.t2 },
{ value: 'c', label: t.t3 },
]}
/>
</div>
<p className="font-title mt-5 text-[22px] leading-snug">{t.h}</p>
<p className="mt-1.5 text-sm text-text-secondary">
{t.p}
</p>
<div className="mt-5 flex items-center gap-2 rounded-[20px] bg-surface-3 py-1.5 pl-4 pr-1.5 shadow-[0_4px_20px_rgba(0,0,0,0.035),inset_0_0_0_1px_var(--cds-border)] focus-within:shadow-[0_4px_24px_rgba(0,0,0,0.08),inset_0_0_0_1px_var(--cds-border-strong)]">
<MagnifyingGlass size={16} className="text-text-muted" />
<input
placeholder={t.search}
className="h-8 flex-1 bg-transparent text-sm outline-none placeholder:text-text-muted"
/>
<Button size="icon" aria-label={t.send}><PaperPlaneRight weight="fill" /></Button>
</div>
<div className="mt-5 flex flex-wrap gap-2">
<Button>{t.b1}</Button>
<Button variant="secondary">{t.b2}</Button>
<Button variant="outline">{t.b3}</Button>
<Button variant="ghost">{t.b4}</Button>
<Button variant="danger">{t.b5}</Button>
</div>
<div className="mt-5 flex flex-wrap gap-4 text-[13px]">
<span className="text-success">{t.ok}</span>
<span className="text-warning">{t.warn}</span>
<span className="text-danger">{t.bad}</span>
<span className="text-accent-text">{t.acc}</span>
<a className="text-accent-text underline underline-offset-4" href={`/${lang}/docs`}>{t.link}</a>
</div>
</Card>
</Section>
<div className="md:hidden">
<Button variant="ghost" size="sm" onClick={() => { reset(); setTheme('system'); }}>
<ArrowCounterClockwise /> {t.reset}
</Button>
</div>
</div>
</div>
</div>
);
}