v1.0

FloatLabel

Preview

Basic

Loading…

Preview

Code

ts
import FloatLabel from '@/components/form/FloatLabel';

src/components/form/FloatLabel.tsx

AI prompt

text
Build a floating-label field wrapper component in React + TypeScript + Tailwind CSS, in three variants (over, in, on), with pure CSS — no state.

## Look
- Wraps exactly one `<input>`, `<textarea>` or `<select>` (already styled as a standard text input). The label is absolutely positioned inside a `relative` box, `pointer-events-none truncate leading-none max-w-[calc(100%-1.5rem)]`, animating `transition-all duration-150 ease-out`. While the control is focused the floated label turns indigo-600 / dark indigo-400.
- RESTING (empty and not focused): the label sits in the field like a placeholder — `left-3 text-xs font-normal text-slate-400 dark:text-slate-500`, vertically centred (`top-1/2 -translate-y-1/2`), or on the first line (`top-2`) for a textarea.
- FLOATED, per variant:
  - `over` (default): above the box — the wrapper reserves the row with `pt-5`; label at `left-0 top-0 -translate-y-[calc(100%+4px)] text-[11px] font-semibold text-slate-600 dark:text-slate-300`.
  - `in`: to the top inside the box — the control gets `pt-5 pb-1.5`; label at `left-3 top-1.5 text-[10px] font-medium text-slate-500 dark:text-slate-400`.
  - `on`: on the border line — label at `left-2 top-0 -translate-y-1/2 px-1 text-[10px] font-medium` with an OPAQUE patch `bg-white dark:bg-slate-900` that hides the border behind it; the control is made opaque in the same colour (`bg-white dark:bg-slate-900`) so the patch is invisible. The resting state drops the patch (`bg-transparent px-0`).

