# Input group

> An input with icons, text, buttons or a keyboard hint attached, sharing one border and focus ring.

Docs: https://hextaui.com/docs/input-group
Markdown: https://hextaui.com/docs/input-group.md

```tsx title="components/examples/input-group/demo.tsx"
"use client"

import * as React from "react"
import { IconArrowUp, IconLock, IconSearch } from "@tabler/icons-react"

import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupClear,
  InputGroupCount,
  InputGroupInput,
  InputGroupPasswordToggle,
  InputGroupTextarea,
} from "@/components/ui/input-group"

export function InputGroupDemo() {
  const [message, setMessage] = React.useState("")

  return (
    <div className="flex w-full max-w-sm flex-col gap-4">
      <InputGroup>
        <InputGroupInput
          type="search"
          defaultValue="Accordion"
          placeholder="Search components"
          aria-label="Search components"
        />
        <InputGroupAddon>
          <IconSearch />
        </InputGroupAddon>
        <InputGroupAddon align="inline-end">
          <InputGroupClear />
        </InputGroupAddon>
      </InputGroup>
      <InputGroup>
        <InputGroupInput
          type="password"
          defaultValue="hunter2-but-longer"
          autoComplete="current-password"
          aria-label="Password"
        />
        <InputGroupAddon>
          <IconLock />
        </InputGroupAddon>
        <InputGroupAddon align="inline-end">
          <InputGroupPasswordToggle />
        </InputGroupAddon>
      </InputGroup>
      <InputGroup>
        <InputGroupTextarea
          value={message}
          onChange={(event) => setMessage(event.target.value)}
          maxLength={140}
          placeholder="Leave a note for the team"
          aria-label="Note"
        />
        <InputGroupAddon align="block-end">
          <InputGroupCount />
          <InputGroupButton
            variant="default"
            size="icon-xs"
            aria-label="Post note"
            disabled={!message.trim()}
            className="ms-auto"
          >
            <IconArrowUp />
          </InputGroupButton>
        </InputGroupAddon>
      </InputGroup>
    </div>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/input-group.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 @tabler/icons-react class-variance-authority cn
```

Copy and paste the following code into your project.

