Skip to the content

ComponentsForm

Float label

A label that sits inside an empty field and moves out of the way when the field has focus or a value.

Import

import { FloatLabel } from "@booleanpress/ui/float-label"

Usage

FloatLabel wraps one field and its <label>. The label sits inside the empty field like a placeholder; when the field has focus or a value, it moves above the field (variant="over", the default), into the top of the field (in) or onto its top edge (on).

import { FloatLabel } from "@booleanpress/ui/float-label"
import { Input } from "@booleanpress/ui/input"
import { Label } from "@booleanpress/ui/label"

export function FromName() {
  return (
    <FloatLabel>
      <Input id="from-name" />
      <Label htmlFor="from-name">From name</Label>
    </FloatLabel>
  )
}

The field is an Input, an InputNumber, an InputMask, a Textarea, a PasswordInput, an InputGroup, a NativeSelect or a Select; the label is a Label (or a label) whose htmlFor is the field's id, a direct child of the wrapper. The wrapper reads whether the field has a value on every keystroke and change, after every render and after a reset of its form, so a controlled value set from code and a form reset move the label too; it marks the wrapper data-filled while there is one. A field with a placeholder keeps the label moved, so the two never overlap; give a Select no placeholder.

over puts the label 18 px above the field: leave that room above it. in makes the field 47 px tall. Put an icon in through an InputGroup with a start addon; the label starts after it. To float a label next to a text addon, put the FloatLabel inside the InputGroup, round the InputGroupInput and its label.

Examples

Basic

The label sits inside the empty field and moves 18 px above it, at 10 px, on focus or with a value; it starts after a leading icon.

import { MailIcon } from "lucide-react"
import { FloatLabel } from "@booleanpress/ui/float-label"
import { Input } from "@booleanpress/ui/input"
import { InputGroup, InputGroupAddon, InputGroupInput } from "@booleanpress/ui/input-group"
import { Label } from "@booleanpress/ui/label"

export default function FloatLabelBasic() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-8 pt-4.5">
      <FloatLabel>
        <Input id="fl-from-name" />
        <Label htmlFor="fl-from-name">From name</Label>
      </FloatLabel>
      <FloatLabel>
        <InputGroup>
          <InputGroupInput id="fl-from-email" defaultValue="support@example.com" />
          <InputGroupAddon>
            <MailIcon />
          </InputGroupAddon>
        </InputGroup>
        <Label htmlFor="fl-from-email">From email</Label>
      </FloatLabel>
    </div>
  )
}

In

variant="in" moves the label into the top of the field, which grows to 47 px to make room.

import { FloatLabel } from "@booleanpress/ui/float-label"
import { Input } from "@booleanpress/ui/input"
import { Label } from "@booleanpress/ui/label"

export default function FloatLabelIn() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-4">
      <FloatLabel variant="in">
        <Input id="fl-in-host" />
        <Label htmlFor="fl-in-host">SMTP host</Label>
      </FloatLabel>
      <FloatLabel variant="in">
        <Input id="fl-in-port" inputMode="numeric" defaultValue="587" />
        <Label htmlFor="fl-in-port">SMTP port</Label>
      </FloatLabel>
    </div>
  )
}

On

variant="on" moves the label onto the field's top edge, on the field's fill.

import { FloatLabel } from "@booleanpress/ui/float-label"
import { Input } from "@booleanpress/ui/input"
import { Label } from "@booleanpress/ui/label"

export default function FloatLabelOn() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-6 pt-1.5">
      <FloatLabel variant="on">
        <Input id="fl-on-organisation" />
        <Label htmlFor="fl-on-organisation">Organisation</Label>
      </FloatLabel>
      <FloatLabel variant="on">
        <Input id="fl-on-domain" defaultValue="mail.example.com" />
        <Label htmlFor="fl-on-domain">Sending domain</Label>
      </FloatLabel>
    </div>
  )
}

Invalid

aria-invalid on the field colours the label --destructive-strong, focused or not.

import { FloatLabel } from "@booleanpress/ui/float-label"
import { Input } from "@booleanpress/ui/input"
import { Label } from "@booleanpress/ui/label"

export default function FloatLabelInvalid() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-6 pt-4.5">
      <div className="flex flex-col gap-1.5">
        <FloatLabel>
          <Input id="fl-invalid-host" aria-invalid aria-describedby="fl-invalid-host-error" />
          <Label htmlFor="fl-invalid-host">SMTP host</Label>
        </FloatLabel>
        <p id="fl-invalid-host-error" className="text-xs text-destructive-strong">
          Enter the host your provider gave you.
        </p>
      </div>
      <div className="flex flex-col gap-1.5">
        <FloatLabel variant="in">
          <Input id="fl-invalid-port" defaultValue="99999" aria-invalid aria-describedby="fl-invalid-port-error" />
          <Label htmlFor="fl-invalid-port">SMTP port</Label>
        </FloatLabel>
        <p id="fl-invalid-port-error" className="text-xs text-destructive-strong">
          Enter a port from 1 to 65535.
        </p>
      </div>
    </div>
  )
}

