# Label

> A label that follows its control, dimming when it's disabled and marking it required or optional on its own.

Docs: https://hextaui.com/docs/label
Markdown: https://hextaui.com/docs/label.md

```tsx title="components/examples/label/demo.tsx"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export function LabelDemo() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-6">
      <div className="flex flex-col gap-2">
        <Label htmlFor="label-demo-email" indicator="required">
          Email
        </Label>
        <Input
          id="label-demo-email"
          type="email"
          autoComplete="email"
          required
        />
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="label-demo-team" indicator="required">
          Team
        </Label>
        <Input id="label-demo-team" defaultValue="Design systems" disabled />
      </div>
      <Label>
        <Checkbox defaultChecked />
        Send me product updates
      </Label>
    </div>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/label.json
```

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @base-ui/react cn
```

Copy and paste the following code into your project.

```tsx title="components/ui/label.tsx"
"use client"

import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "cn"

type LabelIndicator = "required" | "optional"

type ControlState = {
  disabled: boolean
  required: boolean
  invalid: boolean
  readOnly: boolean
}

const idleState: ControlState = {
  disabled: false,
  required: false,
  invalid: false,
  readOnly: false,
}

const controlSelector =
  'input:not([type="hidden"]), textarea, select, button, meter, output, progress, [role="checkbox"], [role="switch"], [role="radio"], [role="combobox"], [role="slider"]'

function findControl(label: HTMLLabelElement) {
  if (label.htmlFor) {
    return document.getElementById(label.htmlFor)
  }
  return label.querySelector<HTMLElement>(controlSelector)
}

function readState(control: HTMLElement | null): ControlState {
  if (!control) {
    return idleState
  }
  return {
    disabled:
      control.matches(":disabled") ||
      control.hasAttribute("data-disabled") ||
      control.getAttribute("aria-disabled") === "true",
    required:
      control.matches(":required") ||
      control.getAttribute("aria-required") === "true" ||
      control.hasAttribute("data-required"),
    invalid:
      control.getAttribute("aria-invalid") === "true" ||
      control.hasAttribute("data-invalid") ||
      control.matches(":user-invalid"),
    readOnly:
      control.matches(":read-only:is(input, textarea)") ||
      control.getAttribute("aria-readonly") === "true" ||
      control.hasAttribute("data-readonly"),
  }
}

function sameState(a: ControlState, b: ControlState) {
  return (
    a.disabled === b.disabled &&
    a.required === b.required &&
    a.invalid === b.invalid &&
    a.readOnly === b.readOnly
  )
}

function useControlState(
  labelRef: React.RefObject<HTMLLabelElement | null>,
  htmlFor: string | undefined
) {
  const [state, setState] = React.useState(idleState)

  React.useEffect(() => {
    const label = labelRef.current
    if (!label) {
      return
    }

    let control: HTMLElement | null = null
    let frame = 0
    const attributes = new MutationObserver(() => sync())
    const children = new MutationObserver(() => connect())

    const sync = () => {
      const next = readState(control)
      setState((previous) => (sameState(previous, next) ? previous : next))
    }

    const connect = () => {
      const found = findControl(label)
      if (found === control) {
        return
      }
      control = found
      attributes.disconnect()
      if (control) {
        attributes.observe(control, {
          attributes: true,
          attributeFilter: [
            "disabled",
            "required",
            "readonly",
            "aria-disabled",
            "aria-required",
            "aria-invalid",
            "aria-readonly",
            "data-disabled",
            "data-required",
            "data-invalid",
            "data-readonly",
          ],
        })
      }
      sync()
    }

    const onFieldEvent = (event: Event) => {
      if (control && event.target === control) {
        sync()
      }
    }

    connect()
    if (!control) {
      frame = requestAnimationFrame(connect)
    }
    if (!htmlFor) {
      children.observe(label, { childList: true, subtree: true })
    }
    window.addEventListener("input", onFieldEvent)
    window.addEventListener("change", onFieldEvent)
    window.addEventListener("focusout", onFieldEvent)
    window.addEventListener("invalid", onFieldEvent, true)

    return () => {
      cancelAnimationFrame(frame)
      attributes.disconnect()
      children.disconnect()
      window.removeEventListener("input", onFieldEvent)
      window.removeEventListener("change", onFieldEvent)
      window.removeEventListener("focusout", onFieldEvent)
      window.removeEventListener("invalid", onFieldEvent, true)
    }
  }, [labelRef, htmlFor])

  return state
}

