Input Field
A floating-label input with helper, error and success states, adornments, counter, clear and password reveal.
Single-line email capture with inline validation, a loading button and a success morph with undo.
Browser support · All evergreen browsers. Uses the inert attribute (Chrome 102+, Safari 15.5+, Firefox 112+).
npx shadcn@latest add https://freedesignmd.com/r/newsletter-inline.json1/**2 * NewsletterInline — a single-line email capture with inline validation,3 * a loading state inside the button and a success morph (the field turns into4 * a confirmation with a drawn check, with an undo link).5 *6 * Props: onSubmit(email) (return a Promise; throw Error("message") to show a7 * server error such as "already subscribed"), placeholder, cta, successTitle,8 * successBody, size ("md" | "lg"), theme.9 *10 * Browser support: all evergreen browsers.11 * Accessibility: <form> with a labelled email input (visually hidden label),12 * aria-invalid + aria-describedby, errors in role="alert", the success message13 * is a polite live region and receives focus management through the "Use a14 * different email" button. Validation runs on blur (if non-empty) and submit;15 * once an error is shown it clears as soon as the value becomes valid.16 * Reduced motion: no shake, the morph is an opacity fade.17 */18import { useEffect, useId, useRef, useState } from "react";19 20export interface NewsletterInlineProps {21 onSubmit?: (email: string) => void | Promise<unknown>;22 placeholder?: string;23 cta?: string;24 successTitle?: string;25 successBody?: (email: string) => string;26 size?: "md" | "lg";27 theme?: "light" | "dark";28 className?: string;29}30 31const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;32 33export function NewsletterInline({34 onSubmit,35 placeholder = "Your email address",36 cta = "Subscribe",37 successTitle = "You’re on the list",38 successBody = (e) => `We sent a confirmation link to ${e}.`,39 size = "lg",40 theme,41 className,42}: NewsletterInlineProps) {43 const uid = useId();44 const [email, setEmail] = useState("");45 const [error, setError] = useState<string | undefined>();46 const [phase, setPhase] = useState<"idle" | "loading" | "done">("idle");47 const [shake, setShake] = useState(0);48 const input = useRef<HTMLInputElement>(null);49 const alive = useRef(true);50 useEffect(() => () => void (alive.current = false), []);51 52 const check = (v: string) => (!v.trim() ? "Add your email to subscribe" : EMAIL.test(v.trim()) ? undefined : "That doesn’t look like an email. Try name@domain.com");53 54 const submit = async (e: React.FormEvent) => {55 e.preventDefault();56 if (phase !== "idle") return;57 const err = check(email);58 if (err) {59 setError(err);60 setShake((s) => s + 1);61 input.current?.focus();62 return;63 }64 setError(undefined);65 setPhase("loading");66 try {67 await onSubmit?.(email.trim());68 await new Promise((r) => window.setTimeout(r, 900));69 if (alive.current) setPhase("done");70 } catch (ex) {71 if (!alive.current) return;72 setPhase("idle");73 setError(ex instanceof Error ? ex.message : "Something went wrong. Please try again.");74 setShake((s) => s + 1);75 input.current?.focus();76 }77 };78 79 return (80 <div className={`fd-nl${className ? " " + className : ""}`} data-size={size} data-phase={phase} data-bad={error ? "" : undefined} data-fd-theme={theme}>81 <style>{css}</style>82 <div key={shake} className="fd-nl-box" data-shake={shake && error ? "" : undefined}>83 <form className="fd-nl-form" onSubmit={submit} noValidate aria-hidden={phase === "done"} inert={phase === "done"}>84 <label htmlFor={`${uid}-e`} className="fd-nl-sr">Email address</label>85 <svg className="fd-nl-mail" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="3" /><path d="M3.5 7.5l8.5 6 8.5-6" /></svg>86 <input87 ref={input}88 id={`${uid}-e`}89 type="email"90 inputMode="email"91 autoComplete="email"92 placeholder={placeholder}93 value={email}94 disabled={phase === "loading"}95 aria-invalid={!!error}96 aria-describedby={`${uid}-m`}97 onChange={(e) => {98 setEmail(e.target.value);99 if (error && !check(e.target.value)) setError(undefined);100 }}101 onBlur={() => email && setError(check(email))}102 />103 <button type="submit" className="fd-nl-btn" aria-busy={phase === "loading"}>104 <span>{cta}</span>105 <svg className="fd-nl-spin" width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity=".3" strokeWidth="2.5" /><path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" /></svg>106 </button>107 </form>108 109 <div className="fd-nl-done" role="status" aria-live="polite">110 {phase === "done" && (111 <>112 <span className="fd-nl-badge" aria-hidden="true"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.8" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg></span>113 <span className="fd-nl-dtxt"><b>{successTitle}</b><span>{successBody(email.trim())}</span></span>114 <button type="button" className="fd-nl-undo" onClick={() => { setPhase("idle"); setEmail(""); requestAnimationFrame(() => input.current?.focus()); }}>Use a different email</button>115 </>116 )}117 </div>118 </div>119 <p id={`${uid}-m`} className="fd-nl-msg" role={error ? "alert" : undefined} data-err={error ? "" : undefined}>{error}</p>120 </div>121 );122}123 124const css = `125:where(.fd-nl,.fd-nl *){box-sizing:border-box;margin:0;padding:0;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none}126:where(.fd-nl,.fd-nl *)::before,:where(.fd-nl,.fd-nl *)::after{box-sizing:border-box}127:where(.fd-nl svg){display:block;flex-shrink:0}128:where(.fd-nl button,.fd-nl input){appearance:none;-webkit-appearance:none;background:none;border:0;border-radius:0;outline:0}129.fd-nl{130 --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;131 --n-bg:#fff;--n-fg:#1A1410;--n-mut:#7A6E64;--n-line:rgba(26,20,16,.16);--n-accent:#1A1410;--n-on:#fff;--n-err:#C2271B;--n-ok:#1F7A45;--n-okbg:#E8F4EC;--n-ring:rgba(194,98,28,.55);132 width:100%;max-width:520px;133 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(--n-fg);134}135[data-fd-theme="dark"] .fd-nl,.fd-nl[data-fd-theme="dark"]{--n-bg:#241C16;--n-fg:#F6EEE6;--n-mut:#A89A8C;--n-line:rgba(246,238,230,.16);--n-accent:#F6EEE6;--n-on:#1A1410;--n-err:#FF8A7A;--n-ok:#7BE0A4;--n-okbg:#16301F;--n-ring:rgba(255,150,70,.6)}136.fd-nl[data-size="md"]{--n-h:48px}.fd-nl[data-size="lg"]{--n-h:60px}137.fd-nl-box{position:relative;display:grid;height:var(--n-h);border-radius:9999px;background:var(--n-bg);box-shadow:0 0 0 1px var(--n-line),0 1px 2px rgba(26,20,16,.05),0 12px 24px -16px rgba(26,20,16,.25);138 transition:box-shadow var(--fd-dur-2) var(--fd-ease-out),background-color var(--fd-dur-3) var(--fd-ease-out)}139.fd-nl-box:focus-within{box-shadow:0 0 0 1.5px var(--n-accent),0 0 0 5px var(--n-ring)}140.fd-nl[data-bad] .fd-nl-box{box-shadow:0 0 0 1.5px var(--n-err)}141.fd-nl[data-bad] .fd-nl-box:focus-within{box-shadow:0 0 0 1.5px var(--n-err),0 0 0 5px color-mix(in srgb,var(--n-err) 22%,transparent)}142.fd-nl[data-phase="done"] .fd-nl-box{background:var(--n-okbg);box-shadow:0 0 0 1px color-mix(in srgb,var(--n-ok) 40%,transparent)}143.fd-nl-box[data-shake]{animation:fd-nl-shake 440ms cubic-bezier(.36,.07,.19,.97)}144.fd-nl-form,.fd-nl-done{grid-area:1/1;display:flex;align-items:center;min-width:0;transition:opacity var(--fd-dur-3) var(--fd-ease-in-out),transform var(--fd-dur-3) var(--fd-ease-out)}145.fd-nl-form{gap:0;padding:0 calc((var(--n-h) - 44px) / 2) 0 22px}146.fd-nl[data-size="md"] .fd-nl-form{padding-right:4px}147.fd-nl[data-phase="done"] .fd-nl-form{opacity:0;transform:translateY(-10px) scale(.98);pointer-events:none}148.fd-nl-mail{color:var(--n-mut);margin-right:12px}149.fd-nl-form input{flex:1;min-width:0;height:100%;color:var(--n-fg);font-family:inherit;font-size:16px;line-height:1.2;letter-spacing:inherit;caret-color:var(--n-fg);text-overflow:ellipsis}150.fd-nl-form input::placeholder{color:var(--n-mut);opacity:.85}151.fd-nl-form input:disabled{opacity:.6}152.fd-nl-btn{position:relative;display:grid;place-items:center;flex:none;height:calc(var(--n-h) - 16px);padding:0 22px;border-radius:9999px;cursor:pointer;overflow:hidden;153 background:var(--n-accent);color:var(--n-on);font-family:inherit;font-size:15px;font-weight:600;line-height:1;letter-spacing:-.005em;154 transform:scale(1);transition:transform var(--fd-dur-3) var(--fd-spring),filter var(--fd-dur-1)}155.fd-nl[data-size="lg"] .fd-nl-btn{min-width:116px}156@media (hover:hover){.fd-nl-btn:hover{filter:brightness(1.18)}[data-fd-theme="dark"] .fd-nl-btn:hover,.fd-nl[data-fd-theme="dark"] .fd-nl-btn:hover{filter:brightness(.92)}}157.fd-nl-btn:active{transform:scale(.95);transition:transform var(--fd-dur-1) var(--fd-ease-out)}158.fd-nl-btn:focus-visible{outline:2px solid var(--n-fg);outline-offset:2px}159.fd-nl-btn>*{grid-area:1/1;transition:opacity var(--fd-dur-2) var(--fd-ease-out),transform var(--fd-dur-3) var(--fd-ease-out)}160.fd-nl-spin{opacity:0;transform:scale(.6);animation:fd-nl-rot .8s linear infinite}161.fd-nl[data-phase="loading"] .fd-nl-btn>span{opacity:0;transform:translateY(-10px)}162.fd-nl[data-phase="loading"] .fd-nl-spin{opacity:1;transform:none}163.fd-nl-done{gap:14px;padding:0 20px 0 12px;opacity:0;transform:translateY(10px) scale(.98);pointer-events:none}164.fd-nl[data-phase="done"] .fd-nl-done{opacity:1;transform:none;pointer-events:auto;transition-delay:80ms}165.fd-nl-badge{display:grid;place-items:center;flex:none;width:36px;height:36px;border-radius:50%;background:var(--n-ok);color:var(--n-okbg);animation:fd-nl-pop 560ms var(--fd-spring)}166.fd-nl-badge path{stroke-dasharray:24;stroke-dashoffset:24;animation:fd-nl-draw 420ms var(--fd-ease-out) 160ms forwards}167.fd-nl-dtxt{display:flex;flex-direction:column;gap:2px;flex:1;min-width:0;font-size:13px;line-height:1.25;color:var(--n-mut)}168.fd-nl-dtxt b{font-size:15px;font-weight:600;color:var(--n-fg)}169.fd-nl-dtxt span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}170.fd-nl-undo{flex:none;cursor:pointer;font-family:inherit;font-size:13px;font-weight:500;line-height:1;color:var(--n-ok);padding:8px 6px;border-radius:8px;text-decoration:underline;text-underline-offset:3px}171.fd-nl-undo:focus-visible{outline:2px solid var(--n-ok);outline-offset:1px}172.fd-nl-msg{min-height:0;max-height:0;overflow:hidden;padding:0 22px;font-size:13px;line-height:1.35;color:var(--n-err);opacity:0;transform:translateY(-4px);transition:max-height var(--fd-dur-3) var(--fd-ease-out),opacity var(--fd-dur-2),transform var(--fd-dur-2) var(--fd-ease-out),padding var(--fd-dur-2)}173.fd-nl-msg[data-err]{max-height:48px;opacity:1;transform:none;padding-top:10px}174.fd-nl-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}175@keyframes fd-nl-rot{to{transform:rotate(360deg)}}176@keyframes fd-nl-draw{to{stroke-dashoffset:0}}177@keyframes fd-nl-pop{from{transform:scale(.4);opacity:0}}178@keyframes fd-nl-shake{10%,90%{transform:translateX(-2px)}20%,80%{transform:translateX(4px)}30%,50%,70%{transform:translateX(-6px)}40%,60%{transform:translateX(6px)}}179@media (max-width:480px){.fd-nl-mail{display:none}.fd-nl-form{padding-left:18px}.fd-nl-undo{display:none}.fd-nl[data-size="lg"] .fd-nl-btn{min-width:0;padding:0 18px}.fd-nl-done{padding-right:12px}}180@media (prefers-reduced-motion:reduce){.fd-nl-box[data-shake]{animation:none}.fd-nl-form,.fd-nl-done{transition:opacity 100ms linear;transform:none!important}.fd-nl-badge{animation:none}.fd-nl-badge path{animation:none;stroke-dashoffset:0}.fd-nl-spin{animation:none}.fd-nl-btn,.fd-nl-btn:active{transition:none;transform:none}}181@media (prefers-contrast:more){.fd-nl-box{box-shadow:0 0 0 1px currentColor}}182`;183 184/* ───────────────────────────── demo ───────────────────────────── */185 186export function Demo() {187 const [theme, setTheme] = useState<"light" | "dark">("light");188 return (189 <div className="fd-nl-demo" data-fd-theme={theme}>190 <style>{demoCss}</style>191 <header className="fd-nl-demo-bar">192 <span className="fd-nl-demo-brand">Marlow<i>&</i>Finch</span>193 <nav aria-label="Primary"><a href="#shop">Shop</a><a href="#subscriptions">Subscriptions</a><a href="#brew">Brew guides</a><a href="#roastery">Roastery</a></nav>194 <button type="button" className="fd-nl-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>195 </header>196 197 <main className="fd-nl-demo-main">198 <section className="fd-nl-demo-copy" aria-labelledby="fd-nl-h">199 <p className="fd-nl-demo-eyebrow">The Roast Letter · every other Thursday</p>200 <h1 id="fd-nl-h">First pick of every small-lot roast.</h1>201 <p className="fd-nl-demo-sub">Tasting notes, brew recipes and early access to limited harvests from Huila, Yirgacheffe and Kerinci — straight from our roastery in Portland.</p>202 <NewsletterInline203 theme={theme}204 cta="Join free"205 placeholder="you@email.com"206 onSubmit={(email) => new Promise<void>((res, rej) => window.setTimeout(() => (email.toLowerCase() === "taken@example.com" ? rej(new Error("That address is already subscribed. Check your inbox for the latest letter.")) : res()), 700))}207 successTitle="Welcome to the Roast Letter"208 successBody={() => "Confirm in your inbox to claim 10% off."}209 />210 <ul className="fd-nl-demo-perks">211 <li><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg>10% off your first bag</li>212 <li><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg>Early access to micro-lots</li>213 <li><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5" /></svg>Unsubscribe in one click</li>214 </ul>215 <p className="fd-nl-demo-fine">18,400 readers · No spam. Read our <a href="#privacy">privacy policy</a>.</p>216 </section>217 218 <div className="fd-nl-demo-art" aria-hidden="true">219 <svg viewBox="0 0 360 440" width="100%" height="100%">220 <ellipse cx="180" cy="410" rx="130" ry="14" fill="rgba(26,20,16,.16)" />221 <path d="M82 96 Q180 70 278 96 L296 400 Q180 424 64 400 Z" fill="#C9A06B" />222 <path d="M82 96 Q180 70 278 96 L278 120 Q180 96 82 120 Z" fill="#B58B57" />223 <path d="M82 96 Q180 70 278 96" fill="none" stroke="#A67B48" strokeWidth="3" />224 <rect x="106" y="190" width="148" height="164" rx="10" fill="#FBF4E8" />225 <circle cx="180" cy="236" r="26" fill="#1A1410" />226 <ellipse cx="180" cy="236" rx="9" ry="22" fill="#FBF4E8" transform="rotate(24 180 236)" />227 <rect x="126" y="282" width="108" height="9" rx="4" fill="#1A1410" />228 <rect x="140" y="302" width="80" height="6" rx="3" fill="#1A1410" opacity=".5" />229 <rect x="148" y="318" width="64" height="6" rx="3" fill="#1A1410" opacity=".3" />230 <path d="M82 96 L74 70 Q180 54 286 70 L278 96" fill="#9C713F" />231 </svg>232 <span className="fd-nl-demo-tag">Huila Pink Bourbon<small>Notes of plum, jasmine, brown sugar</small></span>233 </div>234 </main>235 </div>236 );237}238 239const demoCss = `240:where(.fd-nl-demo,.fd-nl-demo *){box-sizing:border-box;margin:0;padding:0;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none}241:where(.fd-nl-demo,.fd-nl-demo *)::before,:where(.fd-nl-demo,.fd-nl-demo *)::after{box-sizing:border-box}242:where(.fd-nl-demo svg){display:block;flex-shrink:0}243:where(.fd-nl-demo button){appearance:none;-webkit-appearance:none;background:none;border:0;border-radius:0}244.fd-nl-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;245 --fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-dur-2:240ms;246 --d-bg:#F4EBDD;--d-ink:#1A1410;--d-mut:#6F6358;--d-line:rgba(26,20,16,.12);--d-art:#E8D6BA;--d-accent:#B8541C;247 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)}248.fd-nl-demo[data-fd-theme="dark"]{--d-bg:#17110D;--d-ink:#F6EEE6;--d-mut:#A89A8C;--d-line:rgba(246,238,230,.12);--d-art:#2A1F17;--d-accent:#FF9A55}249.fd-nl-demo-bar{display:flex;align-items:center;gap:32px;height:68px;padding:0 clamp(16px,4vw,48px)}250.fd-nl-demo-brand{margin-right:auto;font:700 20px/1 var(--d-disp);letter-spacing:-.04em}.fd-nl-demo-brand i{font-style:normal;color:var(--d-accent);margin:0 2px}251.fd-nl-demo-bar nav{display:flex;gap:28px;font:500 14px/1 var(--d-font);color:var(--d-mut)}.fd-nl-demo-bar nav a{padding:8px 0}.fd-nl-demo-bar nav a:hover{color:var(--d-ink)}252.fd-nl-demo-bar a:focus-visible,.fd-nl-demo-theme:focus-visible,.fd-nl-demo-fine a:focus-visible{outline:2px solid var(--d-accent);outline-offset:2px;border-radius:4px}253.fd-nl-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-nl-demo-theme:hover{color:var(--d-ink)}254.fd-nl-demo-main{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,.9fr);gap:clamp(24px,5vw,72px);align-items:center;max-width:1160px;margin:0 auto;padding:clamp(24px,6vh,72px) clamp(16px,4vw,48px) 64px}255.fd-nl-demo-eyebrow{font:600 12px/1 var(--d-font);letter-spacing:.08em;text-transform:uppercase;color:var(--d-accent)}256.fd-nl-demo-copy h1{margin:20px 0 0;font:600 clamp(36px,5.2vw,64px)/1.02 var(--d-disp);letter-spacing:-.04em;max-width:11em}257.fd-nl-demo-sub{margin:20px 0 32px;max-width:30em;font:400 17px/1.55 var(--d-font);letter-spacing:-.01em;color:var(--d-mut)}258.fd-nl-demo-perks{display:flex;flex-wrap:wrap;gap:8px 20px;margin-top:24px;font:500 14px/1 var(--d-font)}259.fd-nl-demo-perks li{display:flex;align-items:center;gap:8px}.fd-nl-demo-perks svg{color:var(--d-accent)}260.fd-nl-demo-fine{margin-top:20px;font:400 13px/1.4 var(--d-font);color:var(--d-mut)}.fd-nl-demo-fine a{text-decoration:underline;text-underline-offset:3px}261.fd-nl-demo-art{position:relative;aspect-ratio:1/1.15;max-height:560px;border-radius:32px;background:var(--d-art);padding:8% 12% 4%;box-shadow:0 0 0 1px var(--d-line)}262.fd-nl-demo-tag{position:absolute;left:20px;bottom:20px;display:flex;flex-direction:column;gap:4px;padding:12px 16px;border-radius:14px;background:var(--d-bg);box-shadow:0 0 0 1px var(--d-line),0 8px 24px -12px rgba(0,0,0,.3);font:600 14px/1.2 var(--d-font)}263.fd-nl-demo-tag small{font:400 12px/1.3 var(--d-font);color:var(--d-mut)}264@media (max-width:860px){.fd-nl-demo-main{grid-template-columns:1fr}.fd-nl-demo-bar nav{display:none}.fd-nl-demo-art{max-height:380px;aspect-ratio:auto;height:340px;padding:6% 24% 3%}}265`;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.