## Behaviour
- The trick: clone the child with `placeholder=" "` (a single space) and the `peer` class, render the `<label>` AFTER it, and express resting with `peer-[:placeholder-shown:not(:focus)]:…` variants. `:placeholder-shown` is true exactly while the field is empty, so it works controlled or uncontrolled with no onChange or ref. The compound selector also outranks the floated base classes, so resting wins reliably.
- A `<select>` never matches `:placeholder-shown`, so its label stays floated — correct, it always shows an option.
- Write every class string as a literal (no runtime-composed variant prefixes, or Tailwind won't generate them).
- The child's own placeholder is replaced — the label is the placeholder. Uses the child's `id` or generates one, and links the label with `htmlFor`.

## API
`label: ReactNode`, `variant: 'over' | 'in' | 'on' = 'over'`, `children` (one control, or a component forwarding `id`, `className`, `placeholder`), `multiline?` (defaults to true for a textarea child), `className`.

## Demo
Three columns, one per variant, each with Username (empty), Email (prefilled "someone@example.com") and a two-row Notes textarea.

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

import { cloneElement, useId, type ReactElement } from 'react';
import { cn } from '@/lib/cn';

type ControlProps = { id?: string; className?: string; placeholder?: string };

/*
 * Each variant is two class sets for the label: FLOATED (the base classes) and
 * RESTING (sitting in the field like a placeholder). Resting is expressed as
 *
 *   peer-[:placeholder-shown:not(:focus)]:…
 *
 * i.e. "the control before me shows its placeholder and is not focused" — which
 * is exactly "empty and idle". The trick that makes it work uncontrolled: the
 * control is given `placeholder=" "`. A single space is invisible, but it makes
 * `:placeholder-shown` true precisely while the field is empty and false the
 * moment it holds a character, whether React owns the value or the DOM does.
 * No onChange, no state, no ref reading `.value`.
 *
 * The compound selector also outranks the base classes on specificity (three
 * classes/pseudo-classes against one), so resting reliably wins while it
 * applies without depending on the order Tailwind happens to emit variants in.
 *
 * A <select> never matches `:placeholder-shown`, so its label stays floated —
 * correct, since a select always displays some option.
 *
 * Every class string below is a literal because Tailwind's scanner only sees
 * literals; composing the variant prefix at runtime would purge the rules.
 */
const VARIANT = {
  // Floats ABOVE the box; the wrapper reserves the row it floats into.
  over: {
    wrapper: 'pt-5',
    control: '',
    floated: 'left-0 top-0 -translate-y-[calc(100%+4px)] text-[11px] font-semibold text-slate-600 dark:text-slate-300',
    resting: 'peer-[:placeholder-shown:not(:focus)]:left-3 peer-[:placeholder-shown:not(:focus)]:text-xs peer-[:placeholder-shown:not(:focus)]:font-normal peer-[:placeholder-shown:not(:focus)]:text-slate-400 dark:peer-[:placeholder-shown:not(:focus)]:text-slate-500',
  },
  // Floats to the top INSIDE the box; the control grows top padding to make room.
  in: {
    wrapper: '',
    control: 'pt-5 pb-1.5',
    floated: 'left-3 top-1.5 translate-y-0 text-[10px] font-medium text-slate-500 dark:text-slate-400',
    resting: 'peer-[:placeholder-shown:not(:focus)]:text-xs peer-[:placeholder-shown:not(:focus)]:font-normal peer-[:placeholder-shown:not(:focus)]:text-slate-400 dark:peer-[:placeholder-shown:not(:focus)]:text-slate-500',
  },
  // Sits ON the border line. The patch behind the text must hide the border,
  // so it has to be opaque — and to be invisible as a patch it has to be the
  // field's own colour. `.field-input` is translucent (it shows the ground
  // through), so this variant makes the control opaque in the same colour as
  // the patch; the pair then match in both schemes whatever sits behind them.
  // Utilities outrank `.field-input` (it lives in the components layer), so
  // the plain `bg-*` here wins without `!important`.
  on: {
    wrapper: '',
    control: 'bg-white dark:bg-slate-900',
    floated: 'left-2 top-0 -translate-y-1/2 px-1 text-[10px] font-medium text-slate-500 dark:text-slate-400 bg-white dark:bg-slate-900',
    resting: 'peer-[:placeholder-shown:not(:focus)]:left-3 peer-[:placeholder-shown:not(:focus)]:px-0 peer-[:placeholder-shown:not(:focus)]:text-xs peer-[:placeholder-shown:not(:focus)]:font-normal peer-[:placeholder-shown:not(:focus)]:text-slate-400 dark:peer-[:placeholder-shown:not(:focus)]:text-slate-500 peer-[:placeholder-shown:not(:focus)]:bg-transparent',
  },
} as const;

// Where a resting label sits: centred on a one-line control, on the first line
// of a textarea (centring it in a tall box reads as a floating caption).
const REST_AT = {
  line: 'peer-[:placeholder-shown:not(:focus)]:top-1/2 peer-[:placeholder-shown:not(:focus)]:-translate-y-1/2',
  multiline: 'peer-[:placeholder-shown:not(:focus)]:top-2 peer-[:placeholder-shown:not(:focus)]:translate-y-0',
} as const;

export interface FloatLabelProps {
  label: React.ReactNode;
  /** `over` floats above the box, `in` to the top inside it, `on` onto the border line. */
  variant?: keyof typeof VARIANT;
  /** Exactly one `<input>`, `<textarea>` or `<select>` (or a component that forwards `id`, `className` and `placeholder` to one). Its own `placeholder` is replaced — the label is the placeholder. */
  children: ReactElement<ControlProps>;
  /** Rest the label on the first line instead of centred. Defaults to true for a `<textarea>` child. */
  multiline?: boolean;
  className?: string;
}

/**
 * A label that sits inside an empty field as its placeholder and floats clear
 * when the field is focused or filled. Pure CSS — see the note on `VARIANT`.
 */
export default function FloatLabel({ label, variant = 'over', children, multiline, className }: FloatLabelProps) {
  const autoId = useId();
  const id = children.props.id ?? autoId;
  const v = VARIANT[variant];
  const isMultiline = multiline ?? children.type === 'textarea';

  return (
    <div className={cn(v.wrapper, className)}>
      <div className="relative">
        {cloneElement(children, {
          id,
          placeholder: ' ',
          // `peer` must sit on the control, and the control must come BEFORE
          // the label: CSS sibling selectors only look forwards.
          className: cn(children.props.className, 'peer', v.control),
        })}
        <label
          htmlFor={id}
          className={cn(
            // pointer-events-none so a click on the resting label lands on the
            // control underneath rather than on text that merely looks like it.
            'pointer-events-none absolute max-w-[calc(100%-1.5rem)] truncate leading-none transition-all duration-150 ease-out',
            'peer-focus:text-indigo-600 dark:peer-focus:text-indigo-400',
            v.floated,
            v.resting,
            isMultiline ? REST_AT.multiline : REST_AT.line,
          )}
        >
          {label}
        </label>
      </div>
    </div>
  );
}

Props

PropTypeDefaultDescription
label*React.ReactNode—
children*ReactElement<ControlProps>—Exactly one `<input>`, `<textarea>` or `<select>` (or a component that forwards `id`, `className` and `placeholder` to one). Its own `placeholder` is replaced — the label is the placeholder.
variantkeyof typeof VARIANT'over'`over` floats above the box, `in` to the top inside it, `on` onto the border line.
multilineboolean—Rest the label on the first line instead of centred. Defaults to true for a `<textarea>` child.
classNamestring—