```tsx title="components/ui/input-group.tsx"
"use client"

import * as React from "react"
import { flushSync } from "react-dom"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { IconEye, IconEyeOff, IconX } from "@tabler/icons-react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

import { easeOut, prefersReducedMotion, useSizeMorph } from "@/lib/motion"

import { Button } from "@/components/ui/button"
import { useAutosize } from "@/hooks/use-autosize"
import { useComposedRef } from "@/hooks/use-composed-ref"
import { useInvalidShake } from "@/hooks/use-invalid-shake"
import { useMergedRef } from "@/hooks/use-merged-ref"
import {
  Input,
  InputCount,
  type InputControl,
  type InputCountProps,
} from "@/components/ui/input"

type InputGroupSize = "sm" | "default" | "lg"

type InputGroupContextValue = {
  size: InputGroupSize
  controlRef: React.RefObject<InputControl | null>
  value: string
  maxLength: number | null
  disabled: boolean
  readOnly: boolean
  revealed: boolean
  setRevealed: (revealed: boolean) => void
  sync: () => void
  clearRef: React.RefObject<(() => void) | null>
}

const InputGroupContext = React.createContext<InputGroupContextValue | null>(
  null
)

function useInputGroup(part: string) {
  const context = React.useContext(InputGroupContext)
  if (!context) {
    throw new Error(`<${part}> must be used within <InputGroup>.`)
  }
  return context
}

function noop() {}

function useControlContext() {
  const context = React.useContext(InputGroupContext)
  const controlRef = React.useRef<InputControl | null>(null)
  const clearRef = React.useRef<(() => void) | null>(null)
  const fallback = React.useMemo<InputGroupContextValue>(
    () => ({
      size: "default",
      controlRef,
      clearRef,
      value: "",
      maxLength: null,
      disabled: false,
      readOnly: false,
      revealed: false,
      setRevealed: noop,
      sync: noop,
    }),
    []
  )
  return context ?? fallback
}

type ControlSnapshot = Pick<
  InputGroupContextValue,
  "value" | "maxLength" | "disabled" | "readOnly"
>

const emptySnapshot: ControlSnapshot = {
  value: "",
  maxLength: null,
  disabled: false,
  readOnly: false,
}

function readControl(control: InputControl | null): ControlSnapshot {
  if (!control) {
    return emptySnapshot
  }
  return {
    value: control.value,
    maxLength: control.maxLength > 0 ? control.maxLength : null,
    disabled: control.disabled,
    readOnly: control.readOnly,
  }
}

function sameSnapshot(a: ControlSnapshot, b: ControlSnapshot) {
  return (
    a.value === b.value &&
    a.maxLength === b.maxLength &&
    a.disabled === b.disabled &&
    a.readOnly === b.readOnly
  )
}

const control = "[data-slot=input-group-control]"

const inputGroupVariants = cva(
  "group/input-group relative flex w-full min-w-0 items-center rounded-(--input-group-radius) bg-transparent text-sm inset-ring-(length:--hairline) inset-ring-input transition-[color,background-color,box-shadow] duration-150 ease-out-cubic outline-none [--input-group-radius:var(--radius-md)] focus-visible:outline-hidden has-[[data-slot=input-group-control]:disabled]:cursor-not-allowed has-[[data-slot=input-group-control]:disabled]:opacity-50 has-[[data-slot=input-group-control]:focus]:ring-3 has-[[data-slot=input-group-control]:focus]:ring-focus-ring has-[[data-slot=input-group-control]:focus]:inset-ring-ring has-[[data-slot=input-group-control]:user-invalid]:inset-ring-destructive has-[[data-slot=input-group-control]:user-invalid:focus]:ring-destructive/20 has-[[data-slot=input-group-control][aria-invalid=true]]:inset-ring-destructive has-[[data-slot=input-group-control][aria-invalid=true]:focus]:ring-destructive/20 has-[[data-slot=input-group-control][data-invalid]]:inset-ring-destructive has-[[data-slot=input-group-control][data-invalid]:focus]:ring-destructive/20 has-[[data-slot=input-group-control][readonly]]:bg-muted/40 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:items-stretch has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:items-stretch has-[>textarea]:h-auto has-[>textarea]:items-stretch data-shake:motion-safe:animate-button-shake motion-reduce:transition-none dark:bg-input/30 dark:has-[[data-slot=input-group-control]:user-invalid:focus]:ring-destructive/40 dark:has-[[data-slot=input-group-control][aria-invalid=true]:focus]:ring-destructive/40 dark:has-[[data-slot=input-group-control][data-invalid]:focus]:ring-destructive/40 dark:has-[[data-slot=input-group-control][readonly]]:bg-input/20 forced-colors:border data-disabled:cursor-not-allowed data-disabled:opacity-50 [@media(hover:hover)]:hover:not-has-[[data-slot=input-group-control]:focus]:not-has-[[data-slot=input-group-control][aria-invalid=true]]:not-has-[[data-slot=input-group-control][data-invalid]]:not-has-[[data-slot=input-group-control]:user-invalid]:not-has-[[data-slot=input-group-control]:disabled]:not-has-[[data-slot=input-group-control][readonly]]:not-data-disabled:inset-ring-ring/70",
  {
    variants: {
      size: {
        sm: "h-(--input-group-height) [--input-group-height:calc(var(--spacing)*8)]",
        default:
          "h-(--input-group-height) [--input-group-height:calc(var(--spacing)*9)]",
        lg: "h-(--input-group-height) [--input-group-height:calc(var(--spacing)*10)]",
      },
    },
    defaultVariants: {
      size: "default",
    },
  }
)

type InputGroupProps = React.ComponentProps<"div"> &
  VariantProps<typeof inputGroupVariants>

function InputGroup({
  className,
  size = "default",
  ...props
}: InputGroupProps) {
  const resolvedSize = size ?? "default"
  const controlRef = React.useRef<InputControl | null>(null)
  const clearRef = React.useRef<(() => void) | null>(null)
  const [snapshot, setSnapshot] = React.useState(emptySnapshot)
  const [revealed, setRevealed] = React.useState(false)

  const sync = React.useCallback(() => {
    const next = readControl(controlRef.current)
    setSnapshot((previous) => (sameSnapshot(previous, next) ? previous : next))
  }, [])

  const context = React.useMemo<InputGroupContextValue>(
    () => ({
      size: resolvedSize,
      controlRef,
      ...snapshot,
      revealed,
      setRevealed,
      sync,
      clearRef,
    }),
    [resolvedSize, snapshot, revealed, sync]
  )

  return (
    <InputGroupContext.Provider value={context}>
      <div
        role="group"
        data-slot="input-group"
        data-size={resolvedSize}
        data-filled={snapshot.value ? "" : undefined}
        className={cn(inputGroupVariants({ size: resolvedSize }), className)}
        {...props}
      />
    </InputGroupContext.Provider>
  )
}

function useControlSync(
  context: InputGroupContextValue,
  value: unknown,
  extra: { disabled?: boolean; readOnly?: boolean; maxLength?: number }
) {
  const { controlRef, sync, setRevealed } = context

  React.useLayoutEffect(() => {
    sync()
  }, [sync, value, extra.disabled, extra.readOnly, extra.maxLength])

  React.useEffect(() => {
    const element = controlRef.current
    if (!element) {
      return
    }
    const form = element.form
    let timer: ReturnType<typeof setTimeout> | undefined

    const onReset = () => {
      clearTimeout(timer)
      timer = setTimeout(sync)
    }
    const onSubmit = () => {
      flushSync(() => setRevealed(false))
    }

    const onInput = (event: Event) => {
      if (event.target === element) {
        sync()
      }
    }

    window.addEventListener("input", onInput)
    form?.addEventListener("reset", onReset)
    form?.addEventListener("submit", onSubmit)

    return () => {
      window.removeEventListener("input", onInput)
      form?.removeEventListener("reset", onReset)
      form?.removeEventListener("submit", onSubmit)
      clearTimeout(timer)
    }
  }, [controlRef, sync, setRevealed])
}

function handleClearKey(
  event: React.KeyboardEvent<InputControl>,
  context: InputGroupContextValue
) {
  if (
    event.key !== "Escape" ||
    event.defaultPrevented ||
    event.nativeEvent.isComposing ||
    !context.clearRef.current ||
    !event.currentTarget.value ||
    event.currentTarget.readOnly
  ) {
    return
  }
  event.preventDefault()
  event.stopPropagation()
  context.clearRef.current()
}

const buttonSizes =
  "has-[>[data-addon-button][data-size=xs]]:[--input-group-button-size:calc(var(--spacing)*6)] has-[>[data-addon-button][data-size=icon-xs]]:[--input-group-button-size:calc(var(--spacing)*6)] has-[>[data-addon-button][data-size=sm]]:[--input-group-button-size:calc(var(--spacing)*8)] has-[>[data-addon-button][data-size=icon-sm]]:[--input-group-button-size:calc(var(--spacing)*8)] has-[>kbd]:[--input-group-button-size:calc(var(--spacing)*5)]"

const insetAddon =
  "has-[>[data-addon-button]]:ps-(--input-group-addon-inset) has-[>kbd]:ps-(--input-group-addon-inset)"

const insetBlockEdges =
  "has-[>[data-addon-button]:first-child]:ps-(--input-group-addon-inset) has-[>[data-addon-button]:last-child]:pe-(--input-group-addon-inset)"

const inputGroupAddonVariants = cva(
  cn(
    "flex h-auto min-w-0 cursor-text items-center gap-1.5 text-sm text-muted-foreground select-none [--input-group-addon-inset:max(calc(var(--spacing)*0.5),calc((var(--input-group-height)-var(--input-group-button-size,calc(var(--spacing)*6)))/2))] group-has-[[data-slot=input-group-control]:disabled]/input-group:cursor-not-allowed group-data-disabled/input-group:cursor-not-allowed data-morphing:overflow-clip [&>kbd]:inline-flex [&>kbd]:h-5 [&>kbd]:min-w-5 [&>kbd]:items-center [&>kbd]:justify-center [&>kbd]:rounded-[max(calc(var(--radius-sm)*0.5),calc(var(--input-group-radius)-var(--input-group-addon-inset)))] [&>kbd]:bg-muted [&>kbd]:px-1 [&>kbd]:font-sans [&>kbd]:text-xs [&>kbd]:text-muted-foreground [&>kbd]:inset-ring-(length:--hairline) [&>kbd]:inset-ring-border [&>svg]:pointer-events-none [&>svg]:shrink-0 [&>svg:not([class*='size-'])]:size-4",
    buttonSizes
  ),
  {
    variants: {
      align: {
        "inline-start": cn(
          "order-first shrink-0 self-stretch ps-3",
          insetAddon
        ),
        "inline-end":
          "order-last shrink-0 self-stretch pe-3 has-[>[data-addon-button]]:pe-(--input-group-addon-inset) has-[>kbd]:pe-(--input-group-addon-inset)",
        "block-start": cn(
          "order-first w-full justify-start px-3 pt-2.5 [--input-group-addon-inset:max(calc(var(--spacing)*1.5),calc((var(--input-group-height)-var(--input-group-button-size,calc(var(--spacing)*6)))/2))] has-[>[data-addon-button]]:pt-(--input-group-addon-inset) data-separator:pb-2.5 data-separator:shadow-[inset_0_calc(-1*var(--hairline))_0_var(--color-border)] has-[>[data-addon-button]]:data-separator:pb-(--input-group-addon-inset)",
          insetBlockEdges
        ),
        "block-end": cn(
          "order-last w-full justify-start px-3 pb-2.5 [--input-group-addon-inset:max(calc(var(--spacing)*1.5),calc((var(--input-group-height)-var(--input-group-button-size,calc(var(--spacing)*6)))/2))] has-[>[data-addon-button]]:pb-(--input-group-addon-inset) data-separator:pt-2.5 data-separator:shadow-[inset_0_var(--hairline)_0_var(--color-border)] has-[>[data-addon-button]]:data-separator:pt-(--input-group-addon-inset)",
          insetBlockEdges
        ),
      },
    },
    defaultVariants: {
      align: "inline-start",
    },
  }
)

type InputGroupAddonProps = React.ComponentProps<"div"> &
  VariantProps<typeof inputGroupAddonVariants> & {
    separator?: boolean
  }

function focusControl(event: React.MouseEvent<HTMLDivElement>) {
  const target = event.target as HTMLElement
  if (target.closest("button, a, input, textarea, select, [tabindex]")) {
    return
  }
  const group = event.currentTarget.closest("[data-slot=input-group]")
  const field = group?.querySelector<HTMLElement>(control)
  if (field && !field.matches(":disabled")) {
    event.preventDefault()
    field.focus()
  }
}

function InputGroupAddon({
  className,
  align = "inline-start",
  separator = false,
  onMouseDown,
  ref,
  ...props
}: InputGroupAddonProps) {
  const resolvedAlign = align ?? "inline-start"
  const morphRef = useSizeMorph<HTMLDivElement>({
    axis: "width",
    enabled: resolvedAlign.startsWith("inline"),
    duration: 220,
  })
  const setRef = useMergedRef(ref, morphRef)

  return (
    <div
      ref={setRef}
      role="group"
      data-slot="input-group-addon"
      data-align={resolvedAlign}
      data-separator={separator ? "" : undefined}
      className={cn(inputGroupAddonVariants({ align }), className)}
      onMouseDown={(event) => {
        onMouseDown?.(event)
        if (!event.defaultPrevented) {
          focusControl(event)
        }
      }}
      {...props}
    />
  )
}

const inputGroupButtonVariants = cva(
  "rounded-[max(calc(var(--radius-sm)*0.5),calc(var(--input-group-radius)-var(--input-group-addon-inset)))]"
)

type InputGroupButtonProps = Omit<
  React.ComponentProps<typeof Button>,
  "size"
> & {
  size?: "xs" | "sm" | "icon-xs" | "icon-sm"
}

function InputGroupButton({
  className,
  type = "button",
  variant = "ghost",
  size = "xs",
  ...props
}: InputGroupButtonProps) {
  return (
    <Button
      type={type}
      variant={variant}
      size={size}
      data-slot="input-group-button"
      data-addon-button=""
      data-size={size}
      className={cn(inputGroupButtonVariants(), className)}
      {...props}
    />
  )
}

function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
  return (
    <span
      data-slot="input-group-text"
      className={cn(
        "flex min-w-0 items-center gap-1.5 overflow-hidden text-sm whitespace-nowrap text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    />
  )
}

function InputGroupInput({
  className,
  type,
  ref,
  onKeyDown,
  ...props
}: React.ComponentProps<typeof Input>) {
  const context = useControlContext()
  const [, setRef] = useComposedRef<HTMLInputElement>(ref)
  const setControl = React.useCallback(
    (node: HTMLInputElement | null) => {
      context.controlRef.current = node
      setRef(node)
    },
    [context.controlRef, setRef]
  )
  useControlSync(context, props.value, {
    disabled: props.disabled,
    readOnly: props.readOnly,
    maxLength: props.maxLength,
  })

  return (
    <Input
      ref={setControl}
      data-slot="input-group-control"
      size={context.size}
      type={type === "password" && context.revealed ? "text" : type}
      onKeyDown={(event) => {
        onKeyDown?.(event)
        handleClearKey(event, context)
      }}
      className={cn(
        "h-auto min-h-(--input-group-height) min-w-16 flex-1 self-stretch rounded-none bg-transparent! inset-ring-0 group-has-[>[data-align=inline-end]]/input-group:pe-2 group-has-[>[data-align=inline-start]]/input-group:ps-2 focus-visible:ring-0 disabled:opacity-100 group-has-[[data-slot=input-group-clear]]/input-group:[&::-webkit-search-cancel-button]:appearance-none",
        className
      )}
      {...props}
    />
  )
}

type InputGroupTextareaProps = React.ComponentProps<"textarea"> & {
  autoResize?: boolean
  shake?: boolean
}

function InputGroupTextarea({
  className,
  autoResize = true,
  shake = true,
  ref,
  onKeyDown,
  ...props
}: InputGroupTextareaProps) {
  const context = useControlContext()
  const [textareaRef, setRef] = useComposedRef<HTMLTextAreaElement>(ref)
  const setControl = React.useCallback(
    (node: HTMLTextAreaElement | null) => {
      context.controlRef.current = node
      setRef(node)
    },
    [context.controlRef, setRef]
  )
  useAutosize(textareaRef, autoResize, props.value)
  useInvalidShake(textareaRef, shake)
  useControlSync(context, props.value, {
    disabled: props.disabled,
    readOnly: props.readOnly,
    maxLength: props.maxLength,
  })

  return (
    <InputPrimitive
      data-slot="input-group-control"
      render={<textarea ref={setControl} />}
      onKeyDown={(event) => {
        const keyEvent =
          event as unknown as React.KeyboardEvent<HTMLTextAreaElement>
        onKeyDown?.(keyEvent)
        handleClearKey(keyEvent, context)
      }}
      className={cn(
        "max-h-64 min-h-16 w-full min-w-0 flex-1 resize-none overscroll-none rounded-none bg-transparent px-3 py-2.5 text-sm text-foreground outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:outline-hidden disabled:cursor-not-allowed pointer-coarse:text-[max(16px,1rem)]",
        className
      )}
      {...(props as InputPrimitive.Props)}
    />
  )
}

function setControlValue(element: InputControl, value: string) {
  element.focus()
  element.select()
  let done = false
  try {
    done =
      typeof document.execCommand === "function" &&
      document.execCommand(value ? "insertText" : "delete", false, value)
  } catch {
    done = false
  }
  if (done && element.value === value) {
    return
  }
  const prototype =
    element instanceof HTMLTextAreaElement
      ? HTMLTextAreaElement.prototype
      : HTMLInputElement.prototype
  Object.getOwnPropertyDescriptor(prototype, "value")?.set?.call(element, value)
  element.dispatchEvent(new Event("input", { bubbles: true }))
}

const reveal =
  "transition-[opacity,scale,filter] duration-200 ease-out-quint motion-reduce:transition-none"

type InputGroupClearProps = Omit<InputGroupButtonProps, "size" | "children"> & {
  onClear?: () => void
  children?: React.ReactNode
}

function InputGroupClear({
  className,
  onClear,
  onClick,
  onMouseDown,
  children,
  "aria-label": ariaLabel = "Clear",
  ...props
}: InputGroupClearProps) {
  const context = useInputGroup("InputGroupClear")
  const { controlRef, clearRef } = context
  const visible = Boolean(
    context.value && !context.disabled && !context.readOnly
  )

  const clear = React.useCallback(() => {
    const element = controlRef.current
    if (!element) {
      return
    }
    setControlValue(element, "")
    onClear?.()
  }, [controlRef, onClear])

  React.useLayoutEffect(() => {
    clearRef.current = clear
    return () => {
      if (clearRef.current === clear) {
        clearRef.current = null
      }
    }
  }, [clearRef, clear])

  return (
    <InputGroupButton
      size="icon-xs"
      tabIndex={-1}
      aria-label={ariaLabel}
      aria-hidden={visible ? undefined : true}
      data-slot="input-group-clear"
      data-visible={visible ? "" : undefined}
      className={cn(
        reveal,
        "text-muted-foreground not-data-visible:pointer-events-none not-data-visible:scale-50 not-data-visible:opacity-0 not-data-visible:blur-[2px] hover:text-foreground",
        className
      )}
      onMouseDown={(event) => {
        onMouseDown?.(event)
        if (!event.defaultPrevented) {
          event.preventDefault()
        }
      }}
      onClick={(event) => {
        onClick?.(event)
        if (!event.defaultPrevented) {
          clear()
        }
      }}
      {...props}
    >
      {children ?? <IconX />}
    </InputGroupButton>
  )
}

type InputGroupPasswordToggleProps = Omit<
  InputGroupButtonProps,
  "size" | "children"
> & {
  revealed?: boolean
  onRevealedChange?: (revealed: boolean) => void
}

function InputGroupPasswordToggle({
  className,
  revealed: revealedProp,
  onRevealedChange,
  onClick,
  onMouseDown,
  "aria-label": ariaLabel = "Show password",
  ...props
}: InputGroupPasswordToggleProps) {
  const context = useInputGroup("InputGroupPasswordToggle")
  const { controlRef, setRevealed } = context
  const revealed = revealedProp ?? context.revealed

  React.useLayoutEffect(() => {
    if (revealedProp !== undefined) {
      setRevealed(revealedProp)
    }
  }, [revealedProp, setRevealed])

  React.useEffect(() => () => setRevealed(false), [setRevealed])

  return (
    <InputGroupButton
      size="icon-xs"
      aria-label={ariaLabel}
      aria-pressed={revealed}
      disabled={context.disabled}
      data-slot="input-group-password-toggle"
      data-revealed={revealed ? "" : undefined}
      className={cn("grid *:col-start-1 *:row-start-1", className)}
      onMouseDown={(event) => {
        onMouseDown?.(event)
        if (
          !event.defaultPrevented &&
          controlRef.current === document.activeElement
        ) {
          event.preventDefault()
        }
      }}
      onClick={(event) => {
        onClick?.(event)
        if (event.defaultPrevented) {
          return
        }
        const element = controlRef.current
        const start = element?.selectionStart ?? null
        const end = element?.selectionEnd ?? null
        const next = !revealed
        if (revealedProp === undefined) {
          flushSync(() => setRevealed(next))
        }
        onRevealedChange?.(next)
        if (element && start !== null && end !== null) {
          try {
            element.setSelectionRange(start, end)
          } catch {}
        }
      }}
      {...props}
    >
      <IconEye
        aria-hidden="true"
        className={cn(reveal, revealed && "scale-50 opacity-0 blur-[2px]")}
      />
      <IconEyeOff
        aria-hidden="true"
        className={cn(reveal, !revealed && "scale-50 opacity-0 blur-[2px]")}
      />
    </InputGroupButton>
  )
}

type InputGroupCountProps = Omit<
  InputCountProps,
  "length" | "maxLength" | "controlRef"
>

function InputGroupCount(props: InputGroupCountProps) {
  const context = useInputGroup("InputGroupCount")

  return (
    <InputCount
      data-slot="input-group-count"
      length={context.value.length}
      maxLength={context.maxLength}
      controlRef={context.controlRef}
      {...props}
    />
  )
}

export {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupClear,
  InputGroupCount,
  InputGroupInput,
  InputGroupPasswordToggle,
  InputGroupText,
  InputGroupTextarea,
  inputGroupAddonVariants,
  inputGroupButtonVariants,
  inputGroupVariants,
  useInputGroup,
}
export type {
  InputGroupAddonProps,
  InputGroupButtonProps,
  InputGroupClearProps,
  InputGroupCountProps,
  InputGroupPasswordToggleProps,
  InputGroupProps,
  InputGroupTextareaProps,
}
```

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

