bpdm/ui
Form

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.

email.tsx
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>
  );
}
email.component.ts
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).

variants.tsx
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>
  );
}
variants.component.ts
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.

states.tsx
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>
  );
}
states.component.ts
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.

PropTypeDefaultDescription
labelstringRequired. The floating label text.
htmlForstringId of the wrapped control. Omit it and a matching id is generated, so <label for> always resolves.
variantover | in | onoverLabel placement — above the field, inside near the top, or a notch on the border.
children (React) / projected content (Angular)elementThe single control to wrap.
className (React)stringExtra 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 by for/id, so clicking it focuses the field and assistive tech announces it as the field's name. The association is deterministic: the id comes from htmlFor, else the control's own id, 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 disabled or aria-invalid on the field and pair aria-invalid with aria-describedby pointing at your error text.

Internationalization

  • The floating label is your text — pass it already translated (same for any placeholder on 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 under dir="rtl". See the Internationalization guide.

On this page