Form
Curved Input
A text field bent along an arc — the text, caret, icon and button all follow the curve, and long values scroll along it.
Type on the curve — click anywhere on it to place the caret
import { useState } from "react";
import CurvedInput from "@/components/ui/curved-input";
export default function CurvedInputDemo() {
const [sent, setSent] = useState<string | null>(null);
return (
<div className="w-full min-w-0 space-y-10">
<div className="grid place-items-center gap-10 rounded-card border border-line bg-[#0e0b14] px-6 py-12">
<CurvedInput width="min(450px, 100%)" onSubmit={(value) => setSent(value)} />
<CurvedInput
width="min(450px, 100%)"
bend={-24}
placeholder="Your name"
buttonText="Next"
type="text"
buttonColor="#ceff00"
buttonTextColor="#0f1113"
borderColor="#2b2e34"
backgroundColor="#16181b"
onSubmit={(value) => setSent(value)}
/>
</div>
<p className="text-center text-xs text-faint">
{sent === null
? "Type on the curve — click anywhere on it to place the caret"
: `Submitted: ${sent || "(empty)"}`}
</p>
</div>
);
}Installation
1. Install the dependencies.
terminal npm install2. 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/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; }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| bend | number | 28 | How far the middle rises above the ends, in px. Negative sags, 0 is flat. |
| width | number | string | 450 | Width of the field. |
| height | number | 64 | Thickness of the band, in px. |
| value | string | — | Controlled value; leave out for uncontrolled. |
| onChange | (value: string) => void | — | Called on every edit. |
| onSubmit | (value: string) => void | — | Called by Enter or the button. |
| placeholder | string | "Enter your email" | Shown along the curve while empty. |
| buttonText | string | "Get Started" | Label of the built-in button. |
| theme | "dark" | "light" | "dark" | Base palette; each colour prop overrides one part of it. |
| showButton | boolean | true | Show the submit button. |
| showIcon | boolean | true | Show the envelope chip. |