import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

import { useAutosize } from "@/hooks/use-autosize"
import { useComposedRef } from "@/hooks/use-composed-ref"
import { useInvalidShake } from "@/hooks/use-invalid-shake"
import { useMergedRef } from "@/hooks/use-merged-ref"
import { prefersReducedMotion } from "@/lib/motion"

import { NumberFlow } from "@/components/ui/number-flow"

type ClassName<State> =
  string | ((state: State) => string | undefined) | undefined

function mergeClassName<State>(base: string, className: ClassName<State>) {
  return typeof className === "function"
    ? (state: State) => cn(base, className(state))
    : cn(base, className)
}

const inputVariants = cva(
  "w-full min-w-0 rounded-md bg-transparent text-sm text-foreground inset-ring-(length:--hairline) inset-ring-input transition-[color,background-color,box-shadow] duration-150 ease-out-cubic outline-none selection:bg-primary selection:text-primary-foreground file:me-3 file:inline-flex file:h-full file:items-center file:border-0 file:bg-transparent file:p-0 file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground placeholder:transition-opacity placeholder:duration-150 user-invalid:inset-ring-destructive focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden focus-visible:placeholder:opacity-70 user-invalid:focus-visible:ring-destructive/70 user-invalid:focus-visible:inset-ring-destructive disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:inset-ring-destructive aria-invalid:focus-visible:ring-destructive/70 aria-invalid:focus-visible:inset-ring-destructive data-invalid:inset-ring-destructive data-invalid:focus-visible:ring-destructive/70 data-invalid:focus-visible:inset-ring-destructive data-shake:motion-safe:animate-button-shake motion-reduce:transition-none dark:bg-input/30 dark:scheme-dark dark:user-invalid:focus-visible:ring-destructive/70 dark:aria-invalid:focus-visible:ring-destructive/70 dark:data-invalid:focus-visible:ring-destructive/70 forced-colors:border pointer-coarse:text-[max(16px,1rem)] [&::-webkit-search-cancel-button]:cursor-pointer [&::-webkit-search-cancel-button]:opacity-50 [&::-webkit-search-cancel-button]:transition-opacity [&::-webkit-search-cancel-button:hover]:opacity-100 [&[readonly]]:bg-muted/40 dark:[&[readonly]]:bg-input/20 [@media(hover:hover)]:hover:not-focus-visible:not-disabled:not-[[readonly]]:not-aria-invalid:not-data-invalid:not-user-invalid:inset-ring-ring/70",
  {
    variants: {
      size: {
        sm: "h-8 px-2.5 file:pe-2.5",
        default: "h-9 px-3",
        lg: "h-10 px-3.5",
      },
    },
    defaultVariants: {
      size: "default",
    },
  }
)

type InputControl = HTMLInputElement | HTMLTextAreaElement

type InputProps = Omit<InputPrimitive.Props, "size"> &
  VariantProps<typeof inputVariants> & {
    htmlSize?: number
    shake?: boolean
  }

function Input({
  className,
  size = "default",
  htmlSize,
  shake = true,
  ref,
  ...props
}: InputProps) {
  const resolvedSize = size ?? "default"
  const [inputRef, setRef] = useComposedRef<HTMLInputElement>(ref)
  useInvalidShake(inputRef, shake)

  return (
    <InputPrimitive
      ref={setRef}
      data-slot="input"
      data-size={resolvedSize}
      size={htmlSize}
      className={mergeClassName(
        inputVariants({ size: resolvedSize }),
        className
      )}
      {...props}
    />
  )
}

function defaultAnnouncement(remaining: number) {
  return remaining === 0
    ? "Character limit reached"
    : `${remaining} ${remaining === 1 ? "character" : "characters"} left`
}

type InputCountProps = Omit<React.ComponentProps<"span">, "children"> & {
  length: number
  maxLength?: number | null
  controlRef?: React.RefObject<InputControl | null>
  threshold?: number
  announcement?: (remaining: number) => string
}

function InputCount({
  className,
  length,
  maxLength = null,
  controlRef,
  threshold,
  announcement = defaultAnnouncement,
  ref,
  ...props
}: InputCountProps) {
  const max = maxLength !== null && maxLength > 0 ? maxLength : null
  const remaining = max === null ? null : Math.max(0, max - length)
  const near =
    max === null
      ? Infinity
      : Math.max(1, threshold ?? Math.ceil(Math.min(max * 0.1, 20)))
  const state =
    remaining === null
      ? undefined
      : remaining === 0
        ? "limit"
        : remaining <= near
          ? "near"
          : undefined

  const [message, setMessage] = React.useState("")
  const [lastState, setLastState] = React.useState(state)
  if (state !== lastState) {
    setLastState(state)
    setMessage(state && remaining !== null ? announcement(remaining) : "")
  }

  const [countRef, setCountRef] = useComposedRef<HTMLSpanElement>(ref)

  React.useEffect(() => {
    const element = controlRef?.current
    const count = countRef.current
    if (!element || !count || max === null) {
      return
    }
    let timer: ReturnType<typeof setTimeout> | undefined
    const onBeforeInput = (event: Event) => {
      const input = event as InputEvent
      if (
        !input.inputType?.startsWith("insert") ||
        element.value.length < max ||
        element.selectionStart !== element.selectionEnd ||
        prefersReducedMotion()
      ) {
        return
      }
      count.removeAttribute("data-bump")
      void count.offsetWidth
      count.setAttribute("data-bump", "")
      clearTimeout(timer)
      timer = setTimeout(() => count.removeAttribute("data-bump"), 400)
    }
    element.addEventListener("beforeinput", onBeforeInput)
    return () => {
      element.removeEventListener("beforeinput", onBeforeInput)
      clearTimeout(timer)
    }
  }, [controlRef, max])

  return (
    <span
      ref={setCountRef}
      data-slot="input-count"
      data-state={state}
      className={cn(
        "inline-flex shrink-0 items-baseline text-xs text-muted-foreground tabular-nums transition-colors duration-200 ease-out-cubic data-bump:animate-button-shake data-[state=limit]:text-destructive data-[state=near]:text-foreground",
        className
      )}
      {...props}
    >
      <span aria-hidden="true" dir="ltr" className="inline-flex items-baseline">
        <NumberFlow value={length} />
        {max !== null && <span>/{max}</span>}
      </span>
      <span role="status" className="sr-only">
        {message}
      </span>
    </span>
  )
}

