---
title: FloatingLabelTextField
description: A pill-shaped text input where the label floats to the top when focused or filled. Optimized for search and selection flows.
---

```tsx
'use client';

import { FloatingLabelTextField } from '@/ui/forms/FloatingLabelTextField';

export function FloatingLabelTextFieldDemo() {
  return (
    <div className="flex flex-col gap-16 w-full max-w-[496px]">
      <FloatingLabelTextField label="Select" />

      <FloatingLabelTextField label="Select" value="First value of the list" hasDropdown />

      <FloatingLabelTextField
        label="Select"
        value="First value of the list"
        validationStatus="error"
        errorMessage="Please select a value"
        hasDropdown
      />

      <FloatingLabelTextField label="Select" disabled />
    </div>
  );
}
```

## Installation

Using the CLI:

```bash
npx shadcn@latest add https://develop.trident-ui.pro.clubmed/r/floating-label-text-field.json
```

Or manually, copy the following code into `/ui/forms/FloatingLabelTextField.tsx`:

```tsx
'use client';

import { useId, useState, useRef } from 'react';
import clsx from 'clsx';
import { useValue, type UseValueProps } from '../hooks/useValue';
import { useInternalStatus } from '../hooks/useInternalStatus';
import { FormControlError } from './FormControlError';
import { Icon, type IconicNames, type IconicTypes } from '@clubmed/trident-icons';
import { type FormControlProps } from './FormControl';

export interface FloatingLabelTextFieldProps<Value = string> extends FormControlProps<Value> {
  icon?: IconicNames;
  iconType?: IconicTypes;
  formatter?: UseValueProps<Value>['formatter'];
  hasDropdown?: boolean;
  clear?: string;
  iconFirst?: boolean;
}

export const FloatingLabelTextField = <Value = string,>(
  props: FloatingLabelTextFieldProps<Value>,
) => {
  const internalId = useId();

  const {
    id = internalId,
    name = id,
    label,
    labelClassName,
    value: initialValue,
    validationStatus = 'default',
    errorMessage,
    disabled = false,
    required = false,
    hideRequiredStar,
    description,
    icon,
    iconType,
    iconFirst = true,
    hasDropdown = false,
    clear = '',
    className,
    dataTestId,
    dataName,
    'data-testid': dataTestIdProp,
    onChange,
    formatter,
    ...rest
  } = props;

  const { value, setValue } = useValue<Value>({ initialValue, onChange, formatter });
  const internalStatus = useInternalStatus({ isDisabled: disabled, validationStatus });
  const [focused, setFocused] = useState(false);
  const inputRef = useRef<HTMLInputElement>(null);

  const isFloating = focused || (value !== '' && value !== null && value !== undefined);
  const showError = internalStatus === 'error' && errorMessage;
  const shouldDisplayClearButton =
    !disabled && value !== '' && value !== null && value !== undefined && !!clear;

  const nbIconsStart = Number(!!icon && iconFirst);
  const nbIconsEnd =
    Number(!!icon && !iconFirst) +
    Number(shouldDisplayClearButton || hasDropdown) +
    Number(internalStatus === 'error' || internalStatus === 'success');

  return (
    <div
      className={clsx('flex flex-col gap-4', className)}
      data-name={dataName ?? 'FloatingLabelTextField'}
      data-testid={dataTestIdProp ?? dataTestId}
    >
      <div className="relative">
        <input
          {...rest}
          ref={inputRef}
          id={id}
          name={name}
          disabled={disabled}
          required={required}
          value={value as string}
          onChange={(e) => setValue(e.target.value as Value, e.nativeEvent)}
          onFocus={() => setFocused(true)}
          onBlur={() => setFocused(false)}
          style={
            {
              '--nbIconsStart': nbIconsStart,
              '--nbIconsEnd': nbIconsEnd,
            } as React.CSSProperties
          }
          className={clsx(
            'text-b3 rounded-pill w-full inset-ring h-60 font-normal outline-none focus-visible:ring-4 focus-visible:ring-black focus-visible:ring-offset-2',
            'ps-[calc(20px+var(--nbIconsStart)*32px)] pe-[calc(20px+var(--nbIconsEnd)*32px)]',
            {
              'pt-[26px] pb-[8px]': isFloating && label,
              'py-12': !(isFloating && label),
              'inset-ring-middleGrey focus:inset-ring-black active:inset-ring-black':
                internalStatus === 'default',
              'bg-white text-black': internalStatus !== 'disabled',
              'bg-pearl inset-ring-middleGrey': internalStatus === 'disabled',
              'inset-ring-red': internalStatus === 'error',
              'inset-ring-green': internalStatus === 'success',
            },
          )}
          aria-label={label ? undefined : name}
          aria-invalid={internalStatus === 'error'}
          aria-describedby={showError ? `${id}-error` : undefined}
        />

        {label && (
          <label
            htmlFor={id}
            style={
              {
                left: `calc(20px + ${nbIconsStart} * 32px)`,
              } as React.CSSProperties
            }
            className={clsx(
              'pointer-events-none absolute right-0 transition-all duration-150 select-none pe-[calc(20px+var(--nbIconsEnd)*32px)]',
              {
                'top-[10px] text-b6 font-semibold': isFloating,
                'top-1/2 -translate-y-1/2 text-b3 font-normal': !isFloating,
                'text-red': internalStatus === 'error' && isFloating,
                'text-middleGrey': internalStatus !== 'error' && isFloating,
                'text-black': !isFloating && internalStatus !== 'disabled',
                'text-grey': internalStatus === 'disabled',
              },
              labelClassName,
            )}
          >
            {label}
            {required && !hideRequiredStar && (
              <span className="text-red ms-4" aria-hidden="true">
                *
              </span>
            )}
          </label>
        )}

        <div
          className={clsx(
            'pointer-events-none absolute inset-0 flex items-center justify-between px-20 py-12',
            {
              'text-grey': internalStatus === 'disabled',
              'text-red': internalStatus === 'error',
              'text-green': internalStatus === 'success',
            },
          )}
        >
          {icon && iconFirst && <Icon name={icon} width="24px" />}

          <span className="ms-auto flex gap-x-8">
            {internalStatus === 'error' && (
              <Icon name="CrossDefault" width="24px" type={iconType} />
            )}
            {internalStatus === 'success' && (
              <Icon name="CheckDefault" width="24px" type={iconType} />
            )}
            {(hasDropdown || shouldDisplayClearButton) &&
              (shouldDisplayClearButton ? (
                <button
                  className="pointer-events-auto outline-none focus-visible:ring-4 focus-visible:ring-black focus-visible:ring-offset-2"
                  onClick={(e) => setValue('' as Value, e.nativeEvent)}
                  aria-label={clear}
                  type="reset"
                >
                  <Icon name="CrossDefault" className="text-black" width="24px" />
                </button>
              ) : (
                <Icon name="ArrowDefaultDown" className="text-black" width="24px" />
              ))}
            {!iconFirst && icon && <Icon name={icon} width="24px" />}
          </span>
        </div>
      </div>

      {description && (
        <span className="text-b4 text-darkGrey font-normal px-20">{description}</span>
      )}

      {showError && <FormControlError id={`${id}-error`}>{errorMessage}</FormControlError>}
    </div>
  );
};
```