With a select

A Select moves the label once a value is chosen or the list opens; a NativeSelect once an option with a value is chosen.

import { FloatLabel } from "@booleanpress/ui/float-label"
import { Label } from "@booleanpress/ui/label"
import { NativeSelect, NativeSelectOption } from "@booleanpress/ui/native-select"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@booleanpress/ui/select"

export default function FloatLabelWithSelect() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-8 pt-4.5">
      <FloatLabel>
        <Select>
          <SelectTrigger id="fl-provider" className="w-full">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            <SelectItem value="smtp">Other SMTP</SelectItem>
            <SelectItem value="ses">Amazon SES</SelectItem>
            <SelectItem value="mailgun">Mailgun</SelectItem>
          </SelectContent>
        </Select>
        <Label htmlFor="fl-provider">Email provider</Label>
      </FloatLabel>
      <FloatLabel variant="in">
        <NativeSelect id="fl-encryption" defaultValue="tls" className="w-60">
          <NativeSelectOption value="" />
          <NativeSelectOption value="tls">TLS</NativeSelectOption>
          <NativeSelectOption value="ssl">SSL</NativeSelectOption>
        </NativeSelect>
        <Label htmlFor="fl-encryption">Encryption</Label>
      </FloatLabel>
    </div>
  )
}

Textarea

In a textarea the label sits at the first line.

import { FloatLabel } from "@booleanpress/ui/float-label"
import { Label } from "@booleanpress/ui/label"
import { Textarea } from "@booleanpress/ui/textarea"

export default function FloatLabelTextarea() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-8 pt-4.5">
      <FloatLabel>
        <Textarea id="fl-note" rows={3} />
        <Label htmlFor="fl-note">Internal note</Label>
      </FloatLabel>
      <FloatLabel variant="in">
        <Textarea id="fl-reply" rows={3} defaultValue="Thanks, we have resent the message." />
        <Label htmlFor="fl-reply">Reply</Label>
      </FloatLabel>
    </div>
  )
}

With a number and a mask

An InputNumber and an InputMask take every variant; with buttons on both sides the label starts past the minus button.

import { FloatLabel } from "@booleanpress/ui/float-label"
import { InputMask } from "@booleanpress/ui/input-mask"
import { InputNumber } from "@booleanpress/ui/input-number"
import { Label } from "@booleanpress/ui/label"

export default function FloatLabelWithNumberAndMask() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-6 pt-4.5">
      <FloatLabel>
        <InputNumber id="fl-daily-limit" fluid min={0} />
        <Label htmlFor="fl-daily-limit">Daily sending limit</Label>
      </FloatLabel>
      <FloatLabel variant="in">
        <InputMask id="fl-support-phone" type="tel" mask="(999) 999-9999" />
        <Label htmlFor="fl-support-phone">Support phone</Label>
      </FloatLabel>
      <FloatLabel variant="in">
        <InputNumber id="fl-batch-size" fluid buttons="stacked" defaultValue={500} step={50} min={50} />
        <Label htmlFor="fl-batch-size">Batch size</Label>
      </FloatLabel>
      <FloatLabel variant="on">
        <InputNumber id="fl-retries" fluid buttons="horizontal" defaultValue={3} min={0} max={10} />
        <Label htmlFor="fl-retries">Retries</Label>
      </FloatLabel>
    </div>
  )
}

Accessibility

Semantics
A div round the field and a native label. The wrapper adds no role.
Labels
The label names the field through htmlFor and the field's id: it is the field's visible and accessible name. It stays in the page while it floats, so the name never changes.
Focus
The wrapper takes no focus. The label ignores the pointer, so a click on it reaches the field under it. Focus inside the wrapper colours the label --secondary-foreground.
Known limits
  • A placeholder and a resting label would overlap, so a field with a placeholder keeps its label moved.
  • The label's position depends on the field's start padding; a field with a text addon at the start needs the FloatLabel inside the group.
  • The 10 px floated label is small; keep the label short.

Keyboard

Keyboard
KeyBehaviour

API

FloatLabel

Renders a div and passes it every other prop.

FloatLabel props
PropTypeDefaultDescription
variant"on" | "in" | "over"overWhere the label goes when the field has focus or a value: over (default), in or on.

Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.

Data attributes: data-slot="float-label" (FloatLabel), and data-variant.

Theming

The label is --muted-foreground at rest, --secondary-foreground while the field has focus and --destructive-strong while the field is invalid. The on label is drawn on --field. The move takes --bui-duration-control; reduced motion makes it instant.

The tokens its classes read. Change their values in your theme, and it follows.

Theme tokens
TokenUsed for
--destructive-strongtext
--fieldbackground
--muted-foregroundtext
--secondary-foregroundtext