export {
  Input,
  InputCount,
  useAutosize,
  inputVariants,
  useComposedRef,
  useInvalidShake,
  useMergedRef,
}
export type { InputControl, InputCountProps, InputProps }
```

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

import * as React from "react"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { IconAlertCircle, IconCircleCheck } from "@tabler/icons-react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

import { Spinner } from "@/components/ui/spinner"
import {
  useButtonFeedback,
  type ButtonFeedbackOptions,
  type ButtonStatus,
} from "@/hooks/use-button-feedback"

const buttonVariants = cva(
  "group/button relative inline-flex items-center justify-center rounded-md bg-clip-padding text-sm font-medium whitespace-nowrap inset-ring-(length:--hairline) inset-ring-transparent transition-[color,background-color,box-shadow,opacity,translate,scale] duration-200 ease-out-quint outline-none select-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden active:duration-100 active:not-aria-[haspopup]:not-aria-busy:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-busy:cursor-progress aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:inset-ring-destructive motion-safe:active:not-aria-[haspopup]:not-aria-busy:scale-[0.97] dark:aria-invalid:ring-destructive/40 dark:aria-invalid:inset-ring-destructive/50 forced-colors:border [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/80",
        outline:
          "bg-background inset-ring-border hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30 dark:inset-ring-input dark:hover:bg-input/50",
        secondary:
          "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
        ghost:
          "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50 forced-colors:border-0",
        destructive:
          "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/70",
        link: "text-primary underline-offset-4 hover:underline forced-colors:border-0",
        success:
          "bg-success text-success-foreground hover:bg-success/90 focus-visible:ring-success/70 focus-visible:inset-ring-success/40",
        error:
          "bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/70 focus-visible:inset-ring-destructive/40 motion-safe:animate-button-shake",
      },
      size: {
        default:
          "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
        xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
        sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5",
        lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2",
        icon: "size-9 shrink-0",
        "icon-xs":
          "size-6 shrink-0 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-2.5 pointer-coarse:in-data-[slot=button-group]:after:hidden [&_svg:not([class*='size-'])]:size-3",
        "icon-sm":
          "size-8 shrink-0 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md pointer-coarse:after:absolute pointer-coarse:after:-inset-1.5 pointer-coarse:in-data-[slot=button-group]:after:hidden",
        "icon-lg": "size-10 shrink-0",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

type ButtonVariant = Exclude<
  NonNullable<VariantProps<typeof buttonVariants>["variant"]>,
  "success" | "error"
>

type ButtonClickEvent = Parameters<
  NonNullable<ButtonPrimitive.Props["onClick"]>
>[0]

type ButtonProps = Omit<ButtonPrimitive.Props, "onClick"> &
  Omit<VariantProps<typeof buttonVariants>, "variant"> &
  ButtonFeedbackOptions & {
    variant?: ButtonVariant | null
    onClick?: (event: ButtonClickEvent) => unknown
    feedback?: boolean
    loading?: boolean
    status?: ButtonStatus
    loadingLabel?: React.ReactNode
    successLabel?: React.ReactNode
    errorLabel?: React.ReactNode | ((error: unknown) => React.ReactNode)
  }

const stateTransitionTime = 220
const statuses: ButtonStatus[] = ["idle", "loading", "success", "error"]
const quietVariants: ButtonVariant[] = ["ghost", "link"]
const quietStatusClasses: Partial<Record<ButtonStatus, string>> = {
  success: "text-success hover:text-success",
  error:
    "text-destructive hover:text-destructive motion-safe:animate-button-shake",
}

function isThenable(value: unknown): value is PromiseLike<unknown> {
  return (
    typeof value === "object" &&
    value !== null &&
    typeof (value as PromiseLike<unknown>).then === "function"
  )
}

function useStatusTransition(status: ButtonStatus) {
  const [shown, setShown] = React.useState(status)
  const [leaving, setLeaving] = React.useState<ButtonStatus | null>(null)
  const [transitionKey, setTransitionKey] = React.useState(0)

  if (status !== shown) {
    setLeaving(shown)
    setShown(status)
    setTransitionKey((key) => key + 1)
  }

  React.useEffect(() => {
    if (leaving === null) {
      return
    }
    const timer = setTimeout(() => setLeaving(null), stateTransitionTime)
    return () => clearTimeout(timer)
  }, [leaving, transitionKey])

  return { leaving, transitionKey }
}

function useLayerWidth(status: ButtonStatus, active: boolean) {
  const contentRef = React.useRef<HTMLSpanElement>(null)
  const layersRef = React.useRef(new Map<ButtonStatus, HTMLElement>())
  const observerRef = React.useRef<ResizeObserver | null>(null)
  const statusRef = React.useRef(status)

  const applyWidth = React.useCallback(() => {
    const content = contentRef.current
    const layer = layersRef.current.get(statusRef.current)
    if (!content || !layer) {
      return
    }
    const natural = parseFloat(getComputedStyle(layer).width)
    if (!Number.isFinite(natural)) {
      return
    }
    content.style.width = `${natural}px`
    if (!content.hasAttribute("data-measured")) {
      requestAnimationFrame(() => content.setAttribute("data-measured", ""))
    }
  }, [])

  React.useLayoutEffect(() => {
    statusRef.current = status
    if (active) {
      applyWidth()
    }
  }, [active, applyWidth, status])

  React.useLayoutEffect(() => {
    if (!active || typeof ResizeObserver === "undefined") {
      return
    }
    const observer = new ResizeObserver(applyWidth)
    observerRef.current = observer
    layersRef.current.forEach((layer) => observer.observe(layer))
    document.fonts?.addEventListener?.("loadingdone", applyWidth)
    return () => {
      observer.disconnect()
      observerRef.current = null
      document.fonts?.removeEventListener?.("loadingdone", applyWidth)
    }
  }, [active, applyWidth])

  const layerRef = React.useCallback((node: HTMLElement | null) => {
    if (!node) {
      return
    }
    const layer = node.dataset.layer as ButtonStatus
    layersRef.current.set(layer, node)
    observerRef.current?.observe(node)
    return () => {
      observerRef.current?.unobserve(node)
      if (layersRef.current.get(layer) === node) {
        layersRef.current.delete(layer)
      }
    }
  }, [])

  return { contentRef, layerRef }
}

function composeHandlers<Event>(
  first: ((event: Event) => void) | undefined,
  second: (event: Event) => void
) {
  return (event: Event) => {
    first?.(event)
    second(event)
  }
}

function Button({
  className,
  variant = "default",
  size = "default",
  children,
  onClick,
  disabled,
  focusableWhenDisabled,
  feedback,
  loading,
  status: statusProp,
  onStatusChange,
  onError,
  resetAfter,
  loadingLabel,
  successLabel = "Done",
  errorLabel = "Failed",
  onPointerEnter,
  onPointerLeave,
  onFocus,
  onBlur,
  ...props
}: ButtonProps) {
  const internal = useButtonFeedback({ resetAfter, onStatusChange, onError })
  const status = statusProp ?? (loading ? "loading" : internal.status)
  const requested =
    Boolean(feedback) || loading !== undefined || statusProp !== undefined
  const [enabled, setEnabled] = React.useState(requested)
  if (requested && !enabled) {
    setEnabled(true)
  }

  const { leaving, transitionKey } = useStatusTransition(status)
  const iconOnly = size?.startsWith("icon") ?? false
  const widthStatus =
    status === "loading" && loadingLabel === undefined ? "idle" : status
  const { contentRef, layerRef } = useLayerWidth(
    widthStatus,
    enabled && !iconOnly
  )
  const isLoading = status === "loading"
  const quiet = quietVariants.includes(variant ?? "default")
  const resolvedVariant =
    !quiet && status === "success"
      ? "success"
      : !quiet && status === "error"
        ? "error"
        : variant
  const resolvedErrorLabel =
    typeof errorLabel !== "function"
      ? errorLabel
      : internal.error !== undefined
        ? errorLabel(internal.error)
        : "Failed"

  const handleClick = (event: ButtonClickEvent) => {
    if (feedback && internal.isPending()) {
      return
    }
    const result = onClick?.(event)
    if (feedback && isThenable(result)) {
      internal.track(result)
    }
  }

  const layerContent = (layer: ButtonStatus, animated: boolean) => {
    if (layer === "idle") {
      return children
    }
    if (layer === "loading") {
      return (
        <>
          <Spinner aria-hidden size={null} animated={animated} />
          {iconOnly ? null : loadingLabel}
        </>
      )
    }
    if (layer === "success") {
      return (
        <>
          <IconCircleCheck
            className={cn(
              animated &&
                "motion-safe:[&>path:first-child]:animate-button-draw-circle motion-safe:[&>path:first-child]:[stroke-dasharray:57] motion-safe:[&>path:first-child]:[stroke-dashoffset:57] motion-safe:[&>path:last-child]:animate-button-draw-check motion-safe:[&>path:last-child]:[stroke-dasharray:9] motion-safe:[&>path:last-child]:[stroke-dashoffset:9]"
            )}
          />
          {iconOnly ? null : successLabel}
        </>
      )
    }
    return (
      <>
        <IconAlertCircle />
        {iconOnly ? null : resolvedErrorLabel}
      </>
    )
  }

  const button = (
    <ButtonPrimitive
      data-slot="button"
      data-status={enabled ? status : undefined}
      aria-busy={isLoading || undefined}
      disabled={disabled || isLoading}
      focusableWhenDisabled={focusableWhenDisabled ?? (isLoading || undefined)}
      className={cn(
        buttonVariants({ variant: resolvedVariant, size }),
        quiet && quietStatusClasses[status],
        className
      )}
      onClick={handleClick}
      onPointerEnter={composeHandlers(
        onPointerEnter,
        internal.buttonProps.onPointerEnter
      )}
      onPointerLeave={composeHandlers(
        onPointerLeave,
        internal.buttonProps.onPointerLeave
      )}
      onFocus={composeHandlers(onFocus, internal.buttonProps.onFocus)}
      onBlur={composeHandlers(onBlur, internal.buttonProps.onBlur)}
      {...props}
    >
      {enabled ? (
        <span
          ref={contentRef}
          data-slot="button-content"
          className="relative inline-grid grid-cols-[minmax(0,1fr)] place-items-center gap-[inherit] overflow-x-clip *:col-start-1 *:row-start-1 motion-safe:data-measured:transition-[width] motion-safe:data-measured:duration-220 motion-safe:data-measured:ease-[cubic-bezier(0.2,0,0,1)]"
        >
          {statuses.map((layer) => {
            const active = layer === status
            const exiting = layer === leaving
            const namesButton =
              layer === "idle" && isLoading && loadingLabel === undefined

            return (
              <span
                key={active ? `${layer}-${transitionKey}` : layer}
                ref={layerRef}
                data-slot="button-layer"
                data-layer={layer}
                aria-hidden={active || namesButton ? undefined : true}
                className={cn(
                  "inline-flex items-center justify-center gap-[inherit] backface-hidden",
                  !active &&
                    !exiting &&
                    "pointer-events-none absolute opacity-0",
                  active &&
                    transitionKey > 0 &&
                    "origin-bottom motion-safe:animate-button-state-in motion-reduce:animate-in motion-reduce:animation-duration-150 motion-reduce:fade-in-0",
                  exiting &&
                    "pointer-events-none origin-top opacity-0 motion-safe:animate-button-state-out"
                )}
              >
                {layerContent(layer, active || exiting)}
              </span>
            )
          })}
        </span>
      ) : (
        children
      )}
    </ButtonPrimitive>
  )

  if (!enabled) {
    return button
  }

  const announcement =
    status === "loading"
      ? typeof loadingLabel === "string"
        ? loadingLabel
        : "Loading"
      : status === "success"
        ? typeof successLabel === "string"
          ? successLabel
          : "Done"
        : status === "error"
          ? typeof resolvedErrorLabel === "string"
            ? resolvedErrorLabel
            : "Failed"
          : ""

  return (
    <>
      {button}
      <span data-slot="button-status" role="status" className="sr-only">
        {announcement}
      </span>
    </>
  )
}

export { Button, buttonVariants, useButtonFeedback }
export type { ButtonProps, ButtonStatus, ButtonFeedbackOptions }
```

