Skip to content

Levitating Field Input

A real text input whose typed words hang above the field and cast a soft shadow on its lit shelf; focus lifts them higher and submitting lands them.

FreeAttentionminimal
Preview

Fig. 09 / Medium: air

Demo form: nothing is sent.

Props

5
Show an error
No runtime dependencies
"use client";

import { useId } from "react";
import type { ComponentPropsWithoutRef, CSSProperties } from "react";

/**
 * LevitatingFieldInput — a real text input whose words hang above the field and cast a shadow on its shelf.
 *
 * The input is lifted a few pixels inside a pale field with a lit shelf
 * along its bottom edge, and its text throws a soft shadow down onto the
 * shelf (offset and blur grow with the height), so the typed words read as
 * floating. The caret, selection, autofill and every input attribute stay
 * native. Focus raises the words about 4px more and lights a faint wedge
 * above the field; `dropped` (set it when the form submits) lands them on
 * the shelf with one small settle. It is CSS only: one registered custom
 * property drives the lift, the shadow and the light. Label, hint and error
 * are wired with htmlFor, aria-describedby and aria-invalid. Reduced motion
 * makes every change instant.
 *
 * Part of the Attention kit: a style of anomalies, familiar things made wrong
 * (fish in dry air, a stone that breaks upward, a shadow that runs toward its
 * lamp). One subject, one motivated light, one broken rule, and everything
 * else calm. Cut byte for byte from the kit's one site (AttentionSite, a
 * demo studio called Aerium). Paste it as its own file: it carries its own
 * copy of every helper it uses and has no dependencies.
 */

/** The kit's type: Instrument Sans (through --font-instrument) over Geist. */
const A_SANS = 'var(--font-instrument, "Instrument Sans"), "Geist", system-ui, sans-serif';

/** The field's CSS: one registered number (--a-h, the text's height in px) drives the lift, the shadow and the light. */
const A_FIELD_CSS =
  "@property --a-h{syntax:'<number>';inherits:true;initial-value:5}" +
  ".a-field{--a-h:var(--a-rest,5);transition:--a-h .24s cubic-bezier(.2,.7,.2,1)}" +
  ".a-field:focus-within{--a-h:var(--a-up,9)}" +
  ".a-field[data-dropped]{--a-h:0;animation:a-drop .3s cubic-bezier(.5,0,.75,0)}" +
  "@keyframes a-drop{70%{--a-h:0}85%{--a-h:.7}100%{--a-h:0}}" +
  ".a-field input{transform:translate3d(0,calc(-1px * var(--a-h)),0);text-shadow:0 calc(1px + 1.5px * var(--a-h)) calc(0.6px + 0.9px * var(--a-h)) rgba(10,14,18,calc(0.36 - 0.018 * var(--a-h)))}" +
  ".a-field input::placeholder{color:rgba(10,14,18,.42);text-shadow:none}" +
  ".a-field .a-wedge{opacity:0;transition:opacity .3s}" +
  ".a-field:focus-within .a-wedge{opacity:1}" +
  "@media (prefers-reduced-motion:reduce){.a-field,.a-field .a-wedge{transition:none}.a-field[data-dropped]{animation:none}}";

export interface LevitatingFieldInputProps extends Omit<ComponentPropsWithoutRef<"input">, "className" | "style" | "children" | "size"> {
  /** The visible label. */
  label: string;
  /** A short hint under the field. */
  hint?: string;
  /** An error under the field (sets aria-invalid). */
  error?: string;
  /** How high the words hang at rest, 2-10px (focus lifts them about 4px more). */
  lift?: number;
  /** Settle the words onto the shelf (set it when the form submits). */
  dropped?: boolean;
  className?: string;
  style?: CSSProperties;
}

/**
 * LevitatingFieldInput — a real text input whose words hang above the field
 * and cast a soft shadow on its lit shelf; focus lifts them, `dropped` lands them.
 */
