Skip to content
jswnthui

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

Installation

  1. 1. Install the dependencies.

    terminal
    npm install gsap
  2. 2. Add the cn helper, 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. 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 &amp; 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 CurvedInput
    styles/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

PropTypeDefaultDescription
onSubmit(form: FormState) => Promise<void> | void—Sends the finished form; resolve to show the success screen. Without it the round trip is simulated.