```tsx title="components/ui/number-flow.tsx"
"use client"

import * as React from "react"
import { cn } from "cn"

import { easeSpring } from "@/lib/motion"

type NumberFlowTrend = "auto" | "up" | "down" | "shortest"

type NumberFlowChar = {
  key: string
  section: number
  order: number
  value: string
  digit: number | null
}

type NumberFlowItem = NumberFlowChar & {
  exiting: boolean
  exitId: number
}

type NumberFlowProps = Omit<
  React.ComponentProps<"span">,
  "children" | "prefix"
> & {
  value: number
  locales?: Intl.LocalesArgument
  format?: Intl.NumberFormatOptions
  prefix?: string
  suffix?: string
  trend?: NumberFlowTrend
  duration?: number
  easing?: string
  animated?: boolean
  onAnimationsStart?: () => void
  onAnimationsFinish?: () => void
}

const rows = 30

const restingClasses =
  "data-[digit='0']:translate-y-[-33.3333%] data-[digit='1']:translate-y-[-36.6667%] data-[digit='2']:translate-y-[-40%] data-[digit='3']:translate-y-[-43.3333%] data-[digit='4']:translate-y-[-46.6667%] data-[digit='5']:translate-y-[-50%] data-[digit='6']:translate-y-[-53.3333%] data-[digit='7']:translate-y-[-56.6667%] data-[digit='8']:translate-y-[-60%] data-[digit='9']:translate-y-[-63.3333%]"

function getSpinDelta(
  from: number,
  to: number,
  trend: Exclude<NumberFlowTrend, "auto">
) {
  let delta = to - from

  if (trend === "up") {
    if (delta < 0) {
      delta += 10
    }
  } else if (trend === "down") {
    if (delta > 0) {
      delta -= 10
    }
  } else if (delta > 5) {
    delta -= 10
  } else if (delta < -4) {
    delta += 10
  }

  return delta
}

function digitGlyphs(
  formatter: Intl.NumberFormat,
  locales: Intl.LocalesArgument
) {
  const { numberingSystem } = formatter.resolvedOptions()
  const plain = new Intl.NumberFormat(locales, {
    numberingSystem,
    useGrouping: false,
  })

  return Array.from({ length: 10 }, (_, digit) => plain.format(digit))
}

function formatChars(
  value: number,
  locales: Intl.LocalesArgument,
  format: Intl.NumberFormatOptions | undefined,
  prefix: string | undefined,
  suffix: string | undefined
) {
  const formatter = new Intl.NumberFormat(locales, format)
  const glyphs = digitGlyphs(formatter, locales)
  const parts = formatter.formatToParts(value)
  const integerCount = parts
    .filter((part) => part.type === "integer")
    .reduce((count, part) => count + Array.from(part.value).length, 0)
  const chars: NumberFlowChar[] = []
  const occurrences = new Map<string, number>()
  let integerIndex = 0
  let fractionIndex = 0
  let seenNumber = false

  function symbol(section: number, type: string, text: string) {
    const name = `${section}-${type}`
    const occurrence = occurrences.get(name) ?? 0
    occurrences.set(name, occurrence + 1)
    chars.push({
      key: `s${name}-${occurrence}`,
      section,
      order: occurrence,
      value: text,
      digit: null,
    })
  }

  if (prefix) {
    chars.push({
      key: "prefix",
      section: 0,
      order: 0,
      value: prefix,
      digit: null,
    })
  }

  for (const part of parts) {
    if (part.type === "integer") {
      seenNumber = true
      for (const glyph of Array.from(part.value)) {
        const place = integerCount - 1 - integerIndex
        const digit = glyphs.indexOf(glyph)
        chars.push({
          key: `i${place}`,
          section: 2,
          order: -place,
          value: glyph,
          digit: digit === -1 ? null : digit,
        })
        integerIndex += 1
      }
    } else if (part.type === "group") {
      const place = integerCount - 1 - integerIndex
      chars.push({
        key: `g${place}`,
        section: 2,
        order: -place - 0.5,
        value: part.value,
        digit: null,
      })
    } else if (part.type === "decimal") {
      seenNumber = true
      chars.push({
        key: "decimal",
        section: 3,
        order: 0,
        value: part.value,
        digit: null,
      })
    } else if (part.type === "fraction") {
      for (const glyph of Array.from(part.value)) {
        const digit = glyphs.indexOf(glyph)
        chars.push({
          key: `f${fractionIndex}`,
          section: 4,
          order: fractionIndex,
          value: glyph,
          digit: digit === -1 ? null : digit,
        })
        fractionIndex += 1
      }
    } else {
      symbol(seenNumber ? 5 : 1, part.type, part.value)
    }
  }

  if (suffix) {
    chars.push({
      key: "suffix",
      section: 6,
      order: 0,
      value: suffix,
      digit: null,
    })
  }

  return chars
}

function signature(chars: NumberFlowChar[]) {
  return chars.map((char) => `${char.key}:${char.value}`).join("|")
}

function mergeItems(previous: NumberFlowItem[], next: NumberFlowChar[]) {
  const keys = new Set(next.map((char) => char.key))
  const kept = previous
    .filter((item) => !keys.has(item.key))
    .map((item) =>
      item.exiting ? item : { ...item, exiting: true, exitId: item.exitId + 1 }
    )
  const fresh = next.map((char) => {
    const before = previous.find((item) => item.key === char.key)
    return { ...char, exiting: false, exitId: before?.exitId ?? 0 }
  })

  return [...kept, ...fresh].sort(
    (a, b) => a.section - b.section || a.order - b.order
  )
}

function subscribeReducedMotion(callback: () => void) {
  if (typeof window.matchMedia !== "function") {
    return () => {}
  }
  const query = window.matchMedia("(prefers-reduced-motion: reduce)")
  query.addEventListener("change", callback)
  return () => query.removeEventListener("change", callback)
}

function getReducedMotion() {
  return (
    typeof window.matchMedia === "function" &&
    window.matchMedia("(prefers-reduced-motion: reduce)").matches
  )
}

function canAnimate() {
  return (
    typeof Element !== "undefined" &&
    typeof Element.prototype.animate === "function"
  )
}

function liveIndex(column: HTMLElement) {
  const height = column.offsetHeight

  if (!height) {
    return null
  }

  const translate = getComputedStyle(column).translate
  const y = translate === "none" ? "0" : (translate.split(" ")[1] ?? "0")
  const amount = parseFloat(y) || 0

  return y.endsWith("%") ? (-amount / 100) * rows : (-amount / height) * rows
}

function NumberFlow({
  value,
  locales = "en-US",
  format,
  prefix,
  suffix,
  trend = "auto",
  duration = 600,
  easing = easeSpring,
  animated = true,
  onAnimationsStart,
  onAnimationsFinish,
  className,
  ...props
}: NumberFlowProps) {
  const chars = formatChars(value, locales, format, prefix, suffix)
  const key = signature(chars)
  const reducedMotion = React.useSyncExternalStore(
    subscribeReducedMotion,
    getReducedMotion,
    () => false
  )
  const animate = animated && !reducedMotion && canAnimate()
  const [state, setState] = React.useState(() => ({
    key,
    items: chars.map((char) => ({ ...char, exiting: false, exitId: 0 })),
  }))

  if (state.key !== key) {
    setState({
      key,
      items: animate
        ? mergeItems(state.items, chars)
        : chars.map((char) => ({ ...char, exiting: false, exitId: 0 })),
    })
  }

  const rootRef = React.useRef<HTMLSpanElement>(null)
  const previousRef = React.useRef<Map<
    string,
    { digit: number | null; exiting: boolean; exitId: number }
  > | null>(null)
  const valueRef = React.useRef(value)
  const batchRef = React.useRef(0)
  const callbacksRef = React.useRef({ onAnimationsStart, onAnimationsFinish })
  const finishExitRef = React.useRef((itemKey: string, exitId: number) => {
    setState((current) => ({
      ...current,
      items: current.items.filter(
        (item) =>
          !(item.key === itemKey && item.exiting && item.exitId === exitId)
      ),
    }))
  })

  React.useLayoutEffect(() => {
    callbacksRef.current = { onAnimationsStart, onAnimationsFinish }
  })

  React.useLayoutEffect(() => {
    const root = rootRef.current
    const previous = previousRef.current
    const current = new Map(
      state.items.map((item) => [
        item.key,
        { digit: item.digit, exiting: item.exiting, exitId: item.exitId },
      ])
    )
    const previousValue = valueRef.current

    previousRef.current = current
    valueRef.current = value

    if (!root || !previous || !animate) {
      return
    }

    const direction =
      trend !== "auto"
        ? trend
        : value > previousValue
          ? "up"
          : value < previousValue
            ? "down"
            : "shortest"
    const timing = { duration, easing }
    const started: Animation[] = []
    const cells = root.querySelectorAll<HTMLElement>(
      ":scope > [data-number-flow-key]"
    )

    for (const cell of cells) {
      const itemKey = cell.dataset.numberFlowKey!
      const item = current.get(itemKey)
      const before = previous.get(itemKey)

      if (!item || typeof cell.animate !== "function") {
        continue
      }

      const column = cell.querySelector<HTMLElement>(
        "[data-slot=number-flow-column]"
      )
      const entering = !item.exiting && (!before || before.exiting)
      const leaving = item.exiting && (!before || !before.exiting)

      if (entering || leaving) {
        const running = cell.getAnimations()
        const fromWidth = running.length
          ? cell.getBoundingClientRect().width
          : entering
            ? 0
            : cell.getBoundingClientRect().width
        const fromOpacity = running.length
          ? Number(getComputedStyle(cell).opacity)
          : entering
            ? 0
            : 1
        running.forEach((animation) => animation.cancel())
        const toWidth = entering ? cell.getBoundingClientRect().width : 0
        const animation = cell.animate(
          [
            { width: `${fromWidth}px`, opacity: fromOpacity },
            { width: `${toWidth}px`, opacity: entering ? 1 : 0 },
          ],
          { ...timing, fill: leaving ? "forwards" : "none" }
        )
        started.push(animation)
        if (leaving) {
          const exitId = item.exitId
          animation.finished.then(
            () => finishExitRef.current(itemKey, exitId),
            () => {}
          )
        }
      }

      if (!column || item.digit === null || item.exiting) {
        continue
      }

      const target = item.digit
      const running = column.getAnimations()
      const live = running.length ? liveIndex(column) : null
      running.forEach((animation) => animation.cancel())
      const from =
        live !== null
          ? live - 10
          : entering
            ? 0
            : before && before.digit !== null
              ? before.digit
              : target

      if (live === null && from === target && !entering) {
        continue
      }

      const delta = getSpinDelta(
        from,
        target,
        entering && live === null ? "up" : direction
      )

      if (delta === 0) {
        continue
      }

      started.push(
        column.animate(
          [
            { translate: `0 ${(-(target + 10 - delta) / rows) * 100}%` },
            { translate: `0 ${(-(target + 10) / rows) * 100}%` },
          ],
          timing
        )
      )
    }

    if (!started.length) {
      return
    }

    batchRef.current += 1
    const batch = batchRef.current
    callbacksRef.current.onAnimationsStart?.()
    Promise.all(started.map((animation) => animation.finished)).then(
      () => {
        if (batch === batchRef.current) {
          callbacksRef.current.onAnimationsFinish?.()
        }
      },
      () => {}
    )
  }, [state.items, animate, duration, easing, trend, value])

  return (
    <span
      ref={rootRef}
      dir="ltr"
      className={cn("inline-block max-w-full tabular-nums", className)}
      {...props}
      data-slot="number-flow"
    >
      <span data-slot="number-flow-text" className="sr-only">
        {state.items
          .filter((item) => !item.exiting)
          .map((item) => item.value)
          .join("")}
      </span>
      {state.items.map((item) =>
        item.digit === null ? (
          <span
            key={item.key}
            data-number-flow-key={item.key}
            data-slot="number-flow-symbol"
            aria-hidden
            className="inline-block max-w-full overflow-clip wrap-anywhere whitespace-pre-wrap"
          >
            {item.value}
          </span>
        ) : (
          <span
            key={item.key}
            data-number-flow-key={item.key}
            data-slot="number-flow-digit"
            aria-hidden
            className="relative -my-[0.2em] inline-block overflow-clip [mask-image:linear-gradient(to_bottom,transparent,black_0.2em,black_calc(100%-0.2em),transparent)] py-[0.2em]"
          >
            <span className="opacity-0">{item.value}</span>
            <span
              aria-hidden
              data-slot="number-flow-column"
              data-digit={item.digit}
              className={cn(
                "pointer-events-none absolute inset-x-0 top-0 flex flex-col items-center select-none",
                restingClasses
              )}
            >
              {Array.from({ length: rows }, (_, row) => (
                <span key={row} className="block py-[0.2em]">
                  {glyphFor(item, row)}
                </span>
              ))}
            </span>
          </span>
        )
      )}
    </span>
  )
}

function glyphFor(item: NumberFlowItem, row: number) {
  return String.fromCodePoint(
    item.value.codePointAt(0)! - (item.digit ?? 0) + (row % 10)
  )
}

export {
  NumberFlow,
  getSpinDelta,
  formatChars,
  type NumberFlowProps,
  type NumberFlowTrend,
}
```

