Form
Onboarding Form
A contact form asked one question at a time — name on a curved input, email, project type, details — then a review, and a success screen that draws its own tick.
Step 1 of 5
What's your name?
Four questions, one at a time, then a review — nothing is actually sent here
import OnboardingForm from "@/components/ui/onboarding-form";
export default function OnboardingFormDemo() {
return (
<div className="w-full min-w-0 space-y-3">
<div className="w-full rounded-card border border-line bg-[#f8f4f1] p-4 md:p-10">
<div className="mx-auto max-w-xl rounded-2xl border border-black/10 bg-black/[0.035] p-8 shadow-[0_8px_30px_rgba(0,0,0,0.05)] md:p-12">
<OnboardingForm />
</div>
</div>
<p className="text-center text-xs text-faint">
Four questions, one at a time, then a review — nothing is actually sent here
</p>
</div>
);
}Installation
1. Install the dependencies.
terminal npm install gsap2. Add the
cnhelper, if you don't already have it.lib/utils.ts import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into your project.
components/ui/onboarding-form.tsx 'use client' import { useEffect, useRef, useState } from 'react' import type { KeyboardEvent, ReactNode } from 'react' import gsap from 'gsap' import CurvedInput from '@/components/ui/curved-input' const PROJECT_TYPES = ['Web Design', 'Web Development', 'SaaS Platform', 'Ecommerce', 'Not Sure Yet'] as const type ProjectType = (typeof PROJECT_TYPES)[number] export interface FormState { name: string email: string projectType: ProjectType | null details: string } const EMPTY_FORM: FormState = { name: '', email: '', projectType: null, details: '' } const STEP_COUNT = 4 // name, email, project type, details — review/submit is the screen after step 4 const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ function StepPanel({ children, panelRef }: { children: ReactNode; panelRef: (el: HTMLDivElement | null) => void }) { return ( <div ref={panelRef} className="flex flex-col gap-6"> {children} </div> ) } function FieldError({ message }: { message: string | null }) { if (!message) return null return <p className="font-sans text-xs text-red-600">{message}</p> } interface OnboardingFormProps { /** Sends the finished form. Resolve to show the success screen; without it, the round trip is simulated so the flow can be seen end to end. */ onSubmit?: (form: FormState) => Promise<void> | void } export default function OnboardingForm({ onSubmit }: OnboardingFormProps) { const [step, setStep] = useState(0) const [form, setForm] = useState<FormState>(EMPTY_FORM) const [error, setError] = useState<string | null>(null) const [submitting, setSubmitting] = useState(false) const [submitted, setSubmitted] = useState(false) const panelRef = useRef<HTMLDivElement | null>(null) const checkPathRef = useRef<SVGPathElement>(null) const animatePanel = () => { const panel = panelRef.current if (!panel) return gsap.fromTo(panel, { opacity: 0, x: 24 }, { opacity: 1, x: 0, duration: 0.45, ease: 'power3.out' }) } useEffect(() => { animatePanel() // eslint-disable-next-line react-hooks/exhaustive-deps }, [step]) useEffect(() => { if (!submitted) return const path = checkPathRef.current if (!path) return const total = path.getTotalLength() path.style.strokeDasharray = `${total}` path.style.strokeDashoffset = `${total}` gsap.to(path, { strokeDashoffset: 0, duration: 0.6, ease: 'power2.out', delay: 0.15 }) }, [submitted]) const validate = (): string | null => { if (step === 0) { if (form.name.trim().length < 2) return 'Enter your name (at least 2 characters).' } if (step === 1) { if (!EMAIL_RE.test(form.email.trim())) return 'Enter a valid email address.' } if (step === 2) { if (!form.projectType) return 'Pick the option closest to your project.' } if (step === 3) { if (form.details.trim().length < 10) return 'Give us a few more details (10+ characters).' } return null } const goNext = () => { const validationError = validate() if (validationError) { setError(validationError) return } setError(null) setStep((s) => Math.min(STEP_COUNT, s + 1)) } const goBack = () => { setError(null) setStep((s) => Math.max(0, s - 1)) } const handleSubmit = async () => { setSubmitting(true) try { if (onSubmit) await onSubmit(form) // No endpoint given: simulate the round trip so the success screen and // its animation still play. else await new Promise((resolve) => window.setTimeout(resolve, 900)) setSubmitted(true) } catch { setError('That did not send. Try again in a moment.') } finally { setSubmitting(false) } } const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter' && step < 3) { e.preventDefault() goNext() } } if (submitted) { return ( <div className="flex flex-col items-center gap-6 py-12 text-center"> <span className="flex h-20 w-20 items-center justify-center rounded-full border-2 border-[#0f1113]/70"> <svg viewBox="0 0 24 24" className="h-9 w-9" fill="none"> <path ref={checkPathRef} d="M5 12.5L10 17.5L19 7" stroke="#0f1113" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" /> </svg> </span> <h3 className="font-kine-display text-3xl uppercase tracking-tight text-black/90 md:text-4xl"> Message Sent </h3> <p className="max-w-sm font-kine-body text-sm text-black/60 md:text-base"> Thanks, {form.name.split(' ')[0]} — we'll reply to {form.email} within one business day. </p> <div className="mt-2 flex flex-wrap items-center justify-center gap-4"> <a href="/" className="rounded-full border-2 border-black/15 px-6 py-3 font-kine-body text-sm font-bold uppercase tracking-widest text-black/70 transition-colors duration-300 hover:border-kine-primary hover:text-kine-primary" > Back to Home </a> <a href="/work" className="rounded-full border-2 border-[#0f1113]/70 px-6 py-3 font-kine-body text-sm font-bold uppercase tracking-widest text-[#0f1113] transition-colors duration-300 hover:bg-kine-primary hover:border-kine-primary hover:text-kine-bg" > View Our Work </a> </div> </div> ) } const progress = ((step + 1) / (STEP_COUNT + 1)) * 100 return ( <div className="flex flex-col gap-8"> {/* Progress */} <div className="flex flex-col gap-2"> <div className="h-1 w-full overflow-hidden rounded-full bg-black/10"> <div className="h-full rounded-full bg-kine-primary transition-[width] duration-500 ease-out" style={{ width: `${progress}%` }} /> </div> <span className="font-sans text-xs uppercase tracking-widest text-black/40"> Step {step + 1} of {STEP_COUNT + 1} </span> </div> {step === 0 && ( <StepPanel panelRef={(el) => (panelRef.current = el)}> <div className="flex flex-col gap-4"> <span className="font-kine-display text-2xl uppercase tracking-tight text-black/90 md:text-3xl"> What's your name? </span> <CurvedInput theme="light" type="text" value={form.name} onChange={(v) => setForm({ ...form, name: v })} onSubmit={goNext} placeholder="Jane Al-Sabah" buttonText="Next" ariaLabel="Your name" width="100%" fontSize={18} backgroundColor="#ffffff" textColor="#171717" placeholderColor="#a39c92" borderColor="#e2ddd4" buttonColor="#ceff00" buttonTextColor="#0f1113" shadowColor="#000000" shadowSize="sm" /> <FieldError message={error} /> </div> </StepPanel> )} {step === 1 && ( <StepPanel panelRef={(el) => (panelRef.current = el)}> <label className="flex flex-col gap-3"> <span className="font-kine-display text-2xl uppercase tracking-tight text-black/90 md:text-3xl"> What's your email? </span> <input autoFocus type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} onKeyDown={handleKeyDown} placeholder="jane@yourcompany.com" className="w-full border-b-2 border-black/15 bg-transparent py-3 font-kine-body text-lg text-black/90 placeholder:text-black/30 outline-none transition-colors duration-300 focus:border-[#0f1113] md:text-xl" /> <FieldError message={error} /> </label> </StepPanel> )} {step === 2 && ( <StepPanel panelRef={(el) => (panelRef.current = el)}> <span className="font-kine-display text-2xl uppercase tracking-tight text-black/90 md:text-3xl"> What kind of project? </span> <div className="flex flex-wrap gap-3"> {PROJECT_TYPES.map((type) => ( <button key={type} type="button" onClick={() => { setForm({ ...form, projectType: type }) setError(null) }} className={`rounded-full border-2 px-5 py-2.5 font-kine-body text-sm font-bold uppercase tracking-wider transition-colors duration-300 ${ form.projectType === type ? 'border-kine-primary bg-kine-primary text-kine-bg' : 'border-black/15 text-black/60 hover:border-kine-primary hover:text-kine-primary' }`} > {type} </button> ))} </div> <FieldError message={error} /> </StepPanel> )} {step === 3 && ( <StepPanel panelRef={(el) => (panelRef.current = el)}> <label className="flex flex-col gap-3"> <span className="font-kine-display text-2xl uppercase tracking-tight text-black/90 md:text-3xl"> Tell us more </span> <textarea autoFocus rows={4} value={form.details} onChange={(e) => setForm({ ...form, details: e.target.value })} placeholder="What are you trying to build, and by when?" className="w-full resize-none rounded-lg border-2 border-black/15 bg-transparent p-4 font-kine-body text-base text-black/90 placeholder:text-black/30 outline-none transition-colors duration-300 focus:border-[#0f1113]" /> <FieldError message={error} /> </label> </StepPanel> )} {step === 4 && ( <StepPanel panelRef={(el) => (panelRef.current = el)}> <span className="font-kine-display text-2xl uppercase tracking-tight text-black/90 md:text-3xl"> Review & send </span> <div className="flex flex-col gap-4 rounded-lg border border-black/10 p-5"> {[ { label: 'Name', value: form.name }, { label: 'Email', value: form.email }, { label: 'Project', value: form.projectType }, { label: 'Details', value: form.details }, ].map((row) => ( <div key={row.label} className="flex flex-col gap-1 border-b border-black/5 pb-3 last:border-0 last:pb-0"> <span className="font-sans text-xs uppercase tracking-widest text-black/40">{row.label}</span> <span className="font-kine-body text-sm text-black/80 md:text-base">{row.value}</span> </div> ))} </div> <FieldError message={error} /> </StepPanel> )} {/* Navigation — step 0 uses CurvedInput's own built-in button instead */} {step !== 0 && ( <div className="flex items-center justify-between"> <button type="button" onClick={goBack} disabled={step === 0} className="font-kine-body text-sm font-bold uppercase tracking-widest text-black/40 transition-colors duration-300 hover:text-black/70 disabled:pointer-events-none disabled:opacity-0" > Back </button> {step < STEP_COUNT ? ( <button type="button" onClick={goNext} className="group relative inline-flex items-center gap-2 overflow-hidden rounded-full border-2 border-[#0f1113]/70 px-7 py-3 font-kine-body text-sm font-bold uppercase tracking-widest text-[#0f1113] transition-colors duration-300 hover:border-kine-primary hover:text-kine-bg" > <span className="absolute inset-0 -translate-x-full bg-kine-primary transition-transform duration-300 ease-out group-hover:translate-x-0" /> <span className="relative">Next</span> </button> ) : ( <button type="button" onClick={handleSubmit} disabled={submitting} className="group relative inline-flex items-center gap-2 overflow-hidden rounded-full border-2 border-kine-primary bg-kine-primary px-7 py-3 font-kine-body text-sm font-bold uppercase tracking-widest text-kine-bg transition-opacity duration-300 hover:opacity-80 disabled:opacity-60" > {submitting ? 'Sending…' : 'Send'} </button> )} </div> )} </div> ) }components/ui/curved-input.tsx 'use client' import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode, type SyntheticEvent, } from 'react' import '@/styles/kinematics/curved-input.css' const DEG = 180 / Math.PI const round2 = (n: number) => Math.round(n * 100) / 100 const hexToRgba = (hex: string, alpha: number) => { let h = String(hex).replace('#', '') if (h.length === 3) h = h .split('') .map((c) => c + c) .join('') const n = parseInt(h.slice(0, 6), 16) if (Number.isNaN(n)) return hex return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})` } type ShadowSizeKey = 'sm' | 'md' | 'lg' const SHADOWS: Record<ShadowSizeKey, [number, number, number]> = { sm: [5, 12, 0.3], md: [10, 24, 0.4], lg: [16, 40, 0.52], } type ThemeName = 'dark' | 'light' interface ThemePalette { backgroundColor: string textColor: string placeholderColor: string borderColor: string buttonColor: string buttonTextColor: string shadowColor: string } const THEMES: Record<ThemeName, ThemePalette> = { dark: { backgroundColor: '#1B1722', textColor: '#f5f5f5', placeholderColor: '#a1a1aa', borderColor: '#392e4e', buttonColor: '#A855F7', buttonTextColor: '#ffffff', shadowColor: '#000000', }, light: { backgroundColor: '#ffffff', textColor: '#1d2050', placeholderColor: '#9aa0b6', borderColor: '#262a56', buttonColor: '#4763eb', buttonTextColor: '#ffffff', shadowColor: '#0b0e2a', }, } interface Geometry { straight: boolean W: number T: number svgH: number uPerLen: number point: (u: number, v: number) => [number, number] angleAt: (u: number) => number uFromPoint: (x: number, y: number) => number R?: number dir?: number } // Maps the flat coordinate space (u: 0..W along the bar, v: offset from the // centerline, positive down) onto a circular arc with the given sagitta // (`bend`, in px). Positive bend arches up, negative sags down, 0 is flat. const buildGeometry = (width: number, bend: number, thickness: number, pad: number): Geometry => { const W = width const T = thickness const s = Math.max(-W * 0.35, Math.min(bend, W * 0.35)) const a = Math.abs(s) const dir = s >= 0 ? 1 : -1 const svgH = T + a + pad * 2 if (a < 0.75) { const midY = pad + T / 2 return { straight: true, W, T, svgH, uPerLen: 1, point: (u, v) => [u, midY + v], angleAt: () => 0, uFromPoint: (x) => x, } } const R = (W * W * 0.25 + a * a) / (2 * a) const cx = W / 2 const apexY = pad + T / 2 + (dir > 0 ? 0 : a) const cy = apexY + dir * R const phi = Math.asin(Math.min(1, W / (2 * R))) return { straight: false, W, T, svgH, R, dir, uPerLen: W / (2 * R * phi), point: (u, v) => { const th = ((u - cx) / cx) * phi const rho = R - dir * v return [cx + rho * Math.sin(th), cy - dir * rho * Math.cos(th)] }, angleAt: (u) => dir * ((u - cx) / cx) * phi * DEG, uFromPoint: (x, y) => { const th = Math.atan2(x - cx, dir * (cy - y)) return cx + (th / phi) * cx }, } } const fmt = (g: Geometry, u: number, v: number) => { const [x, y] = g.point(u, v) return `${round2(x)} ${round2(y)}` } // Segment along a constant-v edge, as a circular arc (or a line when flat) const edgeSeg = (g: Geometry, uTo: number, v: number, ltr: boolean) => { if (g.straight) return `L ${fmt(g, uTo, v)}` const rho = round2(g.R! - g.dir! * v) const sweep = ltr === g.dir! > 0 ? 1 : 0 return `A ${rho} ${rho} 0 0 ${sweep} ${fmt(g, uTo, v)}` } // A rectangle bent along the arc: circular top/bottom edges, radial end caps // and quadratic rounded corners. const bentRectPath = (g: Geometry, u0: number, u1: number, vTop: number, vBot: number, radius: number) => { const rc = Math.max(0, Math.min(radius, (vBot - vTop) / 2, (u1 - u0) / 2)) return [ `M ${fmt(g, u0 + rc, vTop)}`, edgeSeg(g, u1 - rc, vTop, true), `Q ${fmt(g, u1, vTop)} ${fmt(g, u1, vTop + rc)}`, `L ${fmt(g, u1, vBot - rc)}`, `Q ${fmt(g, u1, vBot)} ${fmt(g, u1 - rc, vBot)}`, edgeSeg(g, u0 + rc, vBot, false), `Q ${fmt(g, u0, vBot)} ${fmt(g, u0, vBot - rc)}`, `L ${fmt(g, u0, vTop + rc)}`, `Q ${fmt(g, u0, vTop)} ${fmt(g, u0 + rc, vTop)}`, 'Z', ].join(' ') } const bentLinePath = (g: Geometry, u0: number, u1: number, v: number) => `M ${fmt(g, u0, v)} ${edgeSeg(g, u1, v, true)}` const SELECTABLE_TYPES = ['text', 'search', 'tel', 'url', 'password'] interface Layout { btnInset: number chipH: number chipW: number iconU: number textStartU: number textEndU: number btnU0: number btnU1: number winLen: number } export interface CurvedInputProps { value?: string defaultValue?: string onChange?: (value: string) => void onSubmit?: (value: string) => void placeholder?: string buttonText?: string type?: string name?: string ariaLabel?: string theme?: ThemeName width?: number | string bend?: number height?: number cornerRadius?: number borderWidth?: number fontSize?: number backgroundColor?: string textColor?: string placeholderColor?: string borderColor?: string buttonColor?: string buttonTextColor?: string iconColor?: string shadowSize?: ShadowSizeKey shadowColor?: string showButton?: boolean showIcon?: boolean icon?: ReactNode className?: string style?: CSSProperties } const CurvedInput = ({ value, defaultValue = '', onChange, onSubmit, placeholder = 'Enter your email', buttonText = 'Get Started', type = 'email', name, ariaLabel, theme = 'dark', width = 450, bend = 28, height = 64, cornerRadius = 18, borderWidth = 1.5, fontSize = 16, backgroundColor, textColor, placeholderColor, borderColor, buttonColor, buttonTextColor, iconColor, shadowSize = 'md', shadowColor, showButton = true, showIcon = true, icon, className = '', style, }: CurvedInputProps) => { const uid = useId().replace(/[^\w-]/g, '') const layoutPathId = `ci-text-${uid}` const buttonPathId = `ci-btn-${uid}` const clipId = `ci-clip-${uid}` const rootRef = useRef<HTMLFormElement>(null) const svgRef = useRef<SVGSVGElement>(null) const inputRef = useRef<HTMLInputElement>(null) const textRef = useRef<SVGTextElement>(null) const btnMeasureRef = useRef<SVGTextElement>(null) const scrollRef = useRef(0) const [w, setW] = useState(0) const [innerValue, setInnerValue] = useState(defaultValue) const [caretIndex, setCaretIndex] = useState(defaultValue.length) const [focused, setFocused] = useState(false) const [caretU, setCaretU] = useState(0) const [scrollLen, setScrollLen] = useState(0) const [btnTextW, setBtnTextW] = useState(0) const [, setFontTick] = useState(0) const val = value !== undefined ? value : innerValue const display = type === 'password' ? '•'.repeat(val.length) : val const palette = THEMES[theme] || THEMES.dark const bgColor = backgroundColor ?? palette.backgroundColor const fgColor = textColor ?? palette.textColor const phColor = placeholderColor ?? palette.placeholderColor const strokeColor = borderColor ?? palette.borderColor const accentColor = buttonColor ?? palette.buttonColor const btnFgColor = buttonTextColor ?? palette.buttonTextColor const shColor = shadowColor ?? palette.shadowColor useEffect(() => { const el = rootRef.current if (!el) return const ro = new ResizeObserver((entries) => { const cw = entries[0]?.contentRect?.width ?? el.clientWidth setW(Math.round(cw)) }) ro.observe(el) return () => ro.disconnect() }, []) // Re-measure once webfonts finish loading useEffect(() => { let alive = true if (document.fonts?.ready) { document.fonts.ready.then(() => { if (alive) setFontTick((t) => t + 1) }) } return () => { alive = false } }, []) const pad = Math.ceil(borderWidth / 2) + 6 const geom = useMemo(() => (w > 2 ? buildGeometry(w, bend, height, pad) : null), [w, bend, height, pad]) const layout = useMemo<Layout | null>(() => { if (!geom) return null const T = height const btnInset = Math.max(5, borderWidth + 4) const chipH = Math.min(34, Math.max(16, T * 0.34)) const chipW = chipH * 1.25 const iconU = 22 + chipW / 2 const textStartU = showIcon ? 22 + chipW + 13 : 24 const btnW = showButton ? Math.max(btnTextW + fontSize * 2.7, T * 1.35) : 0 const btnU1 = geom.W - btnInset const btnU0 = btnU1 - btnW const textEndU = Math.max(textStartU + 20, showButton ? btnU0 - 14 : geom.W - 24) const winLen = (textEndU - textStartU) / geom.uPerLen return { btnInset, chipH, chipW, iconU, textStartU, textEndU, btnU0, btnU1, winLen } }, [geom, height, borderWidth, btnTextW, fontSize, showIcon, showButton]) // Measure rendered text to keep the caret on the curve and scroll long // values along the arc, exactly like a native input would. useLayoutEffect(() => { if (btnMeasureRef.current) { const bw = btnMeasureRef.current.getComputedTextLength() setBtnTextW((prev) => (Math.abs(prev - bw) > 0.5 ? bw : prev)) } if (!geom || !layout) return const textEl = textRef.current const caret = Math.min(caretIndex, display.length) let caretLen = 0 let totalLen = 0 if (textEl && display.length) { try { totalLen = textEl.getSubStringLength(0, display.length) caretLen = caret > 0 ? textEl.getSubStringLength(0, caret) : 0 } catch { totalLen = 0 caretLen = 0 } } let next = scrollRef.current if (caretLen - next > layout.winLen - 2) next = caretLen - layout.winLen + 2 if (caretLen - next < 0) next = caretLen if (totalLen - next < layout.winLen) next = Math.max(0, totalLen - layout.winLen) next = Math.max(0, next) if (Math.abs(next - scrollRef.current) > 0.5) { scrollRef.current = next setScrollLen(next) } setCaretU(layout.textStartU + (caretLen - next) * geom.uPerLen) }) const commitValue = (v: string) => { if (value === undefined) setInnerValue(v) onChange?.(v) } const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { commitValue(e.target.value) setCaretIndex(e.target.selectionStart ?? e.target.value.length) } const handleSelect = (e: SyntheticEvent<HTMLInputElement>) => { const target = e.target as HTMLInputElement setCaretIndex(target.selectionStart ?? target.value.length) } const handleSubmit = (e?: FormEvent<HTMLFormElement>) => { if (e?.preventDefault) e.preventDefault() if (onSubmit) onSubmit(val) } // Click on the curve: focus the hidden input and drop the caret on the // character closest to the click, measured in arc length. const handleSurfaceClick = (e: ReactMouseEvent<SVGSVGElement>) => { const input = inputRef.current if (!input) return let idx = display.length const svg = svgRef.current const textEl = textRef.current if (svg && geom && layout && textEl && display.length) { try { const ctm = svg.getScreenCTM() const pt = new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm!.inverse()) const target = scrollRef.current + (geom.uFromPoint(pt.x, pt.y) - layout.textStartU) / geom.uPerLen let best = 0 let bestDist = Infinity for (let i = 0; i <= display.length; i++) { const li = i === 0 ? 0 : textEl.getSubStringLength(0, i) const d = Math.abs(li - target) if (d < bestDist) { bestDist = d best = i } } idx = best } catch { idx = display.length } } input.focus() try { input.setSelectionRange(idx, idx) } catch { /* selection API unavailable for this input type */ } setCaretIndex(idx) } const safeType = SELECTABLE_TYPES.includes(type) ? type : 'text' const inputMode = type === 'email' ? 'email' : type === 'number' ? 'decimal' : undefined const shadow = SHADOWS[shadowSize] const svgStyle: CSSProperties | undefined = shadow ? { filter: `drop-shadow(0 ${shadow[0]}px ${shadow[1]}px ${hexToRgba(shColor, shadow[2])})` } : undefined let content: ReactNode = null if (geom && layout) { const T = height const vBase = fontSize * 0.34 const scrollU = scrollLen * geom.uPerLen const bandPath = bentRectPath(geom, 0, geom.W, -T / 2, T / 2, cornerRadius) const layoutPath = bentLinePath(geom, layout.textStartU - scrollU, geom.W, vBase) const clipPath = bentRectPath(geom, layout.textStartU - 6, layout.textEndU + 8, -T / 2, T / 2, 0) const chipFill = iconColor || accentColor const { chipW, chipH } = layout const ew = chipW * 0.5 const eh = chipH * 0.5 const sw = Math.max(1.1, chipH * 0.075) const [ix, iy] = geom.point(layout.iconU, 0) const iconAngle = geom.angleAt(layout.iconU) const [caretX, caretY] = geom.point(caretU, 0) const caretAngle = geom.angleAt(caretU) const caretH = Math.min(T * 0.58, fontSize * 1.45) const btnH = T - layout.btnInset * 2 const buttonPath = showButton ? bentRectPath( geom, layout.btnU0, layout.btnU1, -T / 2 + layout.btnInset, T / 2 - layout.btnInset, Math.min(cornerRadius * 0.72, btnH / 2) ) : '' const buttonTextPath = showButton ? bentLinePath(geom, layout.btnU0, layout.btnU1, vBase) : '' content = ( <svg ref={svgRef} className="curved-input__svg" width={geom.W} height={round2(geom.svgH)} viewBox={`0 0 ${geom.W} ${round2(geom.svgH)}`} style={svgStyle} onPointerDown={(e) => e.preventDefault()} onClick={handleSurfaceClick} > <defs> <clipPath id={clipId}> <path d={clipPath} /> </clipPath> </defs> <path className="curved-input__ring" d={bandPath} fill="none" stroke={accentColor} strokeWidth={borderWidth + 6} /> <path d={bandPath} fill={bgColor} stroke={strokeColor} strokeWidth={borderWidth} /> <path id={layoutPathId} d={layoutPath} fill="none" /> {showIcon && ( <g transform={`translate(${round2(ix)} ${round2(iy)}) rotate(${round2(iconAngle)})`} aria-hidden="true"> {icon || ( <> <rect x={-chipW / 2} y={-chipH / 2} width={chipW} height={chipH} rx={chipH * 0.27} fill={chipFill} /> <rect x={-ew / 2} y={-eh / 2} width={ew} height={eh} rx={1.4} fill="none" stroke="#ffffff" strokeWidth={sw} strokeLinejoin="round" /> <path d={`M ${round2(-ew / 2)} ${round2(-eh / 2 + sw * 0.4)} L 0 ${round2(eh * 0.14)} L ${round2(ew / 2)} ${round2(-eh / 2 + sw * 0.4)}`} fill="none" stroke="#ffffff" strokeWidth={sw} strokeLinejoin="round" strokeLinecap="round" /> </> )} </g> )} <g clipPath={`url(#${clipId})`}> <text ref={textRef} style={{ fontSize: `${fontSize}px`, fontWeight: 500 }} fill={fgColor} xmlSpace="preserve" aria-hidden="true"> <textPath href={`#${layoutPathId}`}>{display}</textPath> </text> {!display && placeholder && ( <text style={{ fontSize: `${fontSize}px`, fontWeight: 500 }} fill={phColor} xmlSpace="preserve" aria-hidden="true"> <textPath href={`#${layoutPathId}`}>{placeholder}</textPath> </text> )} {focused && ( <g key={`${display}-${Math.min(caretIndex, display.length)}`} transform={`translate(${round2(caretX)} ${round2(caretY)}) rotate(${round2(caretAngle)})`} > <line y1={-caretH / 2} y2={caretH / 2} stroke={fgColor} strokeWidth="1.5" strokeLinecap="round"> <animate attributeName="opacity" values="1;0" dur="1.06s" calcMode="discrete" repeatCount="indefinite" /> </line> </g> )} </g> {showButton && ( <g className="curved-input__button" role="button" tabIndex={0} aria-label={buttonText} onClick={(e) => { e.stopPropagation() handleSubmit() }} onPointerDown={(e) => e.stopPropagation()} onKeyDown={(e: ReactKeyboardEvent<SVGGElement>) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() handleSubmit() } }} > <path className="curved-input__button-bg" d={buttonPath} fill={accentColor} /> <path id={buttonPathId} d={buttonTextPath} fill="none" /> <text fill={btnFgColor} textAnchor="middle" style={{ fontSize: `${fontSize}px`, fontWeight: 600, pointerEvents: 'none' }} > <textPath href={`#${buttonPathId}`} startOffset="50%"> {buttonText} </textPath> </text> </g> )} <text ref={btnMeasureRef} style={{ fontSize: `${fontSize}px`, fontWeight: 600 }} x={-9999} y={-9999} visibility="hidden" aria-hidden="true" > {buttonText} </text> </svg> ) } return ( <form ref={rootRef} className={`curved-input ${focused ? 'curved-input--focused' : ''} ${className}`.trim()} style={{ width: typeof width === 'number' ? `${width}px` : width, ...style }} onSubmit={handleSubmit} noValidate > {content} <input ref={inputRef} className="curved-input__field" type={safeType} inputMode={inputMode} name={name} value={val} onChange={handleInputChange} onSelect={handleSelect} onKeyUp={handleSelect} onFocus={() => setFocused(true)} onBlur={() => setFocused(false)} aria-label={ariaLabel || placeholder || 'Curved input'} autoComplete="off" autoCapitalize="none" autoCorrect="off" spellCheck={false} /> </form> ) } export default CurvedInputstyles/kinematics/curved-input.css .curved-input { position: relative; display: block; width: 100%; max-width: 100%; margin: 0; } .curved-input__svg { display: block; overflow: visible; width: 100%; height: auto; cursor: text; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; } .curved-input__svg text { font-family: inherit; } .curved-input__ring { opacity: 0; transition: opacity 0.25s ease; } .curved-input--focused .curved-input__ring { opacity: 0.28; } .curved-input__button { cursor: pointer; outline: none; } .curved-input__button-bg { transition: filter 0.2s ease, opacity 0.2s ease; } .curved-input__button:hover .curved-input__button-bg { filter: brightness(1.12); } .curved-input__button:active .curved-input__button-bg { filter: brightness(0.94); } .curved-input__button:focus-visible .curved-input__button-bg { filter: brightness(1.18); } .curved-input__field { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; border: 0; padding: 0; margin: 0; background: transparent; color: transparent; caret-color: transparent; font-size: 16px; pointer-events: none; outline: none; }styles/kinematics.css /* Kinematic Studio's palette and type, for the components ported from its site. Namespaced as kine-* so they sit beside this library's own tokens — Kinematics calls its off-white "accent", which here is already the blue. Import this from the stylesheet that imports Tailwind, so the @theme block turns into utilities: bg-kine-bg, text-kine-primary/70, font-kine-display and so on. */ @theme { --color-kine-bg: #0f1113; --color-kine-primary: #ceff00; --color-kine-surface: #2b2e34; --color-kine-accent: #e2e8f0; /* Audiowide for headlines, Geo for body copy. Geo breaks up at small sizes, so tiny labels use the plain sans instead. */ --font-kine-display: "Audiowide", ui-sans-serif, system-ui, sans-serif; --font-kine-body: "Geo", ui-sans-serif, system-ui, sans-serif; /* Morni, the restaurant client in the Emoji Cursor demo. */ --font-morni-brand: "Modak", ui-sans-serif, system-ui, sans-serif; --font-morni-body: "Poppins", ui-sans-serif, system-ui, sans-serif; } /* Manifesto emoji markers — the box grows from square to rectangle as scroll reaches it; once mostly grown, the emoji starts rolling, paused until then via animation-play-state set inline. */ @keyframes manifesto-emoji-roll { to { transform: rotate(360deg); } } /* Expertise cards — small, always-looping visuals tying each card's animation to its own subject (motion, layout, load speed, ranking, language). CSS keyframes rather than GSAP, since they run for as long as the card is mounted. */ @keyframes expertise-dash { to { stroke-dashoffset: -24; } } .expertise-flow-line { stroke-dasharray: 6 6; animation: expertise-dash 1s linear infinite; } @keyframes expertise-block-a { 0%, 100% { width: 85%; } 50% { width: 45%; } } @keyframes expertise-block-b { 0%, 100% { width: 55%; } 50% { width: 90%; } } @keyframes expertise-block-c { 0%, 100% { width: 70%; } 50% { width: 30%; } } .expertise-block-a { animation: expertise-block-a 3.2s ease-in-out infinite; } .expertise-block-b { animation: expertise-block-b 3.2s ease-in-out infinite 0.3s; } .expertise-block-c { animation: expertise-block-c 3.2s ease-in-out infinite 0.6s; } @keyframes expertise-fill { 0% { width: 6%; opacity: 0.5; } 70% { width: 100%; opacity: 1; } 85% { width: 100%; opacity: 1; } 100% { width: 6%; opacity: 0.5; } } .expertise-fill-bar { animation: expertise-fill 2.2s cubic-bezier(0.65, 0, 0.35, 1) infinite; } @keyframes expertise-rank-bar-1 { 0%, 100% { height: 30%; } 50% { height: 80%; } } @keyframes expertise-rank-bar-2 { 0%, 100% { height: 55%; } 50% { height: 25%; } } @keyframes expertise-rank-bar-3 { 0%, 100% { height: 40%; } 50% { height: 95%; } } @keyframes expertise-rank-bar-4 { 0%, 100% { height: 70%; } 50% { height: 50%; } } .expertise-rank-1 { animation: expertise-rank-bar-1 2.4s ease-in-out infinite; } .expertise-rank-2 { animation: expertise-rank-bar-2 2.4s ease-in-out infinite 0.15s; } .expertise-rank-3 { animation: expertise-rank-bar-3 2.4s ease-in-out infinite 0.3s; } .expertise-rank-4 { animation: expertise-rank-bar-4 2.4s ease-in-out infinite 0.45s; } @keyframes expertise-lang-cross { 0%, 45% { opacity: 1; transform: translateY(0); } 50%, 95% { opacity: 0; transform: translateY(-6px); } 100% { opacity: 1; transform: translateY(0); } } @keyframes expertise-lang-cross-alt { 0%, 45% { opacity: 0; transform: translateY(6px); } 50%, 95% { opacity: 1; transform: translateY(0); } 100% { opacity: 0; transform: translateY(6px); } } .expertise-lang-a { animation: expertise-lang-cross 3.6s ease-in-out infinite; } .expertise-lang-b { animation: expertise-lang-cross-alt 3.6s ease-in-out infinite; }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| onSubmit | (form: FormState) => Promise<void> | void | — | Sends the finished form; resolve to show the success screen. Without it the round trip is simulated. |