Float Label
Floating-label wrapper — the label starts as a placeholder and floats up on focus or when filled, in three variants, in React (@bpdm/ui) and Angular (@bpdm/ng).
The Float Label wraps a single field so its label starts as a placeholder and floats up on
focus or when the field has a value. It's pure CSS — no JavaScript — and links the label to the
control for accessibility. In React it's <FloatLabel> around any input; in Angular it's
<bpdm-float-label> around a control with the bpdmInput directive.
Usage
Wrap a single control and give the label. Point htmlFor at the control's id so clicking the
label focuses the field — or omit it and a matching id is generated for you, so the label is always
associated.
import { FloatLabel } from '@bpdm/ui/float-label';
import { Input } from '@bpdm/ui/input';
export function Email() {
return (
<FloatLabel label="Email" variant="over" htmlFor="email">
<Input id="email" />
</FloatLabel>
);
}import { Component } from '@angular/core';
import { BpdmFloatLabel, BpdmInput } from '@bpdm/ng';
@Component({
selector: 'app-email',
standalone: true,
imports: [BpdmFloatLabel, BpdmInput],
template: `
<bpdm-float-label label="Email" variant="over" htmlFor="email">
<input bpdmInput id="email" />
</bpdm-float-label>
`,
})
export class EmailComponent {}Variants
Three placements: over (default — the label rests above the field), in (inside, near the top),
and on (a notch that sits on the border).
import { FloatLabel } from '@bpdm/ui/float-label';
import { Input } from '@bpdm/ui/input';
export function Variants() {
return (
<div className="flex w-72 flex-col gap-6">
<FloatLabel label="Over (default)" variant="over" htmlFor="a">
<Input id="a" />
</FloatLabel>
<FloatLabel label="In" variant="in" htmlFor="b">
<Input id="b" />
</FloatLabel>
<FloatLabel label="On the border" variant="on" htmlFor="c">
<Input id="c" />
</FloatLabel>
</div>
);
}import { Component } from '@angular/core';
import { BpdmFloatLabel, BpdmInput } from '@bpdm/ng';
@Component({
selector: 'app-variants',
standalone: true,
imports: [BpdmFloatLabel, BpdmInput],
template: `
<div class="flex w-72 flex-col gap-6">
<bpdm-float-label label="Over (default)" variant="over" htmlFor="a">
<input bpdmInput id="a" />
</bpdm-float-label>
<bpdm-float-label label="In" variant="in" htmlFor="b">
<input bpdmInput id="b" />
</bpdm-float-label>
<bpdm-float-label label="On the border" variant="on" htmlFor="c">
<input bpdmInput id="c" />
</bpdm-float-label>
</div>
`,
})
export class VariantsComponent {}States
The wrapper defers to the field it wraps — disable the control, or mark it aria-invalid and pair
it with a described error message.
Enter a valid email.
import { FloatLabel } from '@bpdm/ui/float-label';
import { Input } from '@bpdm/ui/input';
export function States() {
return (
<div className="flex w-72 flex-col gap-6">
<FloatLabel label="Email" variant="on" htmlFor="disabled-email">
<Input id="disabled-email" disabled defaultValue="jane@company.com" />
</FloatLabel>
<div className="flex flex-col gap-1.5">
<FloatLabel label="Email" variant="on" htmlFor="invalid-email">
<Input id="invalid-email" aria-invalid aria-describedby="email-err" />
</FloatLabel>
<p id="email-err" className="text-sm text-destructive">
Enter a valid email.
</p>
</div>
</div>
);
}import { Component } from '@angular/core';
import { BpdmFloatLabel, BpdmInput } from '@bpdm/ng';
@Component({
selector: 'app-states',
standalone: true,
imports: [BpdmFloatLabel, BpdmInput],
template: `
<div class="flex w-72 flex-col gap-6">
<bpdm-float-label label="Email" variant="on" htmlFor="disabled-email">
<input bpdmInput id="disabled-email" [disabled]="true" value="jane@company.com" />
</bpdm-float-label>
<div class="flex flex-col gap-1.5">
<bpdm-float-label label="Email" variant="on" htmlFor="invalid-email">
<input bpdmInput id="invalid-email" aria-invalid aria-describedby="email-err" />
</bpdm-float-label>
<p id="email-err" class="text-sm text-destructive">Enter a valid email.</p>
</div>
</div>
`,
})
export class StatesComponent {}API
FloatLabel / bpdm-float-label
Wraps exactly one control (input / textarea / select). It sets a blank placeholder and the peer
class on that control so the label can float via CSS — you don't wire anything up.
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | Required. The floating label text. |
htmlFor | string | — | Id of the wrapped control. Omit it and a matching id is generated, so <label for> always resolves. |
variant | over | in | on | over | Label placement — above the field, inside near the top, or a notch on the border. |
children (React) / projected content (Angular) | element | — | The single control to wrap. |
className (React) | string | — | Extra classes on the wrapper. |
In React, FloatLabel also spreads any extra native <div> props (data-*, aria-*,
style, id, …) onto the wrapper. In Angular, style the host element directly.
Accessibility
- The label is a real
<label>associated with the control byfor/id, so clicking it focuses the field and assistive tech announces it as the field's name. The association is deterministic: the id comes fromhtmlFor, else the control's ownid, else a generated one — so it never dangles. - Because it's a true label (not a placeholder standing in for one), the field keeps its accessible name even after the label floats away — unlike placeholder-only patterns, which lose the name as soon as the user types.
- The float is pure CSS (
peer+:placeholder-shown); a blank placeholder is injected so an empty field shows the resting label without a competing placeholder. Any real placeholder you set is preserved. - State lives on the control, not the wrapper: set
disabledoraria-invalidon the field and pairaria-invalidwitharia-describedbypointing at your error text.
Internationalization
- The floating
labelis your text — pass it already translated (same for anyplaceholderon the wrapped control). There's nothing hard-coded to override. - RTL-safe: the resting label position uses a logical property (
start-3), so the label rests and floats from the correct side underdir="rtl". See the Internationalization guide.