```ts title="lib/motion.ts"
import * as React from "react"

const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)"
const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)"
const easeSpring =
  "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)"

const duration = {
  press: 100,
  release: 200,
  hover: 150,
  enter: 200,
  exit: 150,
  morph: 300,
} as const

function prefersReducedMotion() {
  return (
    typeof window === "undefined" ||
    typeof window.matchMedia !== "function" ||
    window.matchMedia("(prefers-reduced-motion: reduce)").matches
  )
}

type SizeAxis = "width" | "height"

type SizeMorphOptions = {
  axis: SizeAxis
  enabled?: boolean
  duration?: number
  easing?: string
}

function readSize(element: HTMLElement, axis: SizeAxis) {
  const value = parseFloat(getComputedStyle(element)[axis])
  if (Number.isFinite(value)) {
    return value
  }
  const rect = element.getBoundingClientRect()
  return axis === "width" ? rect.width : rect.height
}

function attachSizeMorph(
  element: HTMLElement,
  axis: SizeAxis,
  time: number,
  easing: string
) {
  if (
    typeof MutationObserver === "undefined" ||
    typeof element.animate !== "function"
  ) {
    return undefined
  }

  let settled = readSize(element, axis)
  let animation: Animation | null = null

  const morph = () => {
    const from = animation ? readSize(element, axis) : settled
    animation?.cancel()
    animation = null
    const to = readSize(element, axis)
    settled = to

    if (
      Math.abs(from - to) < 0.5 ||
      prefersReducedMotion() ||
      !element.isConnected ||
      element.getClientRects().length === 0
    ) {
      element.removeAttribute("data-morphing")
      return
    }

    element.setAttribute("data-morphing", "")
    const running = element.animate(
      [{ [axis]: `${from}px` }, { [axis]: `${to}px` }],
      { duration: time, easing }
    )
    animation = running
    running.onfinish = () => {
      if (animation === running) {
        animation = null
        element.removeAttribute("data-morphing")
        settled = readSize(element, axis)
      }
    }
  }

  const mutations = new MutationObserver(morph)
  mutations.observe(element, {
    childList: true,
    subtree: true,
    characterData: true,
  })

  const resize =
    typeof ResizeObserver === "undefined"
      ? null
      : new ResizeObserver(() => {
          if (!animation) {
            settled = readSize(element, axis)
          }
        })
  resize?.observe(element)

  return () => {
    mutations.disconnect()
    resize?.disconnect()
    animation?.cancel()
    element.removeAttribute("data-morphing")
  }
}

function useSizeMorph<T extends HTMLElement>({
  axis,
  enabled = true,
  duration: time = duration.morph,
  easing = easeOut,
}: SizeMorphOptions): React.RefCallback<T> {
  return React.useCallback(
    (element: T | null) => {
      if (!element || !enabled) {
        return undefined
      }
      return attachSizeMorph(element, axis, time, easing)
    },
    [axis, enabled, time, easing]
  )
}

function useSlidingHighlight(
  barRef: React.RefObject<HTMLElement | null>,
  highlightRef: React.RefObject<HTMLElement | null>,
  selector: string,
  attribute = "data-popup-open"
) {
  React.useLayoutEffect(() => {
    const bar = barRef.current
    const highlight = highlightRef.current
    if (!bar || !highlight) {
      return
    }

    let current: HTMLElement | null = null

    const place = (trigger: HTMLElement, instant: boolean) => {
      if (instant || prefersReducedMotion()) {
        highlight.setAttribute("data-instant", "")
      } else {
        highlight.removeAttribute("data-instant")
      }
      const frame = bar.getBoundingClientRect()
      const box = trigger.getBoundingClientRect()
      const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1
      highlight.style.left = "0px"
      highlight.style.width = `${box.width / scale}px`
      highlight.style.height = `${box.height / scale}px`
      highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)`
    }

    const sync = () => {
      const trigger = bar.querySelector<HTMLElement>(selector)
      if (trigger === current) {
        if (trigger) {
          place(trigger, true)
        }
        return
      }
      const appearing = current === null
      current = trigger
      if (!trigger) {
        highlight.removeAttribute("data-visible")
        return
      }
      place(trigger, appearing)
      if (appearing) {
        void highlight.offsetWidth
      }
      highlight.setAttribute("data-visible", "")
    }

    sync()
    const mutations = new MutationObserver(sync)
    mutations.observe(bar, {
      subtree: true,
      childList: true,
      attributes: true,
      attributeFilter: [attribute],
    })
    const resize =
      typeof ResizeObserver === "undefined"
        ? null
        : new ResizeObserver(() => {
            if (current) {
              place(current, true)
            }
          })
    resize?.observe(bar)
    const onScroll = () => {
      if (current) {
        place(current, true)
      }
    }
    bar.addEventListener("scroll", onScroll, { capture: true, passive: true })

    return () => {
      mutations.disconnect()
      resize?.disconnect()
      bar.removeEventListener("scroll", onScroll, { capture: true })
    }
  }, [barRef, highlightRef, selector, attribute])
}

export {
  duration,
  easeInOut,
  easeOut,
  easeSpring,
  prefersReducedMotion,
  useSizeMorph,
  useSlidingHighlight,
}
export type { SizeMorphOptions }
```

Update the import paths to match your project setup.

## Usage

```tsx
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupClear,
  InputGroupCount,
  InputGroupInput,
  InputGroupPasswordToggle,
  InputGroupText,
  InputGroupTextarea,
} from "@/components/ui/input-group"
```

```tsx
<InputGroup>
  <InputGroupInput placeholder="Search…" aria-label="Search" />
  <InputGroupAddon>
    <IconSearch />
  </InputGroupAddon>
  <InputGroupAddon align="inline-end">
    <InputGroupClear />
  </InputGroupAddon>
</InputGroup>
```

Put `<InputGroupInput />` or `<InputGroupTextarea />` first and the addons after it. Addons place themselves with `align`, so the field comes first in the tab order and for screen readers.

The group tracks its field, so the smart parts need no wiring. `<InputGroupClear />`, `<InputGroupPasswordToggle />` and `<InputGroupCount />` read the value, `type` and `maxLength` from the field, whether it's controlled or not.

## Composition

```text
InputGroup
├── InputGroupAddon
│   ├── InputGroupText
│   ├── InputGroupButton
│   ├── InputGroupClear
│   ├── InputGroupCount
│   └── InputGroupPasswordToggle
└── InputGroupInput / InputGroupTextarea
```

## Examples

### Icon

Icons sit inside the border on either side. Clicking an icon focuses the input, so the whole group feels like one field.

```tsx title="components/examples/input-group/icon.tsx"
import { IconCheck, IconMail, IconSearch } from "@tabler/icons-react"

import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group"

export function InputGroupIcon() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-4">
      <InputGroup>
        <InputGroupInput placeholder="Search…" aria-label="Search" />
        <InputGroupAddon>
          <IconSearch />
        </InputGroupAddon>
      </InputGroup>
      <InputGroup>
        <InputGroupInput
          type="email"
          defaultValue="hi@hextaui.com"
          aria-label="Email"
        />
        <InputGroupAddon>
          <IconMail />
        </InputGroupAddon>
        <InputGroupAddon align="inline-end">
          <IconCheck className="text-success" aria-label="Verified" />
        </InputGroupAddon>
      </InputGroup>
    </div>
  )
}
```

### Clear

`<InputGroupClear />` fades in once there's a value. It clears through the browser's edit history, so Cmd+Z brings the text back, and it fires your `onChange`. Escape clears too. A second Escape is left for the dialog or popover around it.

```tsx title="components/examples/input-group/clear.tsx"
"use client"

import * as React from "react"
import { IconSearch } from "@tabler/icons-react"

