# 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"`
- **Page:** <https://ui.booleanpress.com/components/float-label> · @booleanpress/ui 0.2.0

## 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`).

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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

| Key | Behaviour |
| --- | --- |

## API

### FloatLabel

Renders a `div` and passes it every other prop.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"on" \| "in" \| "over"` | `over` | Where 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.

| Token | Used for |
| --- | --- |
| `--destructive-strong` | text |
| `--field` | background |
| `--muted-foreground` | text |
| `--secondary-foreground` | text |
