bottega

primitives

Floating Label Input

A single-line text input whose label begins at the field baseline — acting as a visible placeholder — then animates upward and shrinks when the field receives focus or contains a value. The bottom-border transitions from the muted --border token to a two-stop linear-gradient built from --primary on focus, giving a clean gradient underline accent without a surrounding box. Technique uses the CSS :placeholder-shown selector (with a single-space placeholder) and an adjacent sibling combinator to drive the label's position and size purely in CSS — no JS required, making this a server component. Fully controlled (value + onChange), forwardRef, htmlFor/id wired, disabled-safe.

Ported from mrhyddenn (MIT)


Install

1. Register the namespace (once per project):

json
// components.json — register the @bottega namespace once
{
  "registries": {
    "@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
  }
}

2. Add the component:

bash
npx shadcn add @bottega/floating-label-input

Usage

Usagetsx
<FloatingLabelInput />

Props

Standard props — see source.


Source

floating-label-input.tsxtsx
import * as React from "react";
import styles from "./floating-label-input.module.css";

export type FloatingLabelInputProps = {
  /** Unique id wiring the <label> to the <input>. Required for a11y. */
  id: string;
  /** Text displayed as the floating label. */
  label: string;
  /** Controlled value. */
  value: string;
  /** Change handler — receives the raw string value. */
  onChange: (value: string) => void;
  /** Placeholder text (visually hidden; used for the CSS :placeholder-shown trigger). Defaults to " " (a single space) so the label floats correctly even when value is empty. */
  placeholder?: string;
  /** Makes the field non-interactive. */
  disabled?: boolean;
  /** Additional class applied to the root wrapper. */
  className?: string;
  /** Forwarded to the native <input>. */
  name?: string;
  /** Forwarded to the native <input>. */
  autoComplete?: string;
  /** Forwarded to the native <input>. */
  required?: boolean;
  /** Forwarded to the native <input>. */
  type?: string;
};

/** A text input with a floating label that animates up on focus or when a
 * value is present. The bottom-border transitions from a muted token color
 * to a primary-tinted gradient on focus. Fully controlled; passes `id` through
 * to the native element so the `<label>` `htmlFor` is always wired. */
export const FloatingLabelInput = React.forwardRef<
  HTMLInputElement,
  FloatingLabelInputProps
>(function FloatingLabelInput(
  {
    id,
    label,
    value,
    onChange,
    placeholder = " ",
    disabled = false,
    className = "",
    name,
    autoComplete,
    required,
    type = "text",
  },
  ref,
) {
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    onChange(e.target.value);
  };

  return (
    <div className={[styles.group, className].filter(Boolean).join(" ")}>
      <input
        ref={ref}
        id={id}
        name={name}
        type={type}
        className={styles.field}
        placeholder={placeholder}
        value={value}
        onChange={handleChange}
        disabled={disabled}
        required={required}
        autoComplete={autoComplete}
        aria-label={label}
      />
      <label htmlFor={id} className={styles.label}>
        {label}
      </label>
    </div>
  );
});

FloatingLabelInput.displayName = "FloatingLabelInput";

Dependencies

  • @bottega/tokens