import {
  InputGroup,
  InputGroupAddon,
  InputGroupClear,
  InputGroupInput,
} from "@/components/ui/input-group"

const fruits = ["Apple", "Apricot", "Banana", "Blackberry", "Cherry", "Fig"]

export function InputGroupClearDemo() {
  const [query, setQuery] = React.useState("ap")
  const matches = fruits.filter((fruit) =>
    fruit.toLowerCase().includes(query.trim().toLowerCase())
  )

  return (
    <div className="flex w-full max-w-sm flex-col gap-3">
      <InputGroup>
        <InputGroupInput
          type="search"
          value={query}
          onChange={(event) => setQuery(event.target.value)}
          placeholder="Filter fruit"
          aria-label="Filter fruit"
        />
        <InputGroupAddon>
          <IconSearch />
        </InputGroupAddon>
        <InputGroupAddon align="inline-end">
          <InputGroupClear />
        </InputGroupAddon>
      </InputGroup>
      <p className="text-sm text-muted-foreground">
        {matches.length > 0 ? matches.join(", ") : "No fruit matches."}
      </p>
    </div>
  )
}
```

### Character count

`<InputGroupCount />` counts against the field's `maxLength`. Only the digits that change roll. The count darkens near the limit and turns red at it, and a typed key past the limit nudges it. Screen readers hear a message when the field gets close to the limit and when it reaches it, never on every key.

```tsx title="components/examples/input-group/count.tsx"
import { IconAt } from "@tabler/icons-react"

import {
  InputGroup,
  InputGroupAddon,
  InputGroupCount,
  InputGroupInput,
} from "@/components/ui/input-group"

export function InputGroupCountDemo() {
  return (
    <InputGroup className="max-w-sm">
      <InputGroupInput
        defaultValue="hextaui"
        maxLength={15}
        autoComplete="username"
        aria-label="Username"
      />
      <InputGroupAddon>
        <IconAt />
      </InputGroupAddon>
      <InputGroupAddon align="inline-end">
        <InputGroupCount />
      </InputGroupAddon>
    </InputGroup>
  )
}
```

### Text

Use `<InputGroupText />` for units, currencies and URL parts. The input's padding shrinks next to an addon so the text reads as one value.

```tsx title="components/examples/input-group/text.tsx"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
  InputGroupText,
} from "@/components/ui/input-group"

export function InputGroupTextDemo() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-4">
      <InputGroup>
        <InputGroupInput
          placeholder="0.00"
          inputMode="decimal"
          aria-label="Price"
        />
        <InputGroupAddon>
          <InputGroupText>$</InputGroupText>
        </InputGroupAddon>
        <InputGroupAddon align="inline-end">
          <InputGroupText>USD</InputGroupText>
        </InputGroupAddon>
      </InputGroup>
      <InputGroup>
        <InputGroupInput placeholder="hextaui" aria-label="Domain" />
        <InputGroupAddon>
          <InputGroupText>https://</InputGroupText>
        </InputGroupAddon>
        <InputGroupAddon align="inline-end">
          <InputGroupText>.com</InputGroupText>
        </InputGroupAddon>
      </InputGroup>
    </div>
  )
}
```

### Button

`<InputGroupButton />` is a ghost button sized to fit inside the field. Its corners are concentric with the group's, and it gets its own focus ring.

```tsx title="components/examples/input-group/button.tsx"
"use client"

import * as React from "react"
import { IconCheck, IconCopy, IconX } from "@tabler/icons-react"

import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from "@/components/ui/input-group"

export function InputGroupButtonDemo() {
  const [copied, setCopied] = React.useState(false)
  const [query, setQuery] = React.useState("button group")

  return (
    <div className="flex w-full max-w-sm flex-col gap-4">
      <InputGroup>
        <InputGroupInput
          readOnly
          defaultValue="https://hextaui.com/docs/input-group"
          aria-label="Share link"
        />
        <InputGroupAddon align="inline-end">
          <InputGroupButton
            size="icon-xs"
            aria-label={copied ? "Copied" : "Copy link"}
            onClick={async () => {
              await navigator.clipboard
                ?.writeText("https://hextaui.com/docs/input-group")
                .catch(() => {})
              setCopied(true)
              setTimeout(() => setCopied(false), 1500)
            }}
          >
            {copied ? <IconCheck /> : <IconCopy />}
          </InputGroupButton>
        </InputGroupAddon>
      </InputGroup>
      <InputGroup>
        <InputGroupInput
          value={query}
          onChange={(event) => setQuery(event.target.value)}
          placeholder="Search components…"
          aria-label="Search components"
        />
        <InputGroupAddon align="inline-end">
          {query ? (
            <InputGroupButton
              size="icon-xs"
              aria-label="Clear search"
              onClick={() => setQuery("")}
            >
              <IconX />
            </InputGroupButton>
          ) : null}
          <InputGroupButton variant="secondary">Search</InputGroupButton>
        </InputGroupAddon>
      </InputGroup>
    </div>
  )
}
```

### Keyboard hint

A plain `<kbd>` inside an addon is styled as a key cap. It's a visual hint only, so bind the shortcut yourself.

```tsx title="components/examples/input-group/kbd.tsx"
import { IconSearch } from "@tabler/icons-react"

import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group"

export function InputGroupKbd() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-4">
      <InputGroup>
        <InputGroupInput placeholder="Search docs…" aria-label="Search docs" />
        <InputGroupAddon>
          <IconSearch />
        </InputGroupAddon>
        <InputGroupAddon align="inline-end">
          <kbd>⌘</kbd>
          <kbd>K</kbd>
        </InputGroupAddon>
      </InputGroup>
      <InputGroup>
        <InputGroupInput
          placeholder="Jump to file…"
          aria-label="Jump to file"
        />
        <InputGroupAddon align="inline-end">
          <kbd>/</kbd>
        </InputGroupAddon>
      </InputGroup>
    </div>
  )
}
```

### Textarea

`<InputGroupTextarea />` grows with its content up to 16rem, then scrolls. The height eases between lines instead of jumping. A `block-end` addon becomes a toolbar under it, and buttons at its edges get the same inset as the corner they sit in.

```tsx title="components/examples/input-group/textarea.tsx"
"use client"

import * as React from "react"
import { IconArrowUp, IconPaperclip } from "@tabler/icons-react"

import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupCount,
  InputGroupTextarea,
} from "@/components/ui/input-group"

export function InputGroupTextareaDemo() {
  const [value, setValue] = React.useState("")

  return (
    <InputGroup className="max-w-sm">
      <InputGroupTextarea
        value={value}
        onChange={(event) => setValue(event.target.value)}
        maxLength={280}
        placeholder="Write a reply…"
        aria-label="Reply"
      />
      <InputGroupAddon align="block-end">
        <InputGroupButton size="icon-xs" aria-label="Attach a file">
          <IconPaperclip />
        </InputGroupButton>
        <InputGroupCount className="ms-auto" />
        <InputGroupButton
          variant="default"
          size="icon-xs"
          aria-label="Send"
          disabled={!value.trim()}
        >
          <IconArrowUp />
        </InputGroupButton>
      </InputGroupAddon>
    </InputGroup>
  )
}
```

### Header

A `block-start` addon sits above the field. Add `separator` to draw a hairline between them.

```tsx title="components/examples/input-group/block-start.tsx"
import { IconCopy, IconFileCode } from "@tabler/icons-react"

import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupText,
  InputGroupTextarea,
} from "@/components/ui/input-group"

export function InputGroupBlockStart() {
  return (
    <InputGroup className="max-w-sm">
      <InputGroupTextarea
        defaultValue={'console.log("Hello from HextaUI")'}
        aria-label="script.js"
      />
      <InputGroupAddon align="block-start" separator>
        <InputGroupText>
          <IconFileCode />
          script.js
        </InputGroupText>
        <InputGroupButton size="icon-xs" aria-label="Copy" className="ms-auto">
          <IconCopy />
        </InputGroupButton>
      </InputGroupAddon>
    </InputGroup>
  )
}
```

### Password

`<InputGroupPasswordToggle />` switches a `type="password"` field to text and back. The caret and selection stay where they were, a mouse click keeps focus in the field, and the password is hidden again when the form submits. Control it with `revealed`.

```tsx title="components/examples/input-group/password.tsx"
import { IconLock } from "@tabler/icons-react"

import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
  InputGroupPasswordToggle,
} from "@/components/ui/input-group"

export function InputGroupPassword() {
  return (
    <InputGroup className="max-w-sm">
      <InputGroupInput
        type="password"
        defaultValue="correct horse battery staple"
        autoComplete="current-password"
        aria-label="Password"
      />
      <InputGroupAddon>
        <IconLock />
      </InputGroupAddon>
      <InputGroupAddon align="inline-end">
        <InputGroupPasswordToggle />
      </InputGroupAddon>
    </InputGroup>
  )
}
```

### Sizes

`size` on the group sets the height and passes down to the input, matching `<Input />` sizes. Buttons keep concentric corners at every size.

```tsx title="components/examples/input-group/sizes.tsx"
import { IconSearch } from "@tabler/icons-react"

import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from "@/components/ui/input-group"

export function InputGroupSizes() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-4">
      {(["sm", "default", "lg"] as const).map((size) => (
        <InputGroup key={size} size={size}>
          <InputGroupInput
            placeholder={`Size ${size}`}
            aria-label={`Size ${size}`}
          />
          <InputGroupAddon>
            <IconSearch />
          </InputGroupAddon>
          <InputGroupAddon align="inline-end">
            <InputGroupButton>Go</InputGroupButton>
          </InputGroupAddon>
        </InputGroup>
      ))}
    </div>
  )
}
```

### Invalid

Set `aria-invalid` on the input and the whole group turns red, including its focus ring. Link the message with `aria-describedby`.

```tsx title="components/examples/input-group/invalid.tsx"
import { IconAt, IconAlertCircle } from "@tabler/icons-react"

import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group"

export function InputGroupInvalid() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-2">
      <InputGroup>
        <InputGroupInput
          defaultValue="preet@"
          aria-label="Email"
          aria-invalid
          aria-describedby="email-error"
        />
        <InputGroupAddon>
          <IconAt />
        </InputGroupAddon>
        <InputGroupAddon align="inline-end">
          <IconAlertCircle className="text-destructive" aria-hidden />
        </InputGroupAddon>
      </InputGroup>
      <p id="email-error" className="text-sm text-destructive">
        Enter a complete email address.
      </p>
    </div>
  )
}
```

### Disabled

A disabled input dims the whole group and shows a not-allowed cursor over it. Disable addon buttons too, since they stay usable otherwise.

```tsx title="components/examples/input-group/disabled.tsx"
import { IconLink } from "@tabler/icons-react"

import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from "@/components/ui/input-group"