type LabelProps = useRender.ComponentProps<"label"> & {
  indicator?: LabelIndicator
  optionalText?: React.ReactNode
}

function Label({
  className,
  indicator,
  optionalText = "Optional",
  render,
  children,
  htmlFor,
  onMouseDown,
  ref,
  ...props
}: LabelProps) {
  const labelRef = React.useRef<HTMLLabelElement | null>(null)
  const state = useControlState(labelRef, htmlFor)
  const setRef = React.useCallback(
    (node: HTMLLabelElement | null) => {
      labelRef.current = node
      if (typeof ref === "function") {
        return ref(node)
      }
      if (ref) {
        ref.current = node
      }
      return undefined
    },
    [ref]
  )

  const mark =
    indicator === "required" && state.required ? (
      <span
        aria-hidden="true"
        data-slot="label-indicator"
        data-indicator="required"
        className="-ms-1.5 shrink-0 self-start font-normal whitespace-nowrap text-muted-foreground"
      >
        *
      </span>
    ) : indicator === "optional" && !state.required ? (
      <span
        aria-hidden="true"
        data-slot="label-indicator"
        data-indicator="optional"
        className="shrink-0 self-start font-normal whitespace-nowrap text-muted-foreground"
      >
        {optionalText}
      </span>
    ) : null

  return useRender({
    defaultTagName: "label",
    render,
    ref: setRef,
    props: mergeProps<"label">(
      {
        htmlFor,
        className: cn(
          "inline-flex w-fit max-w-full min-w-0 items-center gap-2 text-sm leading-snug font-medium text-pretty wrap-anywhere text-foreground select-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-muted-foreground",
          className
        ),
        onMouseDown: (event) => {
          onMouseDown?.(event)
          const target = event.target as Element
          if (
            !event.defaultPrevented &&
            event.detail > 1 &&
            !target.closest(controlSelector)
          ) {
            event.preventDefault()
          }
        },
        children: (
          <>
            {children}
            {mark}
          </>
        ),
      },
      props,
      {
        "data-slot": "label",
        ...(state.disabled ? { "data-disabled": "" } : {}),
        ...(state.required ? { "data-required": "" } : {}),
        ...(state.invalid ? { "data-invalid": "" } : {}),
        ...(state.readOnly ? { "data-readonly": "" } : {}),
      } as React.ComponentProps<"label">
    ),
  })
}

export { Label }
export type { LabelIndicator, LabelProps }
```

Update the import paths to match your project setup.

## Usage

```tsx
import { Label } from "@/components/ui/label"
```

```tsx
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" />
```

Point `htmlFor` at a control's `id`, or wrap the control. Either way the label finds it and follows its state. Inside a `<Field />`, use `<FieldLabel />`, which wires up the id for you.

## Examples

### Follows its control

The label dims and shows a not-allowed cursor while its control is disabled, and exposes `data-required`, `data-invalid` and `data-readonly` for your own styles. It keeps up when the control changes.

```tsx title="components/examples/label/states.tsx"
"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export function LabelStates() {
  const [locked, setLocked] = React.useState(true)

  return (
    <div className="flex w-full max-w-sm flex-col gap-3">
      <div className="flex flex-col gap-2">
        <Label htmlFor="label-states-domain">Custom domain</Label>
        <Input
          id="label-states-domain"
          defaultValue="docs.example.com"
          disabled={locked}
        />
      </div>
      <Button
        variant="outline"
        size="sm"
        className="self-start"
        onClick={() => setLocked(!locked)}
      >
        {locked ? "Unlock" : "Lock"}
      </Button>
    </div>
  )
}
```

### Required and optional

`indicator="optional"` tags controls without `required`, and `indicator="required"` adds an asterisk to controls with it. The mark is hidden from screen readers, which already announce required fields.

```tsx title="components/examples/label/indicator.tsx"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export function LabelIndicator() {
  return (
    <div className="grid w-full max-w-sm gap-6">
      <div className="flex flex-col gap-2">
        <Label htmlFor="label-indicator-name" indicator="optional">
          Full name
        </Label>
        <Input id="label-indicator-name" autoComplete="name" required />
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="label-indicator-company" indicator="optional">
          Company
        </Label>
        <Input id="label-indicator-company" autoComplete="organization" />
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="label-indicator-card" indicator="required">
          Card number
        </Label>
        <Input id="label-indicator-card" inputMode="numeric" required />
      </div>
    </div>
  )
}
```

### Checkbox

Wrap a checkbox so the whole label toggles it, or place the label beside it with htmlFor. A disabled checkbox dims its label either way.

```tsx title="components/examples/label/checkbox.tsx"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"

