Input Field
A floating-label input with helper, error and success states, adornments, counter, clear and password reveal.
Grouped settings with switches, a bubble slider, radio cards, a select and a save bar that slides in when dirty.
Browser support · All evergreen browsers. Uses :has() for the radio-card focus ring and color-mix().
npx shadcn@latest add https://freedesignmd.com/r/settings-form.json1/**2 * SettingsForm — grouped settings with switches (role="switch"), range3 * sliders with a value bubble, radio cards, a select, and a save bar that4 * slides in as soon as anything changes.5 *6 * Props: initial (HomeSettings), onSave(values) (may return a Promise),7 * theme. Building blocks are exported too: Switch, RangeField, RadioCards,8 * SelectField, SaveBar — compose your own sections with them.9 *10 * Browser support: all evergreen browsers. The slider uses a native11 * <input type="range"> with ::-webkit / ::-moz thumb styling.12 * Accessibility: switches are <button role="switch" aria-checked> with the13 * label linked via aria-labelledby/aria-describedby; sliders keep native14 * keyboard (arrows, Home/End, PageUp/Down) and expose aria-valuetext; radio15 * cards use real radios (arrow keys); the save bar is a labelled region with16 * a polite live status and Escape-free: Discard and Save are plain buttons.17 * Reduced motion: the bar and knobs change instantly.18 */19import { useEffect, useId, useMemo, useRef, useState } from "react";20import type { ReactNode } from "react";21 22export interface HomeSettings {23 comfort: number;24 setback: number;25 eco: boolean;26 away: boolean;27 preheat: boolean;28 plan: "solar" | "cheap" | "comfort";29 tariff: string;30 alertPeak: boolean;31 weekly: boolean;32}33 34const DEFAULTS: HomeSettings = { comfort: 21, setback: 17.5, eco: true, away: true, preheat: false, plan: "solar", tariff: "octo", alertPeak: true, weekly: false };35 36/* ── primitives ── */37 38export function Switch({ label, description, checked, onChange, disabled }: { label: string; description?: ReactNode; checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {39 const id = useId();40 return (41 <div className="fd-set-row">42 <div className="fd-set-txt">43 <span id={`${id}-l`} className="fd-set-label">{label}</span>44 {description && <span id={`${id}-d`} className="fd-set-desc">{description}</span>}45 </div>46 <button type="button" role="switch" aria-checked={checked} aria-labelledby={`${id}-l`} aria-describedby={description ? `${id}-d` : undefined} disabled={disabled} className="fd-set-switch" onClick={() => onChange(!checked)}>47 <span className="fd-set-knob" />48 </button>49 </div>50 );51}52 53export function RangeField({ label, value, min, max, step = 1, unit = "", onChange, format, hint }: { label: string; value: number; min: number; max: number; step?: number; unit?: string; onChange: (v: number) => void; format?: (v: number) => string; hint?: string }) {54 const id = useId();55 const [active, setActive] = useState(false);56 const p = (value - min) / (max - min);57 const text = format ? format(value) : `${value}${unit}`;58 return (59 <div className="fd-set-range">60 <div className="fd-set-rhead">61 <label htmlFor={id} className="fd-set-label">{label}</label>62 <output htmlFor={id} className="fd-set-out">{text}</output>63 </div>64 <div className="fd-set-track" data-active={active || undefined} style={{ ["--p" as string]: p }}>65 <input66 id={id}67 type="range"68 min={min}69 max={max}70 step={step}71 value={value}72 aria-valuetext={text}73 onChange={(e) => onChange(Number(e.target.value))}74 onPointerDown={() => setActive(true)}75 onPointerUp={() => setActive(false)}76 onFocus={(e) => e.currentTarget.matches(":focus-visible") && setActive(true)}77 onBlur={() => setActive(false)}78 />79 <span className="fd-set-bubble" aria-hidden="true">{text}</span>80 </div>81 <div className="fd-set-ends" aria-hidden="true"><span>{format ? format(min) : `${min}${unit}`}</span>{hint && <span>{hint}</span>}<span>{format ? format(max) : `${max}${unit}`}</span></div>82 </div>83 );84}85 86export function RadioCards<T extends string>({ legend, value, onChange, options }: { legend: string; value: T; onChange: (v: T) => void; options: { value: T; title: string; desc: string; icon: ReactNode; tag?: string }[] }) {87 const name = useId();88 return (89 <fieldset className="fd-set-cards">90 <legend className="fd-set-label">{legend}</legend>91 <div className="fd-set-cardgrid">92 {options.map((o) => (93 <label key={o.value} className="fd-set-card" data-on={value === o.value || undefined}>94 <input type="radio" name={name} value={o.value} checked={value === o.value} onChange={() => onChange(o.value)} />95 <span className="fd-set-cicon">{o.icon}</span>96 <span className="fd-set-ctitle">{o.title}{o.tag && <em>{o.tag}</em>}</span>97 <span className="fd-set-cdesc">{o.desc}</span>98 <span className="fd-set-cradio" aria-hidden="true"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.4" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg></span>99 </label>100 ))}101 </div>102 </fieldset>103 );104}105 106export function SelectField({ label, value, onChange, options, hint }: { label: string; value: string; onChange: (v: string) => void; options: { value: string; label: string }[]; hint?: string }) {107 const id = useId();108 return (109 <div className="fd-set-select">110 <label htmlFor={id} className="fd-set-label">{label}</label>111 <div className="fd-set-sbox">112 <select id={id} value={value} onChange={(e) => onChange(e.target.value)}>113 {options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}114 </select>115 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg>116 </div>117 {hint && <p className="fd-set-desc">{hint}</p>}118 </div>119 );120}121 122export function SaveBar({ open, changes, saving, saved, onDiscard, onSave }: { open: boolean; changes: number; saving?: boolean; saved?: boolean; onDiscard: () => void; onSave: () => void }) {123 return (124 <div className="fd-set-bar" data-open={open || undefined} role="region" aria-label="Unsaved changes" aria-hidden={!open}>125 <p role="status">{saved ? "All changes saved" : `${changes} unsaved ${changes === 1 ? "change" : "changes"}`}</p>126 <button type="button" className="fd-set-bbtn" data-v="ghost" onClick={onDiscard} disabled={!open || saving || saved} tabIndex={open ? 0 : -1}>Discard</button>127 <button type="button" className="fd-set-bbtn" data-v="primary" data-state={saving ? "saving" : saved ? "saved" : undefined} onClick={onSave} disabled={!open || saving || saved} tabIndex={open ? 0 : -1}>128 <span>Save changes</span>129 <svg className="fd-set-spin" width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity=".25" strokeWidth="2.5" /><path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" /></svg>130 <svg className="fd-set-ok" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg>131 </button>132 </div>133 );134}135 136/* ── composed form ── */137 138const ic = (d: string) => <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d={d} /></svg>;139const ICONS = {140 sun: ic("M12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"),141 coin: ic("M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM14.8 9.2C14.3 8.5 13.3 8 12 8c-1.7 0-3 .8-3 2s1.3 1.8 3 2 3 .8 3 2-1.3 2-3 2c-1.3 0-2.3-.5-2.8-1.2M12 6.5V8M12 16v1.5"),142 heart: ic("M20.8 5.6a5.5 5.5 0 0 0-7.8 0L12 6.7l-1-1.1a5.5 5.5 0 0 0-7.8 7.8L12 22l8.8-8.6a5.5 5.5 0 0 0 0-7.8z"),143};144 145export interface SettingsFormProps {146 initial?: HomeSettings;147 onSave?: (v: HomeSettings) => void | Promise<unknown>;148 theme?: "light" | "dark";149 className?: string;150 /** Rendered beside the form (sticky summary). */151 aside?: (v: HomeSettings) => ReactNode;152}153 154export function SettingsForm({ initial = DEFAULTS, onSave, theme, className, aside }: SettingsFormProps) {155 const [saved, setSaved] = useState(initial);156 const [v, setV] = useState(initial);157 const [phase, setPhase] = useState<"idle" | "saving" | "saved">("idle");158 const timer = useRef(0);159 useEffect(() => () => window.clearTimeout(timer.current), []);160 161 const changes = useMemo(() => (Object.keys(v) as (keyof HomeSettings)[]).filter((k) => v[k] !== saved[k]).length, [v, saved]);162 const dirty = changes > 0;163 const set = <K extends keyof HomeSettings>(k: K, val: HomeSettings[K]) => {164 if (phase === "saved") setPhase("idle");165 setV((p) => ({ ...p, [k]: val }));166 };167 168 const save = async () => {169 setPhase("saving");170 try {171 await onSave?.(v);172 await new Promise((r) => (timer.current = window.setTimeout(r, 900)));173 setSaved(v);174 setPhase("saved");175 timer.current = window.setTimeout(() => setPhase("idle"), 1600);176 } catch {177 setPhase("idle");178 }179 };180 181 return (182 <div className={`fd-set${className ? " " + className : ""}`} data-fd-theme={theme}>183 <style>{css}</style>184 <div className="fd-set-layout">185 <form className="fd-set-form" onSubmit={(e) => { e.preventDefault(); if (dirty) save(); }} aria-label="Home energy settings">186 <section className="fd-set-sec" aria-labelledby="fd-set-s1">187 <header><h2 id="fd-set-s1">Comfort</h2><p>Set the temperatures your home aims for.</p></header>188 <RangeField label="Target temperature" value={v.comfort} min={16} max={28} step={0.5} format={(n) => `${n.toFixed(1)} °C`} onChange={(n) => set("comfort", n)} hint="Recommended 20–22" />189 <RangeField label="Night setback" value={v.setback} min={14} max={21} step={0.5} format={(n) => `${n.toFixed(1)} °C`} onChange={(n) => set("setback", n)} />190 </section>191 192 <section className="fd-set-sec" aria-labelledby="fd-set-s2">193 <header><h2 id="fd-set-s2">Automation</h2><p>Let the thermostat adapt to your routine.</p></header>194 <Switch label="Eco mode" description="Lower the target by 1 °C when rooms are empty." checked={v.eco} onChange={(b) => set("eco", b)} />195 <Switch label="Away detection" description="Pause heating when all phones leave the geofence." checked={v.away} onChange={(b) => set("away", b)} />196 <Switch label="Pre-heat before arrival" description="Start warming 30 minutes before you get home." checked={v.preheat} onChange={(b) => set("preheat", b)} />197 </section>198 199 <section className="fd-set-sec" aria-labelledby="fd-set-s3">200 <header><h2 id="fd-set-s3">Energy plan</h2><p>Choose what the system optimises for.</p></header>201 <RadioCards202 legend="Optimise for"203 value={v.plan}204 onChange={(p) => set("plan", p)}205 options={[206 { value: "solar", title: "Solar first", desc: "Run heavy loads when your panels produce.", icon: ICONS.sun, tag: "Popular" },207 { value: "cheap", title: "Cheapest hours", desc: "Shift charging and heating to low-price slots.", icon: ICONS.coin },208 { value: "comfort", title: "Max comfort", desc: "Never compromise on temperature.", icon: ICONS.heart },209 ]}210 />211 <SelectField label="Electricity tariff" value={v.tariff} onChange={(t) => set("tariff", t)} hint="Used to forecast cost from live day-ahead prices." options={[{ value: "octo", label: "Octopus Agile · dynamic" }, { value: "ovo", label: "OVO Simpler · fixed 27.4p/kWh" }, { value: "edf", label: "EDF GoElectric · EV night rate" }, { value: "none", label: "Not listed" }]} />212 </section>213 214 <section className="fd-set-sec" aria-labelledby="fd-set-s4">215 <header><h2 id="fd-set-s4">Notifications</h2></header>216 <Switch label="Peak price alerts" description="Push notification 15 minutes before prices spike." checked={v.alertPeak} onChange={(b) => set("alertPeak", b)} />217 <Switch label="Weekly energy report" description="A Sunday summary of usage, cost and CO₂ saved." checked={v.weekly} onChange={(b) => set("weekly", b)} />218 </section>219 </form>220 {aside && <aside className="fd-set-aside">{aside(v)}</aside>}221 </div>222 223 <SaveBar224 open={dirty || phase !== "idle"}225 changes={changes}226 saving={phase === "saving"}227 saved={phase === "saved"}228 onDiscard={() => setV(saved)}229 onSave={save}230 />231 </div>232 );233}234 235const css = `236:where(.fd-set,.fd-set *){box-sizing:border-box;margin:0;padding:0;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none}237:where(.fd-set,.fd-set *)::before,:where(.fd-set,.fd-set *)::after{box-sizing:border-box}238:where(.fd-set svg){display:block;flex-shrink:0}239:where(.fd-set button,.fd-set input,.fd-set select){appearance:none;-webkit-appearance:none;background:none;border:0;border-radius:0;outline:0}240:where(.fd-set fieldset){min-width:0}241.fd-set{242 --fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-ease-in-out:cubic-bezier(.65,0,.35,1);--fd-spring:cubic-bezier(.34,1.56,.64,1);--fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;243 --t-card:#fff;--t-fg:#0B0B0F;--t-mut:#6B6B76;--t-line:rgba(11,11,15,.08);--t-track:rgba(11,11,15,.12);--t-soft:rgba(11,11,15,.04);--t-accent:#0E9F6E;--t-ring:rgba(10,132,255,.7);--t-field:#fff;--t-field-line:rgba(11,11,15,.16);244 position:relative;width:100%;245 font:400 15px/1.4 ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;letter-spacing:-.005em;color:var(--t-fg);246}247[data-fd-theme="dark"] .fd-set,.fd-set[data-fd-theme="dark"]{--t-card:#111115;--t-fg:#F5F5F7;--t-mut:#9A9AA6;--t-line:rgba(255,255,255,.09);--t-track:rgba(255,255,255,.14);--t-soft:rgba(255,255,255,.05);--t-accent:#34D399;--t-field:#16161B;--t-field-line:rgba(255,255,255,.14)}248.fd-set-layout{display:grid;grid-template-columns:minmax(0,1fr);gap:20px;align-items:start}249.fd-set-layout:has(.fd-set-aside){grid-template-columns:minmax(0,1fr) 300px}250.fd-set-form{display:flex;flex-direction:column;gap:16px}251.fd-set-sec{display:flex;flex-direction:column;gap:20px;padding:24px;border-radius:24px;background:var(--t-card);box-shadow:0 0 0 1px var(--t-line),0 1px 2px rgba(0,0,0,.03),0 16px 32px -24px rgba(0,0,0,.18)}252.fd-set-sec header h2{font-size:17px;font-weight:600;line-height:1.2;letter-spacing:-.01em}253.fd-set-sec header p{margin-top:4px;font-size:14px;line-height:1.4;color:var(--t-mut)}254.fd-set-label{font-size:15px;font-weight:500;line-height:1.3;color:var(--t-fg)}255.fd-set-desc{display:block;font-size:13px;line-height:1.4;color:var(--t-mut)}256.fd-set-row{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:4px 0}257.fd-set-row+.fd-set-row{padding-top:16px;border-top:1px solid var(--t-line)}258.fd-set-txt{display:flex;flex-direction:column;gap:3px;min-width:0}259.fd-set-switch{position:relative;flex:none;width:48px;height:28px;border-radius:9999px;cursor:pointer;background:var(--t-track);260 transition:background-color var(--fd-dur-2) var(--fd-ease-out),box-shadow var(--fd-dur-2) var(--fd-ease-out);box-shadow:inset 0 1px 2px rgba(0,0,0,.12)}261.fd-set-switch[aria-checked="true"]{background:var(--t-accent)}262.fd-set-knob{position:absolute;left:2px;top:2px;width:24px;height:24px;border-radius:50%;background:#fff;box-shadow:0 1px 1px rgba(0,0,0,.12),0 2px 5px rgba(0,0,0,.22);transition:transform var(--fd-dur-3) var(--fd-spring),width var(--fd-dur-1) var(--fd-ease-out)}263.fd-set-switch[aria-checked="true"] .fd-set-knob{transform:translateX(20px)}264.fd-set-switch:active .fd-set-knob{width:28px}.fd-set-switch[aria-checked="true"]:active .fd-set-knob{transform:translateX(16px)}265.fd-set-switch:focus-visible{outline:2px solid var(--t-ring);outline-offset:2px}266.fd-set-switch:disabled{opacity:.45;cursor:not-allowed}267.fd-set-range{display:flex;flex-direction:column;gap:12px}268.fd-set-rhead{display:flex;justify-content:space-between;align-items:baseline}269.fd-set-out{font-size:15px;font-weight:600;font-variant-numeric:tabular-nums;color:var(--t-fg)}270.fd-set-track{position:relative;height:28px;display:flex;align-items:center}271.fd-set-track input{width:100%;height:28px;margin:0;cursor:pointer;background:transparent}272.fd-set-track input::-webkit-slider-runnable-track{height:6px;border-radius:9999px;background:linear-gradient(to right,var(--t-accent) calc(11px + (100% - 22px) * var(--p)),var(--t-track) 0)}273.fd-set-track input::-moz-range-track{height:6px;border-radius:9999px;background:var(--t-track)}274.fd-set-track input::-moz-range-progress{height:6px;border-radius:9999px;background:var(--t-accent)}275.fd-set-track input::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:22px;height:22px;margin-top:-8px;border-radius:50%;border:0;background:#fff;box-shadow:0 0 0 .5px rgba(0,0,0,.08),0 1px 2px rgba(0,0,0,.18),0 3px 8px rgba(0,0,0,.18);transition:transform var(--fd-dur-3) var(--fd-spring)}276.fd-set-track input::-moz-range-thumb{width:22px;height:22px;border-radius:50%;border:0;background:#fff;box-shadow:0 0 0 .5px rgba(0,0,0,.08),0 1px 2px rgba(0,0,0,.18),0 3px 8px rgba(0,0,0,.18)}277.fd-set-track input:active::-webkit-slider-thumb{transform:scale(1.12)}278.fd-set-track input:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 2px var(--t-ring),0 2px 8px rgba(0,0,0,.2)}279.fd-set-track input:focus-visible::-moz-range-thumb{box-shadow:0 0 0 2px var(--t-ring)}280.fd-set-bubble{position:absolute;bottom:calc(100% + 2px);left:calc(11px + (100% - 22px) * var(--p));transform:translate(-50%,6px) scale(.8);transform-origin:50% 100%;opacity:0;pointer-events:none;281 padding:6px 10px;border-radius:9px;background:var(--t-fg);color:var(--t-card);font-size:12px;font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap;282 transition:opacity var(--fd-dur-2) var(--fd-ease-out),transform var(--fd-dur-3) var(--fd-spring)}283.fd-set-bubble::after{content:"";position:absolute;left:50%;top:100%;width:8px;height:8px;margin:-4px 0 0 -4px;background:inherit;transform:rotate(45deg);border-radius:0 0 2px 0}284@media (hover:hover){.fd-set-track:hover .fd-set-bubble{opacity:1;transform:translate(-50%,0) scale(1)}}285.fd-set-track[data-active] .fd-set-bubble{opacity:1;transform:translate(-50%,0) scale(1)}286.fd-set-ends{display:flex;justify-content:space-between;font-size:12px;line-height:1;color:var(--t-mut)}287.fd-set-cards{border:0}.fd-set-cards legend{margin-bottom:12px;padding:0}288.fd-set-cardgrid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}289.fd-set-card{position:relative;display:flex;flex-direction:column;gap:6px;padding:16px;border-radius:16px;cursor:pointer;background:var(--t-field);box-shadow:0 0 0 1px var(--t-field-line);290 transition:box-shadow var(--fd-dur-2) var(--fd-ease-out),background-color var(--fd-dur-2) var(--fd-ease-out),transform var(--fd-dur-3) var(--fd-spring)}291@media (hover:hover){.fd-set-card:hover{box-shadow:0 0 0 1px var(--t-mut)}}292.fd-set-card input{position:absolute;opacity:0;inset:0;width:100%;height:100%;cursor:pointer}293.fd-set-card:has(input:focus-visible){outline:2px solid var(--t-ring);outline-offset:2px}294.fd-set-card:active{transform:scale(.98);transition-duration:var(--fd-dur-1)}295.fd-set-card[data-on]{box-shadow:0 0 0 1.5px var(--t-accent);background:color-mix(in srgb,var(--t-accent) 7%,var(--t-field))}296.fd-set-cicon{display:grid;place-items:center;width:36px;height:36px;margin-bottom:6px;border-radius:10px;background:var(--t-soft);color:var(--t-mut);transition:background-color var(--fd-dur-2),color var(--fd-dur-2)}297.fd-set-card[data-on] .fd-set-cicon{background:var(--t-accent);color:#fff}298.fd-set-ctitle{display:flex;align-items:center;gap:8px;font-size:15px;font-weight:600;line-height:1.2}299.fd-set-ctitle em{font-style:normal;font-size:11px;font-weight:600;padding:3px 7px;border-radius:9999px;background:color-mix(in srgb,var(--t-accent) 16%,transparent);color:var(--t-accent)}300.fd-set-cdesc{font-size:13px;line-height:1.4;color:var(--t-mut)}301.fd-set-cradio{position:absolute;right:12px;top:12px;display:grid;place-items:center;width:20px;height:20px;border-radius:50%;box-shadow:0 0 0 1.5px var(--t-field-line) inset;color:transparent;transition:all var(--fd-dur-3) var(--fd-spring)}302.fd-set-card[data-on] .fd-set-cradio{background:var(--t-accent);box-shadow:none;color:#fff;transform:scale(1.05)}303.fd-set-select{display:flex;flex-direction:column;gap:8px}304.fd-set-sbox{position:relative}305.fd-set-sbox select{display:block;width:100%;height:48px;padding:0 40px 0 14px;border-radius:12px;cursor:pointer;background:var(--t-field);color:var(--t-fg);font-family:inherit;font-size:15px;letter-spacing:inherit;box-shadow:0 0 0 1px var(--t-field-line),0 1px 2px rgba(0,0,0,.04);transition:box-shadow var(--fd-dur-2) var(--fd-ease-out)}306.fd-set-sbox select:focus-visible{box-shadow:0 0 0 1.5px var(--t-ring),0 0 0 5px color-mix(in srgb,var(--t-ring) 25%,transparent)}307.fd-set-sbox select option{color:#0B0B0F;background:#fff}308.fd-set-sbox svg{position:absolute;right:14px;top:16px;pointer-events:none;color:var(--t-mut)}309.fd-set-aside{position:sticky;top:24px}310.fd-set-bar{position:sticky;z-index:5;bottom:16px;display:flex;align-items:center;gap:8px;width:max-content;max-width:calc(100% - 16px);margin:20px auto 0;padding:8px 8px 8px 20px;border-radius:9999px;311 background:#16161B;color:#F5F5F7;box-shadow:0 1px 0 rgba(255,255,255,.08) inset,0 0 0 1px rgba(255,255,255,.08),0 2px 6px rgba(0,0,0,.2),0 24px 48px -12px rgba(0,0,0,.5);312 transform:translateY(calc(100% + 40px)) scale(.96);opacity:0;pointer-events:none;visibility:hidden;transition:transform var(--fd-dur-3) var(--fd-spring),opacity var(--fd-dur-2) var(--fd-ease-out),visibility 0s linear var(--fd-dur-3)}313[data-fd-theme="dark"] .fd-set-bar,.fd-set-bar[data-fd-theme="dark"]{background:#F5F5F7;color:#0B0B0F;box-shadow:0 0 0 1px rgba(0,0,0,.06),0 2px 6px rgba(0,0,0,.2),0 24px 48px -12px rgba(0,0,0,.6)}314.fd-set-bar[data-open]{transform:none;opacity:1;pointer-events:auto;visibility:visible;transition-delay:0s}315.fd-set-bar p{margin-right:12px;font-size:14px;font-weight:500;line-height:1;white-space:nowrap;font-variant-numeric:tabular-nums}316.fd-set-bbtn{position:relative;display:inline-grid;place-items:center;height:40px;padding:0 18px;border-radius:9999px;cursor:pointer;font-family:inherit;font-size:14px;font-weight:500;line-height:1;transform:scale(1);transition:transform var(--fd-dur-3) var(--fd-spring),background-color var(--fd-dur-2),opacity var(--fd-dur-2)}317.fd-set-bbtn:active{transform:scale(.96);transition-duration:var(--fd-dur-1)}318.fd-set-bbtn:focus-visible{outline:2px solid #5AA9FF;outline-offset:2px}319.fd-set-bbtn[data-v="ghost"]{color:inherit;opacity:.7}.fd-set-bbtn[data-v="ghost"]:hover:not(:disabled){opacity:1;background:rgba(127,127,140,.2)}320.fd-set-bbtn[data-v="primary"]{background:#34D399;color:#04261A;min-width:136px}321.fd-set-bbtn[data-v="primary"]:hover:not(:disabled){background:#4BE0AA}322.fd-set-bbtn:disabled{cursor:default}.fd-set-bbtn[data-v="ghost"]:disabled{opacity:.3}323.fd-set-bbtn>*{grid-area:1/1;transition:opacity var(--fd-dur-2) var(--fd-ease-out),transform var(--fd-dur-3) var(--fd-ease-out)}324.fd-set-spin,.fd-set-ok{opacity:0;transform:scale(.6)}325.fd-set-spin{animation:fd-set-rot .8s linear infinite}326.fd-set-bbtn[data-state="saving"]>span,.fd-set-bbtn[data-state="saved"]>span{opacity:0;transform:translateY(-8px)}327.fd-set-bbtn[data-state="saving"] .fd-set-spin,.fd-set-bbtn[data-state="saved"] .fd-set-ok{opacity:1;transform:none}328.fd-set-ok path{stroke-dasharray:24;stroke-dashoffset:24}.fd-set-bbtn[data-state="saved"] .fd-set-ok path{animation:fd-set-draw 380ms var(--fd-ease-out) 100ms forwards}329@keyframes fd-set-rot{to{transform:rotate(360deg)}}330@keyframes fd-set-draw{to{stroke-dashoffset:0}}331@media (max-width:900px){.fd-set-layout:has(.fd-set-aside){grid-template-columns:minmax(0,1fr)}.fd-set-aside{position:static;order:-1}}332@media (max-width:640px){.fd-set-cardgrid{grid-template-columns:1fr}.fd-set-sec{padding:20px;border-radius:20px}.fd-set-card{flex-direction:row;flex-wrap:wrap;align-items:center;column-gap:12px}.fd-set-cicon{margin:0}.fd-set-cdesc{flex-basis:100%}.fd-set-bar{bottom:12px}.fd-set-bar p{margin-right:4px;font-size:13px}.fd-set-bbtn{padding:0 14px}.fd-set-bbtn[data-v="primary"]{min-width:112px}}333@media (prefers-reduced-motion:reduce){.fd-set-bar,.fd-set-knob,.fd-set-bubble,.fd-set-bbtn,.fd-set-card,.fd-set-cradio{transition:none}.fd-set-spin{animation:none}.fd-set-ok path{animation:none;stroke-dashoffset:0}}334@media (prefers-contrast:more){.fd-set-switch{box-shadow:0 0 0 1px currentColor}.fd-set-card{box-shadow:0 0 0 1px currentColor}}335`;336 337/* ───────────────────────────── demo ───────────────────────────── */338 339function estimate(v: HomeSettings) {340 const plan = { solar: -14, cheap: -9, comfort: 8 }[v.plan];341 const heat = (v.comfort - 20) * 5.2 - (v.setback - 17) * 2.4;342 const auto = (v.eco ? -6.5 : 0) + (v.away ? -5 : 0) + (v.preheat ? 3 : 0);343 const eur = Math.max(18, 74 + plan + heat + auto);344 const co2 = Math.max(12, 138 + plan * 3 + heat * 4 + auto * 3);345 return { eur, co2 };346}347 348export function Demo() {349 const [theme, setTheme] = useState<"light" | "dark">("light");350 const [toast, setToast] = useState("");351 return (352 <div className="fd-set-demo" data-fd-theme={theme}>353 <style>{demoCss}</style>354 <header className="fd-set-demo-bar">355 <span className="fd-set-demo-brand"><i aria-hidden="true" />Voltra Home</span>356 <nav aria-label="Primary"><a href="#home">Home</a><a href="#energy">Energy</a><a href="#devices">Devices</a><a href="#settings" aria-current="page">Settings</a></nav>357 <button type="button" className="fd-set-demo-theme" aria-label={theme === "dark" ? "Switch to light theme" : "Switch to dark theme"} onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>{theme === "dark" ? "Light" : "Dark"}</button>358 </header>359 <main className="fd-set-demo-main">360 <div className="fd-set-demo-head"><h1>Heating & energy</h1><p>Maple Street · 4 rooms · 3 devices online</p></div>361 <SettingsForm362 theme={theme}363 onSave={() => setToast("Settings synced to 3 devices")}364 aside={(v) => {365 const e = estimate(v);366 return (367 <div className="fd-set-demo-sum">368 <p>Estimated this month</p>369 <b>€{e.eur.toFixed(2)}</b>370 <div className="fd-set-demo-bars" aria-hidden="true"><i style={{ width: `${Math.min(100, (e.eur / 120) * 100)}%` }} /></div>371 <dl>372 <div><dt>CO₂</dt><dd>{Math.round(e.co2)} kg</dd></div>373 <div><dt>vs. last month</dt><dd data-up={e.eur > 74 || undefined}>{e.eur > 74 ? "+" : "−"}€{Math.abs(74 - e.eur).toFixed(2)}</dd></div>374 </dl>375 {toast && <small role="status">{toast}</small>}376 </div>377 );378 }}379 />380 </main>381 </div>382 );383}384 385const demoCss = `386:where(.fd-set-demo,.fd-set-demo *){box-sizing:border-box;margin:0;padding:0;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none}387:where(.fd-set-demo,.fd-set-demo *)::before,:where(.fd-set-demo,.fd-set-demo *)::after{box-sizing:border-box}388:where(.fd-set-demo button){appearance:none;-webkit-appearance:none;background:none;border:0;border-radius:0}389.fd-set-demo{--d-font:ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;--d-disp:ui-sans-serif,-apple-system,"SF Pro Display","Inter",sans-serif;390 --fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-dur-2:240ms;391 --d-bg:#F3F5F4;--d-ink:#0B0B0F;--d-mut:#6B6B76;--d-line:rgba(11,11,15,.08);--d-green:#0E9F6E;--d-card:#fff;392 min-height:100vh;background:var(--d-bg);color:var(--d-ink);font:400 15px/1.45 var(--d-font);-webkit-font-smoothing:antialiased;transition:background-color var(--fd-dur-2) var(--fd-ease-out)}393.fd-set-demo[data-fd-theme="dark"]{--d-bg:#08090A;--d-ink:#F5F5F7;--d-mut:#9A9AA6;--d-line:rgba(255,255,255,.09);--d-green:#34D399;--d-card:#111115}394.fd-set-demo-bar{display:flex;align-items:center;gap:32px;height:60px;padding:0 clamp(16px,4vw,40px)}395.fd-set-demo-brand{display:flex;align-items:center;gap:10px;font:700 17px/1 var(--d-disp);letter-spacing:-.03em;margin-right:auto}396.fd-set-demo-brand i{width:20px;height:20px;border-radius:50%;background:var(--d-green);position:relative}.fd-set-demo-brand i::after{content:"";position:absolute;left:6px;top:4px;width:8px;height:12px;background:var(--d-bg);clip-path:polygon(60% 0,0 58%,45% 58%,35% 100%,100% 38%,55% 38%)}397.fd-set-demo-bar nav{display:flex;gap:24px;font:500 14px/1 var(--d-font);color:var(--d-mut)}.fd-set-demo-bar nav a{padding:8px 0}.fd-set-demo-bar nav a:hover,.fd-set-demo-bar nav a[aria-current]{color:var(--d-ink)}398.fd-set-demo-bar nav a:focus-visible,.fd-set-demo-theme:focus-visible{outline:2px solid rgba(10,132,255,.7);outline-offset:2px;border-radius:6px}399.fd-set-demo-theme{cursor:pointer;height:32px;padding:0 14px;border-radius:9999px;box-shadow:0 0 0 1px var(--d-line);font:500 13px/1 var(--d-font);color:var(--d-mut)}.fd-set-demo-theme:hover{color:var(--d-ink)}400.fd-set-demo-main{max-width:1040px;margin:0 auto;padding:32px clamp(16px,4vw,40px) 40px}401.fd-set-demo-head{margin-bottom:24px}.fd-set-demo-head h1{font:600 28px/1.15 var(--d-disp);letter-spacing:-.025em}.fd-set-demo-head p{margin-top:6px;font:400 14px/1.4 var(--d-font);color:var(--d-mut)}402.fd-set-demo-sum{display:flex;flex-direction:column;gap:12px;padding:24px;border-radius:24px;background:var(--d-card);box-shadow:0 0 0 1px var(--d-line),0 16px 32px -24px rgba(0,0,0,.2)}403.fd-set-demo-sum p{font:500 13px/1 var(--d-font);color:var(--d-mut)}404.fd-set-demo-sum b{font:600 40px/1 var(--d-disp);letter-spacing:-.03em;font-variant-numeric:tabular-nums}405.fd-set-demo-bars{height:6px;border-radius:9999px;background:var(--d-line);overflow:hidden}.fd-set-demo-bars i{display:block;height:100%;border-radius:inherit;background:var(--d-green);transition:width 420ms var(--fd-ease-out)}406.fd-set-demo-sum dl{display:flex;flex-direction:column;gap:10px;margin-top:4px;padding-top:14px;border-top:1px solid var(--d-line);font:400 14px/1.2 var(--d-font)}407.fd-set-demo-sum dl div{display:flex;justify-content:space-between}.fd-set-demo-sum dt{color:var(--d-mut)}.fd-set-demo-sum dd{font-variant-numeric:tabular-nums;font-weight:500;color:var(--d-green)}.fd-set-demo-sum dd[data-up]{color:#E5484D}408.fd-set-demo-sum small{font:500 12px/1.3 var(--d-font);color:var(--d-green)}409@media (max-width:720px){.fd-set-demo-bar nav{display:none}}410`;A floating-label input with helper, error and success states, adornments, counter, clear and password reveal.
Six-digit code entry with auto-advance, paste and SMS autofill, error shake and a success cascade.
Card entry with brand detection, live formatting, Luhn validation and a preview that flips to the CVC.