export function InputGroupDisabled() {
  return (
    <InputGroup className="max-w-sm">
      <InputGroupInput
        disabled
        defaultValue="https://hextaui.com"
        aria-label="Link"
      />
      <InputGroupAddon>
        <IconLink />
      </InputGroupAddon>
      <InputGroupAddon align="inline-end">
        <InputGroupButton disabled>Copy</InputGroupButton>
      </InputGroupAddon>
    </InputGroup>
  )
}
```

### Loading

Inline addons ease to their new width when their content changes, so the field never jumps as a spinner turns into a result count. The spinner only turns when motion is allowed, and `role="status"` announces the text.

```tsx title="components/examples/input-group/loading.tsx"
"use client"

import * as React from "react"
import { IconLoader2, IconSearch } from "@tabler/icons-react"

import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
  InputGroupText,
} from "@/components/ui/input-group"

const packages = ["react", "react-dom", "react-hook-form", "redux", "remix"]

export function InputGroupLoading() {
  const [query, setQuery] = React.useState("re")
  const [pending, setPending] = React.useState(false)
  const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined)
  const results = packages.filter((name) => name.includes(query.trim()))

  React.useEffect(() => () => clearTimeout(timer.current), [])

  return (
    <InputGroup className="max-w-sm">
      <InputGroupInput
        value={query}
        onChange={(event) => {
          setQuery(event.target.value)
          setPending(true)
          clearTimeout(timer.current)
          timer.current = setTimeout(() => setPending(false), 600)
        }}
        aria-label="Search packages"
        aria-busy={pending}
      />
      <InputGroupAddon>
        <IconSearch />
      </InputGroupAddon>
      <InputGroupAddon align="inline-end">
        <InputGroupText role="status">
          {pending ? (
            <>
              <IconLoader2 className="motion-safe:animate-spin" aria-hidden />
              Searching
            </>
          ) : results.length === 1 ? (
            "1 package"
          ) : (
            `${results.length} packages`
          )}
        </InputGroupText>
      </InputGroupAddon>
    </InputGroup>
  )
}
```

### Dropdown

Render an `<InputGroupButton />` as a dropdown trigger to scope the input.

```tsx title="components/examples/input-group/dropdown.tsx"
import { IconChevronDown } from "@tabler/icons-react"

import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from "@/components/ui/input-group"

export function InputGroupDropdown() {
  return (
    <InputGroup className="max-w-sm">
      <InputGroupInput placeholder="Enter a search term" aria-label="Search" />
      <InputGroupAddon align="inline-end">
        <DropdownMenu>
          <DropdownMenuTrigger render={<InputGroupButton />}>
            Everywhere
            <IconChevronDown data-icon="inline-end" />
          </DropdownMenuTrigger>
          <DropdownMenuContent align="end">
            <DropdownMenuGroup>
              <DropdownMenuItem>Everywhere</DropdownMenuItem>
              <DropdownMenuItem>This project</DropdownMenuItem>
              <DropdownMenuItem>Documentation</DropdownMenuItem>
            </DropdownMenuGroup>
          </DropdownMenuContent>
        </DropdownMenu>
      </InputGroupAddon>
    </InputGroup>
  )
}
```

### Long content

Long values scroll inside the input instead of stretching the group. Wrap long addon text in a truncating span with a max width.

```tsx title="components/examples/input-group/long-content.tsx"
import { IconWorld } from "@tabler/icons-react"

import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
  InputGroupText,
} from "@/components/ui/input-group"

export function InputGroupLongContent() {
  return (
    <div className="flex w-72 flex-col gap-4">
      <InputGroup>
        <InputGroupInput
          defaultValue="averyveryverylongunbrokenvaluethatkeepsgoingandgoing"
          aria-label="Value"
        />
        <InputGroupAddon>
          <IconWorld />
        </InputGroupAddon>
        <InputGroupAddon align="inline-end">
          <InputGroupText>.example.com</InputGroupText>
        </InputGroupAddon>
      </InputGroup>
      <InputGroup>
        <InputGroupInput placeholder="name" aria-label="Subdomain" />
        <InputGroupAddon>
          <InputGroupText className="max-w-28">
            <span className="truncate">
              https://workspace-with-a-long-name.
            </span>
          </InputGroupText>
        </InputGroupAddon>
      </InputGroup>
    </div>
  )
}
```

### Right to left

Addons, padding and corner radii use logical sides, so inline-start lands on the right.

```tsx title="components/examples/input-group/rtl.tsx"
import { IconArrowUp, IconSearch } from "@tabler/icons-react"

import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
  InputGroupText,
} from "@/components/ui/input-group"

export function InputGroupRtl() {
  return (
    <div dir="rtl" className="flex w-full max-w-sm flex-col gap-4">
      <InputGroup>
        <InputGroupInput placeholder="ابحث…" aria-label="بحث" />
        <InputGroupAddon>
          <IconSearch />
        </InputGroupAddon>
        <InputGroupAddon align="inline-end">
          <kbd>⌘K</kbd>
        </InputGroupAddon>
      </InputGroup>
      <InputGroup>
        <InputGroupInput placeholder="0.00" aria-label="السعر" />
        <InputGroupAddon>
          <InputGroupText>ر.س</InputGroupText>
        </InputGroupAddon>
        <InputGroupAddon align="inline-end">
          <InputGroupButton variant="default" size="icon-xs" aria-label="إرسال">
            <IconArrowUp />
          </InputGroupButton>
        </InputGroupAddon>
      </InputGroup>
    </div>
  )
}
```

## Keyboard

| Key | Action |
| --- | --- |
| `Tab` | Moves from the field to each addon button, in source order. |
| `Shift` `Tab` | Moves back through the buttons and the field. |
| `Escape` | With an InputGroupClear, clears the field. When it's already empty, Escape passes through. |

## Accessibility

- Every field needs a name. Use a visible label, a Field, or `aria-label`. Icons and addon text aren't part of the field's name.
- Give icon-only buttons an `aria-label`.
- When addon text carries meaning, like a currency or a domain, add it to the label or reference it with `aria-describedby`.
- `<InputGroupClear />` is skipped in the tab order because Escape does the same thing. The password toggle stays tabbable and keeps the same name, with `aria-pressed` reporting its state.
- When a submit finds the field invalid, the group shakes once. With reduced motion the red border is the only cue.
- On touch screens the field text is at least 16px so phones don't zoom in when it's focused.

## API reference

`<InputGroupInput />` and `<InputGroupTextarea />` accept the props of the elements they render. The other parts accept their element's attributes.

### InputGroup

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `size` | `"sm" \| "default" \| "lg"` | `"default"` | Height of the group, passed down to the input. |

| Attribute | Description |
| --- | --- |
| `data-slot="input-group"` | Target the group in CSS. Renders role="group". |
| `data-size` | The current size. |
| `data-filled` | Present while the field has a value. |
| `data-shake` | Present while the group shakes after a failed submit. |
| `data-disabled` | Set it yourself to dim the group when only the addons are disabled. |
| `--input-group-radius` | Corner radius of the group. Buttons and key caps derive their radius from it. |
| `--input-group-height` | Height of the group for the current size. |

### InputGroupInput

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `size` | `"sm" \| "default" \| "lg"` | – | Inherited from the group. |
| `aria-invalid` | `boolean` | – |  |
| `disabled` | `boolean` | `false` |  |
| `readOnly` | `boolean` | `false` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="input-group-control"` | Marks the field. The group reads its focus, invalid, disabled and read-only states from it. |
| `data-invalid` | Present when a surrounding Field marks the value invalid. |
| `data-disabled` | Present when the field is disabled. |
| `data-focused` | Present while the field has focus. |
| `data-filled` | Present when the field has a value. |
| `data-dirty` | Present once the value differs from the initial one. |
| `data-touched` | Present once the field has been focused and left. |

### InputGroupTextarea

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `autoResize` | `boolean` | `true` | Grow with the content up to 16rem, easing between heights. |
| `shake` | `boolean` | `true` | Shake the group when a submit finds it invalid. |
| `rows` | `number` | – |  |
| `aria-invalid` | `boolean` | – |  |
| `disabled` | `boolean` | `false` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="input-group-control"` | Marks the field. The group reads its focus, invalid, disabled and read-only states from it. |
| `data-invalid` | Present when a surrounding Field marks the value invalid. |
| `data-disabled` | Present when the field is disabled. |
| `data-focused` | Present while the field has focus. |
| `data-filled` | Present when the field has a value. |
| `data-dirty` | Present once the value differs from the initial one. |
| `data-touched` | Present once the field has been focused and left. |

### InputGroupAddon

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `align` | `"inline-start" \| "inline-end" \| "block-start" \| "block-end"` | `"inline-start"` |  |
| `separator` | `boolean` | `false` | Draw a hairline between a block addon and the field. |

| Attribute | Description |
| --- | --- |
| `data-slot="input-group-addon"` | Target addons in CSS. |
| `data-align` | The current alignment. |
| `data-separator` | Present when separator is set. |
| `--input-group-addon-inset` | Space between the group's edge and a button or key cap inside it. |

### InputGroupButton

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"default" \| "outline" \| "secondary" \| "ghost" \| "destructive" \| "link"` | `"ghost"` |  |
| `size` | `"xs" \| "sm" \| "icon-xs" \| "icon-sm"` | `"xs"` |  |
| `type` | `string` | `"button"` |  |
| `feedback` | `boolean` | `false` | Every Button prop works, including the loading and success flow. |

| Attribute | Description |
| --- | --- |
| `data-slot="input-group-button"` | Target addon buttons in CSS. |
| `data-size` | The current size. |

### InputGroupClear

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onClear` | `() => void` | – | Called after the field is cleared. |
| `aria-label` | `string` | `"Clear"` |  |
| `children` | `ReactNode` | `<IconX />` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="input-group-clear"` | Target the clear button in CSS. |
| `data-visible` | Present while the field has a value and is editable. |

### InputGroupPasswordToggle

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `revealed` | `boolean` | – | Controlled state. Leave unset to let it manage itself. |
| `onRevealedChange` | `(revealed: boolean) => void` | – |  |
| `aria-label` | `string` | `"Show password"` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="input-group-password-toggle"` | Target the toggle in CSS. |
| `data-revealed` | Present while the password is shown. |

### InputGroupCount

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `threshold` | `number` | `10% of maxLength, at most 20` | Characters left when the count starts to stand out. |
| `announcement` | `(remaining: number) => string` | – | Message for screen readers when the count crosses the threshold or hits the limit. |

| Attribute | Description |
| --- | --- |
| `data-slot="input-group-count"` | Target the count in CSS. |
| `data-state="near" \| "limit"` | Present within the threshold, and when no characters are left. |
| `data-bump` | Present briefly when a key is pressed at the limit. |

### InputGroupText

| Attribute | Description |
| --- | --- |
| `data-slot="input-group-text"` | Target addon text in CSS. |

## 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