export function LabelCheckbox() {
  return (
    <div className="flex flex-col gap-4">
      <Label>
        <Checkbox defaultChecked />
        Email me when someone replies
      </Label>
      <Label>
        <Checkbox disabled />
        Weekly digest (coming soon)
      </Label>
      <div className="flex items-center gap-2">
        <Checkbox id="label-checkbox-terms" />
        <Label htmlFor="label-checkbox-terms">Accept the terms</Label>
      </div>
    </div>
  )
}
```

### Icon

Icons inside a label are sized and muted to sit next to the text.

```tsx title="components/examples/label/icon.tsx"
import { IconLock } from "@tabler/icons-react"

import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export function LabelIcon() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-2">
      <Label htmlFor="label-icon-key">
        <IconLock aria-hidden="true" />
        API key
      </Label>
      <Input id="label-icon-key" defaultValue="sk_live_51H…" readOnly />
    </div>
  )
}
```

### Long content

Long labels wrap, and unbroken strings break instead of widening the layout.

```tsx title="components/examples/label/long-content.tsx"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export function LabelLongContent() {
  return (
    <div className="flex w-64 max-w-full flex-col gap-6">
      <div className="flex flex-col gap-2">
        <Label htmlFor="label-long" indicator="optional">
          Where should we send invoices for the international subsidiary
        </Label>
        <Input id="label-long" />
      </div>
      <Label>
        <Checkbox />
        billing-notifications@an-extremely-long-company-domain.example.com
      </Label>
    </div>
  )
}
```

### Right to left

Gaps and the indicator follow the reading direction.

```tsx title="components/examples/label/rtl.tsx"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

export function LabelRtl() {
  return (
    <div dir="rtl" className="flex w-full max-w-sm flex-col gap-6">
      <div className="flex flex-col gap-2">
        <Label
          htmlFor="label-rtl-name"
          indicator="optional"
          optionalText="اختياري"
        >
          الاسم
        </Label>
        <Input id="label-rtl-name" />
      </div>
      <Label>
        <Checkbox defaultChecked />
        تذكرني
      </Label>
    </div>
  )
}
```

## Accessibility

- Clicking the label focuses or toggles its control, so it's a bigger target than the control alone.
- Double-clicking the label text doesn't select it. Double-clicks on a control inside the label work as usual.
- Every control needs a name. When there's no visible label, use `aria-label` on the control instead.

## API reference

Renders a `<label>` and accepts its attributes.

### Label

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `htmlFor` | `string` | – | The id of the control. Leave it out when the label wraps the control. |
| `indicator` | `"required" \| "optional"` | – | Mark the label from the control's required state. Off by default. |
| `optionalText` | `ReactNode` | `"Optional"` |  |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<label>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="label"` | Target labels in CSS. |
| `data-disabled` | Present while the control is disabled. |
| `data-required` | Present while the control is required. |
| `data-invalid` | Present while the control is invalid, after the user has interacted or when aria-invalid is set. |
| `data-readonly` | Present while the control is read-only. |
| `data-slot="label-indicator"` | The required or optional mark. |

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
