Anatomy of the Magic Input
How a flat text field lifts into a 3D card on focus, runs a rainbow edge, and turns its own depth layer into a submit progress bar.
↓Scroll to step through it
The three-layer stack
Most inputs draw a border and change its color on focus. In Magic Input the
field lifts off the page, casts a shadow, and reveals a colored edge underneath.
On submit, that same edge becomes a progress bar. None of it touches layout:
it's three stacked layers and a handful of transform / opacity transitions.
The component renders one group wrapper containing three absolutely-positioned
layers pinned to the same bounds: a blurred shadow, a colored edge, and
the real <input> on top. At rest the shadow and edge are hidden; the field
looks flat.
Because all three share inset-0, they stack exactly. The 3D illusion comes
from how far apart they sit on the Y axis, and that distance is what the lift
animates.
The lift
Focus lives on group-focus-within, so tabbing into the <input> drives every
layer at once. The front translates up, the shadow drops down + fades in +
blurs, and the edge fades in. The front moving one way while the shadow moves
the other is what creates the depth.
The front uses an overshoot bezier (…,1.5) so it springs slightly past its
target and settles. That overshoot is the "pop" on focus. The shadow rides a
calmer …,1 curve over 600ms, because a shadow that snapped would read as a
glitch. depth="always" moves the resting position to the lifted one, so the
field is raised before you focus it.
The lift uses no box-shadow transition, no width or top animation and no
clip-path. The shadow is a solid blurred span whose translate and opacity
change; the edge only fades. transform and opacity are the two properties
the browser can animate on the compositor without touching layout or paint, so
the lift stays smooth even with several fields on screen.
const frontClass =
"… [will-change:translate] " +
"[transition:translate_600ms_cubic-bezier(0.3,0.7,0.4,1)] " +
"motion-reduce:[transition:none] …";will-change: translate promotes the layer up front so the first frame doesn't
hitch, and motion-reduce:[transition:none] drops the whole thing to an instant
state change for anyone who asks for reduced motion.
The rainbow edge
With rainbow (the default), focus swaps the edge and shadow fill for a
single 200%-wide linear gradient, and animate-magic-rainbow slides
background-position across it. The front stays neutral, so the color reads as a
glowing edge under a plain field.
The colors are written out as a full literal because Tailwind's class scanner
can't resolve a ${var} nested inside an arbitrary value. Only
background-position animates; the gradient itself never re-renders.
The submit lifecycle
Pass onSubmit (or submitButton) and the field grows a submit button. Drive
status and it runs a lifecycle: loading → success / error. The same
edge/shadow layer that draws the depth becomes the progress bar. Its
background-size grows for determinate progress, or a segment bounces end to
end for indeterminate, while the button's icon cross-fades arrow → ring → check.
The four submit icons (arrow, ring/spinner, check, X) are stacked in the same
box. Only the active one is opacity-100 scale(1); the rest sit at
opacity-0 scale(0.4) rotate(-35deg). Switching status changes which one is
active, so they morph into each other instead of popping.
const iconClass = (active: boolean) =>
active
? "… opacity-100 [transform:scale(1)_rotate(0deg)]"
: "… opacity-0 [transform:scale(0.4)_rotate(-35deg)]";The result
Three stacked layers, a focus-driven lift, and a progress bar that is the depth
layer with a different fill. transform and opacity do the work that borders
and box-shadows usually fake.
- Front
- the real <input> you type in
- Edge
- colored gradient, fakes the 3D wall
- Shadow
- blurred, grounds the lift
<div className="group relative inline-block rounded-xl"><span className={shadowClass} aria-hidden="true" /><span className={edgeClass} aria-hidden="true" /><input className={frontClass} {...props} /></div>Anatomy of the Magic Input
How a flat text field lifts into a 3D card on focus, runs a rainbow edge, and turns its own depth layer into a submit progress bar.
- Front
- the real <input> you type in
- Edge
- colored gradient, fakes the 3D wall
- Shadow
- blurred, grounds the lift
<div className="group relative inline-block rounded-xl"><span className={shadowClass} aria-hidden="true" /><span className={edgeClass} aria-hidden="true" /><input className={frontClass} {...props} /></div>The three-layer stack
Most inputs draw a border and change its color on focus. In Magic Input the
field lifts off the page, casts a shadow, and reveals a colored edge underneath.
On submit, that same edge becomes a progress bar. None of it touches layout:
it's three stacked layers and a handful of transform / opacity transitions.
The component renders one group wrapper containing three absolutely-positioned
layers pinned to the same bounds: a blurred shadow, a colored edge, and
the real <input> on top. At rest the shadow and edge are hidden; the field
looks flat.
Because all three share inset-0, they stack exactly. The 3D illusion comes
from how far apart they sit on the Y axis, and that distance is what the lift
animates.
- front
- edge
- shadow
- 250ms
- 250ms
- 600ms
- translateY -6px
- opacity 0 → 1
- drop + blur + fade
// front: springy overshoot into the lifted position"group-focus-within:-translate-y-[4px] " +"group-focus-within:[transition:translate_250ms_cubic-bezier(0.3,0.7,0.4,1.5)]";// shadow: opacity 0 → 1, translate-y 0 → 4px, on a slower settle"opacity-0 translate-y-0 group-focus-within:opacity-100 " +"group-focus-within:translate-y-[4px] " +"[transition:translate_600ms_cubic-bezier(0.3,0.7,0.4,1),opacity_250ms_ease]";The lift
Focus lives on group-focus-within, so tabbing into the <input> drives every
layer at once. The front translates up, the shadow drops down + fades in +
blurs, and the edge fades in. The front moving one way while the shadow moves
the other is what creates the depth.
The front uses an overshoot bezier (…,1.5) so it springs slightly past its
target and settles. That overshoot is the "pop" on focus. The shadow rides a
calmer …,1 curve over 600ms, because a shadow that snapped would read as a
glitch. depth="always" moves the resting position to the lifted one, so the
field is raised before you focus it.
The lift uses no box-shadow transition, no width or top animation and no
clip-path. The shadow is a solid blurred span whose translate and opacity
change; the edge only fades. transform and opacity are the two properties
the browser can animate on the compositor without touching layout or paint, so
the lift stays smooth even with several fields on screen.
const frontClass =
"… [will-change:translate] " +
"[transition:translate_600ms_cubic-bezier(0.3,0.7,0.4,1)] " +
"motion-reduce:[transition:none] …";will-change: translate promotes the layer up front so the first frame doesn't
hitch, and motion-reduce:[transition:none] drops the whole thing to an instant
state change for anyone who asks for reduced motion.
edge + shadow share one gradient; only the position animates
const RAINBOW_FOCUS_FILL ="group-focus-within:[background-image:linear-gradient(90deg," +"var(--rainbow-1),var(--rainbow-5),var(--rainbow-3)," +"var(--rainbow-4),var(--rainbow-2))] " +"group-focus-within:[background-size:200%_100%] " +"group-focus-within:animate-magic-rainbow " +"motion-reduce:group-focus-within:animate-none";The rainbow edge
With rainbow (the default), focus swaps the edge and shadow fill for a
single 200%-wide linear gradient, and animate-magic-rainbow slides
background-position across it. The front stays neutral, so the color reads as a
glowing edge under a plain field.
The colors are written out as a full literal because Tailwind's class scanner
can't resolve a ${var} nested inside an arbitrary value. Only
background-position animates; the gradient itself never re-renders.
- idle
- loading
- success
- arrow
- bar fills · ring spins
- green sweep · check
const statusFill =isLoading && isDeterminate ? "[background-position:right_center] " + "[background-size:var(--magic-progress,0%)_100%]" : isLoading ? "[background-image:linear-gradient(90deg,transparent," + "var(--magic-fill),transparent)] " + "[background-size:45%_100%] animate-magic-input-indeterminate" : "[background-color:var(--primary)] animate-magic-input-sweep";The submit lifecycle
Pass onSubmit (or submitButton) and the field grows a submit button. Drive
status and it runs a lifecycle: loading → success / error. The same
edge/shadow layer that draws the depth becomes the progress bar. Its
background-size grows for determinate progress, or a segment bounces end to
end for indeterminate, while the button's icon cross-fades arrow → ring → check.
The four submit icons (arrow, ring/spinner, check, X) are stacked in the same
box. Only the active one is opacity-100 scale(1); the rest sit at
opacity-0 scale(0.4) rotate(-35deg). Switching status changes which one is
active, so they morph into each other instead of popping.
const iconClass = (active: boolean) =>
active
? "… opacity-100 [transform:scale(1)_rotate(0deg)]"
: "… opacity-0 [transform:scale(0.4)_rotate(-35deg)]";The result
Three stacked layers, a focus-driven lift, and a progress bar that is the depth
layer with a different fill. transform and opacity do the work that borders
and box-shadows usually fake.
Locked, not disabled
While loading or success, the field has to stop accepting input without
losing its lifted 3D look. A disabled input would flatten and gray out, so it
uses readOnly instead:
const lock = readOnly || isLoading || status === "success";
// …
<input readOnly={lock} aria-busy={isLoading || undefined} … />The visuals stay live, the value is frozen, and aria-busy plus an sr-only
role="progressbar" (with aria-valuenow when determinate) tell assistive tech
what the animation is showing. Focus rings come from focus-visible so keyboard
users get an outline without mouse clicks painting one.
Motion Score
translateLift of edge + shadow layers on focusbackground-sizeDeterminate progress-bar fillbackground-positionFocused rainbow edge keyframe loop