export function LevitatingFieldInput({ label, hint, error, lift = 5, dropped = false, id, className = "", style, ...input }: LevitatingFieldInputProps) {
  const auto = useId();
  const fid = id ?? "a-field-" + auto;
  const rest = Math.min(10, Math.max(2, Number.isFinite(lift) ? lift : 5));
  const hintId = hint ? fid + "-hint" : undefined;
  const errId = error ? fid + "-error" : undefined;
  const described = [input["aria-describedby"], hintId, errId].filter(Boolean).join(" ") || undefined;
  return (
    <div
      className={"a-field flex flex-col gap-2 " + className}
      data-dropped={dropped ? "" : undefined}
      style={{ "--a-rest": rest, "--a-up": rest + 4, fontFamily: A_SANS, ...style } as CSSProperties}
    >
      <style>{A_FIELD_CSS}</style>
      <label htmlFor={fid} className="font-mono text-[10px] leading-[1.6] tracking-[0.14em] text-[#0a0e12]/72 uppercase">
        {label}
      </label>
      <div className="relative rounded-[2px]" style={{ background: "rgba(250,252,253,0.72)", boxShadow: "inset 0 0 0 1px " + (error ? "rgba(150,40,30,0.55)" : "rgba(10,14,18,0.14)"), backdropFilter: "blur(10px)" }}>
        <span
          aria-hidden="true"
          className="a-wedge pointer-events-none absolute inset-x-[8%] -top-[34px] h-[34px]"
          style={{ background: "linear-gradient(180deg, rgba(255,255,255,0), rgba(255,255,255,0.5))", clipPath: "polygon(22% 0, 78% 0, 100% 100%, 0 100%)" }}
        />
        <input
          id={fid}
          {...input}
          aria-invalid={error ? true : input["aria-invalid"]}
          aria-describedby={described}
          className="relative z-[1] block w-full bg-transparent px-3.5 pt-3.5 pb-3 text-[16px] leading-[1.3] text-[#0a0e12] caret-[#0a0e12] outline-none"
        />
        <span
          aria-hidden="true"
          className="pointer-events-none absolute inset-x-0 bottom-0 h-[7px] rounded-b-[2px]"
          style={{ background: "linear-gradient(180deg, rgba(10,14,18,0) 0, rgba(10,14,18,0.05) 5px, rgba(255,255,255,0.9) 6px, rgba(255,255,255,0.9) 7px)" }}
        />
        <span aria-hidden="true" className="pointer-events-none absolute inset-0 rounded-[2px] [.a-field:focus-within_&]:shadow-[inset_0_0_0_2px_#0a0e12]" />
      </div>
      {hint && (
        <p id={hintId} className="m-0 text-[12px] leading-[1.45] text-[#0a0e12]/70">
          {hint}
        </p>
      )}
      {error && (
        <p id={errId} className="m-0 text-[12px] leading-[1.45] text-[#7a1f16]">
          {error}
        </p>
      )}
    </div>
  );
}

Free, yours to ship. Pro adds the heroes, WebGL backgrounds and complete style kits of Attention, from $19/month (launch price until 31 Oct).

Notes

About this component

A text field where the words float. As you type, the letters hang a few pixels above the field and throw a soft shadow down onto a lit shelf along its bottom edge. Focus lifts them a little higher and lights a faint wedge above the field. When the form is sent, set dropped and the words land on the shelf with one small settle, their shadow turning into a contact line. It is a real input with a label, a hint and an error, so it drops into any form.

Curator’s note

CSS only: one registered custom property is the words' height, and the lift, the shadow's offset, blur and opacity, and the focus light all read it, so a single transition moves them together. The input stays a native input: caret, selection and autofill are untouched. Cut byte for byte from the Attention kit's one site (AttentionSite), so it reads as one system with the kit's rooms: one subject, one motivated light, one broken rule.

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

New

A dark glass nav bar whose link labels hang in the air above a lit shelf, each over its own soft shadow; the link you point at, and the one for the section in view, lands.

navigationnavbarheader
New

Mounted letters on a lit plaster wall under a swinging lamp: every word's shadow runs from the light, except one, whose shadow runs toward it.

manifestotypographyshadow
NewPro

Three plan slabs that lift off a lit floor as the section arrives, each over its own soft shadow; the featured one rises highest and the pointer raises any slab higher.

pricingpricing cardsplans