## Usage

```tsx showLineNumbers
'use client';

import { useState } from 'react';
import { FloatingLabelTextField } from '@/ui/forms/FloatingLabelTextField';

export function Component() {
  const [value, setValue] = useState('');

  return (
    <FloatingLabelTextField
      label="Select"
      value={value}
      onChange={(_, v) => setValue(v)}
      hasDropdown
    />
  );
}
```

## API Reference

### FloatingLabelTextField

| Prop | Type | Default | Description |
| ---- | ---- | ------- | ----------- |
| `label` | `string` | `undefined` | The floating label text. Acts as placeholder when empty and floats to the top when focused or filled |
| `id` | `string` | auto-generated | The HTML id for the input element |
| `name` | `string` | `id` | The name attribute for the input |
| `value` | `Value` | `undefined` | Controlled value of the input |
| `onChange` | `(event: Event, value: Value) => void` | `undefined` | Callback when value changes |
| `formatter` | `(value: Value) => Value` | `undefined` | Function to transform the input value (e.g. uppercase) |
| `validationStatus` | `"default" \| "error" \| "success"` | `"default"` | Validation state — changes border colour and label colour |
| `errorMessage` | `string` | `undefined` | Error text shown below the field when validationStatus is "error" |
| `disabled` | `boolean` | `false` | Disables the input |
| `required` | `boolean` | `false` | Marks the field as required |
| `hasDropdown` | `boolean` | `false` | Displays a chevron icon on the right; rotates on focus |
| `icon` | `IconicNames` | `undefined` | Additional icon displayed to the right of the input |
| `iconType` | `IconicTypes` | `undefined` | Icon type variant for the custom icon |
| `placeholder` | `string` | `undefined` | Placeholder text shown inside the input when the label is floating and field is empty |
| `className` | `string` | `undefined` | Additional CSS classes for the outer wrapper |
| `dataTestId` | `string` | `undefined` | Test ID for the field container |

## Notes

- **Floating label animation**: The label transitions between placeholder position (`text-b3`) and floated position (`text-b6 font-semibold`) using CSS transitions. It floats when the input is focused or has a non-empty value.
- **Error state**: In error state the floated label turns red and the border is red. The error message appears below with an icon.
- **Search & selection use case**: Designed for search inputs and dropdown triggers — pairs with `hasDropdown` for a select-like appearance.
- **Accessibility**: The `<label>` is linked to the input via `htmlFor`/`id`. Error messages are announced via `aria-describedby`. Invalid state is conveyed with `aria-invalid`.
- **Client component**: Uses React hooks (`useState`, `useRef`) and must be used in client-side contexts.
