MioUI

Settings Page

A Claude-style settings block. This demo is live — changes apply to the whole MioUI site.

Settings

Changes apply to the whole site instantly and are saved in this browser.

Color mode

Accent color

Only the accent changes. Backgrounds, text, borders and status colors stay fixed.

Font

Default: serif headings, system sans for UI and body. Chinese always uses PingFang and system fonts.

Interface size
Scale text and controls together

Motion

Reduce motion
Turn off springs and transitions

Live preview

Components only read tokens, so every change shows up site-wide.

MioUINew

Warm and restrained. Let the content speak.

The accent appears only on key actions and states; everything else is layered with surface tones and hairlines.

● Success● Warning● Danger● Accent textDocs link

Includes

  • Color mode cards (Light / System / Dark) with thumbnails
  • Accent color picker (accent only)
  • Body font, interface size, reduce motion
  • Live preview card

Source

components/blocks/settings-panel.tsx
'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>
  );
}