New: Activity Terrain +12 more

Label

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

7 examplesDepends on@base-ui/reactcnFrom HextaUI (MIT)

Installation

Run this in your project. It adds the files, installs the npm packages and anything this builds on, and sets up the theme:

npx shadcn@latest add https://kryptonite-ui.vercel.app/r/label.json
Install manually
  1. Install the dependencies:

    npm install @base-ui/react cn
  2. Copy this into components/ui/label.tsx:

    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 }

Examples

Checkbox

Icon

Required and optional

Long content

Right to left

Follows its control

Feedback

Questions, bugs or ideas for Label? Post them here, or reply to someone else. Sign in with GitHub to comment.