# Item

> A row of media, text and actions for lists, settings and pickers, with a grouped surface and a hover highlight that glides between rows.

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

```tsx title="components/examples/item/demo.tsx"
import {
  IconBell,
  IconDeviceDesktop,
  IconKey,
  IconUserCircle,
} from "@tabler/icons-react"

import {
  Item,
  ItemChevron,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemMedia,
  ItemTitle,
  type ItemMediaTone,
} from "@/components/ui/item"

const settings: {
  href: string
  icon: typeof IconBell
  title: string
  tone: ItemMediaTone
  description: string
}[] = [
  {
    href: "#profile",
    icon: IconUserCircle,
    title: "Profile",
    tone: "gray",
    description: "Name, photo and handle",
  },
  {
    href: "#notifications",
    icon: IconBell,
    title: "Notifications",
    tone: "red",
    description: "Mentions, replies and digests",
  },
  {
    href: "#security",
    icon: IconKey,
    title: "Password and passkeys",
    tone: "green",
    description: "Two passkeys, last used today",
  },
  {
    href: "#sessions",
    icon: IconDeviceDesktop,
    title: "Sessions",
    tone: "blue",
    description: "Signed in on 3 devices",
  },
]

export function ItemDemo() {
  return (
    <ItemGroup variant="grouped" className="max-w-sm">
      {settings.map((setting) => (
        <Item key={setting.href} size="sm" render={<a href={setting.href} />}>
          <ItemMedia variant="icon" tone={setting.tone}>
            <setting.icon />
          </ItemMedia>
          <ItemContent>
            <ItemTitle>{setting.title}</ItemTitle>
            <ItemDescription>{setting.description}</ItemDescription>
          </ItemContent>
          <ItemChevron />
        </Item>
      ))}
    </ItemGroup>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/item.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/item.tsx"
"use client"

import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { IconChevronRight } from "@tabler/icons-react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

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

function dataAttributes(slot: string, extra?: Record<string, string>) {
  return { "data-slot": slot, ...extra } as Record<string, string>
}

const interactiveSelector =
  'a[href], button, label, summary, [role="button"], [role="link"], [role="option"], [role="radio"], [role="checkbox"], [role="menuitem"]'

function isDisabled(element: Element) {
  return (
    element.matches(":disabled") ||
    element.getAttribute("aria-disabled") === "true"
  )
}

function markInteractive(element: HTMLElement | null) {
  if (!element) {
    return
  }
  if (element.matches(interactiveSelector)) {
    element.setAttribute("data-interactive", "")
  } else {
    element.removeAttribute("data-interactive")
  }
}

type ItemGroupVariant = "default" | "grouped"

const itemGroupVariants = cva(
  "group/item-group relative isolate flex w-full min-w-0 flex-col",
  {
    variants: {
      variant: {
        default:
          "gap-4 has-[>[data-size=sm]]:gap-2.5 has-[>[data-size=xs]]:gap-2",
        grouped:
          "rounded-(--item-group-radius) bg-card text-card-foreground ring-(length:--hairline) ring-border [--item-group-radius:var(--radius-xl)] forced-colors:border [&>[data-slot=item]]:rounded-none [&>[data-slot=item]:not(:has(~[data-slot=item]))]:rounded-b-[calc(var(--item-group-radius)-var(--hairline))] [&>[data-slot=item]:not([data-slot=item]~[data-slot=item])]:rounded-t-[calc(var(--item-group-radius)-var(--hairline))]",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
)

type ItemGroupProps = useRender.ComponentProps<"div"> & {
  variant?: ItemGroupVariant
  highlight?: boolean
  arrowNavigation?: boolean
}

function useHighlight(enabled: boolean) {
  const groupRef = React.useRef<HTMLElement | null>(null)
  const highlightRef = React.useRef<HTMLDivElement | null>(null)
  const currentRef = React.useRef<HTMLElement | null>(null)
  const hiddenAtRef = React.useRef(-Infinity)

  const place = React.useCallback((item: HTMLElement, instant: boolean) => {
    const highlight = highlightRef.current
    if (!highlight) {
      return
    }
    if (instant || prefersReducedMotion()) {
      highlight.setAttribute("data-instant", "")
    } else {
      highlight.removeAttribute("data-instant")
    }
    const group = item.parentElement
    const box = item.getBoundingClientRect()
    const frame = group?.getBoundingClientRect()
    const scale =
      group && frame && group.offsetWidth > 0
        ? frame.width / group.offsetWidth
        : 1
    const x =
      frame && group
        ? (box.left - frame.left) / scale - group.clientLeft
        : item.offsetLeft
    const y =
      frame && group
        ? (box.top - frame.top) / scale - group.clientTop
        : item.offsetTop
    highlight.style.left = "0px"
    highlight.style.width = `${box.width / scale}px`
    highlight.style.height = `${box.height / scale}px`
    highlight.style.transform = `translate(${x}px, ${y}px)`
    highlight.style.borderRadius = getComputedStyle(item).borderRadius
  }, [])

  const hide = React.useCallback(() => {
    if (highlightRef.current?.hasAttribute("data-visible")) {
      hiddenAtRef.current = performance.now()
    }
    currentRef.current?.removeAttribute("data-highlighted")
    currentRef.current = null
    highlightRef.current?.removeAttribute("data-visible")
    highlightRef.current?.removeAttribute("data-pressed")
  }, [])

  const show = React.useCallback(
    (item: HTMLElement) => {
      const highlight = highlightRef.current
      if (!highlight || currentRef.current === item) {
        return
      }
      const appearing =
        !highlight.hasAttribute("data-visible") &&
        performance.now() - hiddenAtRef.current > 200
      currentRef.current?.removeAttribute("data-highlighted")
      currentRef.current = item
      item.setAttribute("data-highlighted", "")
      place(item, appearing)
      if (appearing) {
        void highlight.offsetWidth
      }
      highlight.setAttribute("data-visible", "")
    },
    [place]
  )

  React.useEffect(() => {
    const group = groupRef.current
    if (!enabled || !group) {
      return
    }

    const itemFrom = (target: EventTarget | null) => {
      const item = (target as Element | null)?.closest<HTMLElement>(
        "[data-slot=item][data-interactive]"
      )
      if (
        !item ||
        item.parentElement !== group ||
        isDisabled(item) ||
        item.hasAttribute("data-disabled")
      ) {
        return null
      }
      return item
    }

    const onPointerOver = (event: PointerEvent) => {
      if (event.pointerType === "touch") {
        return
      }
      const item = itemFrom(event.target)
      if (item) {
        show(item)
      } else if (event.target !== group) {
        hide()
      }
    }
    const onPointerDown = (event: PointerEvent) => {
      if (event.pointerType !== "touch" && itemFrom(event.target)) {
        highlightRef.current?.setAttribute("data-pressed", "")
      }
    }
    const onPointerUp = () => {
      highlightRef.current?.removeAttribute("data-pressed")
    }

    const resize =
      typeof ResizeObserver === "undefined"
        ? null
        : new ResizeObserver(() => {
            if (currentRef.current?.isConnected) {
              place(currentRef.current, true)
            } else if (currentRef.current) {
              hide()
            }
          })
    resize?.observe(group)

    group.addEventListener("pointerover", onPointerOver)
    group.addEventListener("pointerleave", hide)
    group.addEventListener("pointerdown", onPointerDown)
    window.addEventListener("pointerup", onPointerUp)
    window.addEventListener("pointercancel", onPointerUp)

    return () => {
      resize?.disconnect()
      group.removeEventListener("pointerover", onPointerOver)
      group.removeEventListener("pointerleave", hide)
      group.removeEventListener("pointerdown", onPointerDown)
      window.removeEventListener("pointerup", onPointerUp)
      window.removeEventListener("pointercancel", onPointerUp)
      hide()
    }
  }, [enabled, show, hide, place])

  return { groupRef, highlightRef }
}

function useListRole(groupRef: React.RefObject<HTMLElement | null>) {
  const [list, setList] = React.useState(true)

  React.useEffect(() => {
    const group = groupRef.current
    if (!group) {
      return
    }
    const check = () => {
      const items = Array.from(group.children).filter(
        (child) => child.getAttribute("data-slot") === "item"
      )
      setList(
        items.length > 0 &&
          items.every((item) => item.getAttribute("role") === "listitem")
      )
    }
    check()
    const observer = new MutationObserver(check)
    observer.observe(group, { childList: true })
    return () => observer.disconnect()
  }, [groupRef])

  return list
}

function useSeparatorInset(
  groupRef: React.RefObject<HTMLElement | null>,
  enabled: boolean
) {
  React.useEffect(() => {
    const group = groupRef.current
    if (!enabled || !group) {
      return
    }

    let frame = 0
    const measure = () => {
      cancelAnimationFrame(frame)
      frame = requestAnimationFrame(() => {
        for (const item of Array.from(group.children)) {
          if (!(item instanceof HTMLElement) || item.dataset.slot !== "item") {
            continue
          }
          const content = item.querySelector<HTMLElement>(
            ":scope > [data-slot=item-content]"
          )
          if (!content) {
            item.style.removeProperty("--item-inset")
            continue
          }
          const box = item.getBoundingClientRect()
          const text = content.getBoundingClientRect()
          const scale = item.offsetWidth > 0 ? box.width / item.offsetWidth : 1
          const rtl = getComputedStyle(item).direction === "rtl"
          const inset = rtl ? box.right - text.right : text.left - box.left
          item.style.setProperty("--item-inset", `${inset / scale}px`)
        }
      })
    }

    measure()
    const resize =
      typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure)
    resize?.observe(group)
    const mutations = new MutationObserver(measure)
    mutations.observe(group, { childList: true, subtree: true })
    document.fonts?.addEventListener?.("loadingdone", measure)

    return () => {
      cancelAnimationFrame(frame)
      resize?.disconnect()
      mutations.disconnect()
      document.fonts?.removeEventListener?.("loadingdone", measure)
      for (const item of Array.from(group.children)) {
        if (item instanceof HTMLElement) {
          item.style.removeProperty("--item-inset")
        }
      }
    }
  }, [groupRef, enabled])
}

const navigationKeys = new Set(["ArrowDown", "ArrowUp", "Home", "End"])

function useArrowNavigation(
  groupRef: React.RefObject<HTMLElement | null>,
  enabled: boolean
) {
  React.useEffect(() => {
    const group = groupRef.current
    if (!enabled || !group) {
      return
    }

    const onKeyDown = (event: KeyboardEvent) => {
      const current = event.target as HTMLElement
      if (
        event.defaultPrevented ||
        !navigationKeys.has(event.key) ||
        event.altKey ||
        event.ctrlKey ||
        event.metaKey ||
        event.shiftKey ||
        current.parentElement !== group ||
        !current.matches("[data-slot=item][data-interactive]")
      ) {
        return
      }

      const items = Array.from(group.children).filter(
        (child): child is HTMLElement =>
          child instanceof HTMLElement &&
          child.matches("[data-slot=item][data-interactive]") &&
          !isDisabled(child) &&
          !child.hasAttribute("data-disabled") &&
          !child.hidden &&
          (typeof child.checkVisibility !== "function" ||
            child.checkVisibility())
      )
      const index = items.indexOf(current)
      const next =
        event.key === "Home"
          ? items[0]
          : event.key === "End"
            ? items.at(-1)
            : event.key === "ArrowDown"
              ? items[index + 1]
              : items[index - 1]

      event.preventDefault()
      if (next && next !== current) {
        next.focus()
        next.scrollIntoView?.({ block: "nearest" })
      }
    }

    group.addEventListener("keydown", onKeyDown)
    return () => group.removeEventListener("keydown", onKeyDown)
  }, [groupRef, enabled])
}

const ItemGroupContext = React.createContext<{
  variant: ItemGroupVariant
} | null>(null)

function ItemGroup({
  className,
  variant = "default",
  highlight = true,
  arrowNavigation = true,
  render,
  children,
  ...props
}: ItemGroupProps) {
  const { groupRef, highlightRef } = useHighlight(highlight)
  useArrowNavigation(groupRef, arrowNavigation)
  useSeparatorInset(groupRef, variant === "grouped")
  const list = useListRole(groupRef)
  const context = React.useMemo(() => ({ variant }), [variant])

  const element = useRender({
    defaultTagName: "div",
    render,
    ref: groupRef,
    props: mergeProps<"div">(
      {
        role: list ? "list" : undefined,
        className: cn(itemGroupVariants({ variant }), className),
        children: (
          <>
            {highlight && (
              <div
                ref={highlightRef}
                aria-hidden="true"
                data-slot="item-highlight"
                className="pointer-events-none absolute top-0 -z-1 bg-muted opacity-0 transition-[transform,width,height,opacity,border-radius,background-color] duration-200 ease-out-quint data-instant:transition-[opacity,background-color] data-pressed:bg-foreground/10 data-visible:opacity-100 motion-reduce:transition-opacity dark:bg-muted/70"
              />
            )}
            {children}
          </>
        ),
      },
      props,
      dataAttributes("item-group", {
        "data-variant": variant,
        ...(highlight ? { "data-highlight": "" } : {}),
      })
    ),
  })

  return (
    <ItemGroupContext.Provider value={context}>
      {element}
    </ItemGroupContext.Provider>
  )
}

function ItemSeparator({
  className,
  render,
  ...props
}: useRender.ComponentProps<"div">) {
  return useRender({
    defaultTagName: "div",
    render,
    props: mergeProps<"div">(
      {
        role: "none",
        "aria-hidden": true,
        className: cn(
          "h-(--hairline) w-full shrink-0 bg-border group-data-[variant=default]/item-group:my-2",
          className
        ),
      },
      props,
      dataAttributes("item-separator")
    ),
  })
}

const itemVariants = cva(
  "group/item relative flex w-full min-w-0 flex-wrap items-center gap-(--item-gap) rounded-(--item-radius) px-(--item-px) py-(--item-py) text-start text-sm text-foreground transition-[background-color,box-shadow,scale,translate,opacity] duration-200 ease-out-quint outline-none [--item-inset:var(--item-px)] before:pointer-events-none before:absolute focus-visible:outline-hidden focus-visible:before:opacity-0 has-data-checked:bg-muted/60 group-data-[variant=default]/item-group:has-data-checked:inset-ring-(length:--hairline) group-data-[variant=default]/item-group:has-data-checked:inset-ring-foreground/25 has-[>[data-slot=item-media][data-variant=icon]]:[--item-inset:calc(var(--item-px)+var(--item-media-size)+var(--item-gap))] has-[>[data-slot=item-media][data-variant=image]]:[--item-inset:calc(var(--item-px)+var(--item-media-size)+var(--item-gap))] aria-checked:bg-muted/60 group-data-[variant=default]/item-group:aria-checked:inset-ring-(length:--hairline) group-data-[variant=default]/item-group:aria-checked:inset-ring-foreground/25 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-pressed:bg-muted/60 group-data-[variant=default]/item-group:aria-pressed:inset-ring-(length:--hairline) group-data-[variant=default]/item-group:aria-pressed:inset-ring-foreground/25 aria-selected:bg-muted/60 group-data-[variant=default]/item-group:aria-selected:inset-ring-(length:--hairline) group-data-[variant=default]/item-group:aria-selected:inset-ring-foreground/25 data-highlighted:before:opacity-0 data-interactive:cursor-pointer data-interactive:select-none data-interactive:focus-visible:z-10 data-interactive:focus-visible:ring-3 data-interactive:focus-visible:inset-ring-(length:--hairline) data-interactive:focus-visible:ring-focus-ring data-interactive:focus-visible:inset-ring-ring group-data-[variant=grouped]/item-group:data-interactive:focus-visible:ring-0 group-data-[variant=grouped]/item-group:data-interactive:focus-visible:inset-ring-2 group-data-[variant=grouped]/item-group:data-interactive:focus-visible:inset-ring-ring/70 data-interactive:active:duration-100 not-group-data-[variant=grouped]/item-group:data-interactive:motion-safe:active:scale-[0.985] motion-reduce:transition-none data-disabled:pointer-events-none data-disabled:opacity-50 [&:disabled]:pointer-events-none [&:disabled]:opacity-50 [@media(hover:hover)]:not-group-data-highlight/item-group:data-interactive:hover:bg-muted/60 [[data-highlighted]+&]:before:opacity-0 [[data-slot=item-separator]+&]:before:hidden [[data-slot=item]:focus-visible+&]:before:opacity-0 group-data-[variant=grouped]/item-group:[[data-slot=item]~&]:before:start-(--item-inset) group-data-[variant=grouped]/item-group:[[data-slot=item]~&]:before:inset-e-0 group-data-[variant=grouped]/item-group:[[data-slot=item]~&]:before:top-0 group-data-[variant=grouped]/item-group:[[data-slot=item]~&]:before:h-(--hairline) group-data-[variant=grouped]/item-group:[[data-slot=item]~&]:before:bg-border group-data-[variant=grouped]/item-group:[[data-slot=item]~&]:before:transition-opacity group-data-[variant=grouped]/item-group:[[data-slot=item]~&]:before:duration-200 [[data-slot=separator]+&]:before:hidden",
  {
    variants: {
      variant: {
        default: "",
        outline:
          "inset-ring-(length:--hairline) inset-ring-border group-data-[variant=grouped]/item-group:inset-ring-0 forced-colors:border",
        muted:
          "bg-muted/50 group-data-[variant=grouped]/item-group:bg-transparent",
      },
      size: {
        default:
          "[--item-gap:--spacing(3.5)] [--item-media-size:--spacing(10)] [--item-px:--spacing(4)] [--item-py:--spacing(3.5)] [--item-radius:var(--radius-lg)]",
        sm: "[--item-gap:--spacing(2.5)] [--item-media-size:--spacing(8)] [--item-px:--spacing(3)] [--item-py:--spacing(2.5)] [--item-radius:var(--radius-md)]",
        xs: "[--item-gap:--spacing(2)] [--item-media-size:--spacing(6)] [--item-px:--spacing(2.5)] [--item-py:--spacing(2)] [--item-radius:var(--radius-md)]",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

type ItemVariant = NonNullable<VariantProps<typeof itemVariants>["variant"]>
type ItemSize = NonNullable<VariantProps<typeof itemVariants>["size"]>

type ItemProps = useRender.ComponentProps<"div"> & {
  variant?: ItemVariant
  size?: ItemSize
}

function Item({
  className,
  variant = "default",
  size = "default",
  render,
  ref,
  ...props
}: ItemProps) {
  const group = React.useContext(ItemGroupContext)
  const setRef = React.useCallback(
    (node: HTMLElement | null) => {
      markInteractive(node)
      if (typeof ref === "function") {
        return ref(node as HTMLDivElement | null)
      }
      if (ref) {
        ref.current = node as HTMLDivElement | null
      }
      return undefined
    },
    [ref]
  )

  return useRender({
    defaultTagName: "div",
    render,
    ref: setRef,
    props: mergeProps<"div">(
      {
        role: group && !render ? "listitem" : undefined,
        className: cn(itemVariants({ variant, size }), className),
      },
      props,
      dataAttributes("item", { "data-variant": variant, "data-size": size })
    ),
  })
}

const itemMediaVariants = cva(
  "flex shrink-0 items-center justify-center gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
  {
    variants: {
      variant: {
        default: "bg-transparent",
        icon: "size-(--item-media-size) rounded-[max(calc(var(--radius-sm)*0.75),calc(var(--item-radius)-var(--item-py)))] bg-muted text-foreground ring-(length:--hairline) ring-foreground/5 group-data-[size=xs]/item:not-data-tone:bg-transparent group-data-[size=xs]/item:not-data-tone:ring-0 data-tone:bg-(--item-media-tone) data-tone:bg-linear-to-b data-tone:from-white/18 data-tone:to-transparent data-tone:text-white data-tone:shadow-[inset_0_var(--hairline)_0_rgb(255_255_255/0.25)] data-tone:ring-black/8 dark:data-tone:ring-white/10 forced-colors:border [&_svg:not([class*='size-'])]:size-4 group-data-[size=default]/item:[&_svg:not([class*='size-'])]:size-5",
        image:
          "size-(--item-media-size) overflow-hidden rounded-[max(calc(var(--radius-sm)*0.75),calc(var(--item-radius)-var(--item-py)))] bg-muted ring-(length:--hairline) ring-foreground/5 forced-colors:border [&_img]:size-full [&_img]:object-cover",
      },
      tone: {
        gray: "[--item-media-tone:var(--color-zinc-500)]",
        red: "[--item-media-tone:var(--color-red-500)]",
        orange: "[--item-media-tone:var(--color-orange-500)]",
        yellow: "[--item-media-tone:var(--color-amber-400)]",
        green: "[--item-media-tone:var(--color-green-500)]",
        teal: "[--item-media-tone:var(--color-teal-500)]",
        sky: "[--item-media-tone:var(--color-sky-500)]",
        blue: "[--item-media-tone:var(--color-blue-500)]",
        indigo: "[--item-media-tone:var(--color-indigo-500)]",
        purple: "[--item-media-tone:var(--color-purple-500)]",
        pink: "[--item-media-tone:var(--color-pink-500)]",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
)

type ItemMediaTone = NonNullable<VariantProps<typeof itemMediaVariants>["tone"]>

type ItemMediaVariant = NonNullable<
  VariantProps<typeof itemMediaVariants>["variant"]
>

type ItemMediaProps = useRender.ComponentProps<"div"> & {
  variant?: ItemMediaVariant
  tone?: ItemMediaTone
}

function ItemMedia({
  className,
  variant = "default",
  tone,
  render,
  ...props
}: ItemMediaProps) {
  const tinted = variant === "icon" && tone ? tone : undefined

  return useRender({
    defaultTagName: "div",
    render,
    props: mergeProps<"div">(
      {
        className: cn(itemMediaVariants({ variant, tone: tinted }), className),
        ...(variant === "icon" ? { "aria-hidden": true } : {}),
      },
      props,
      dataAttributes("item-media", {
        "data-variant": variant,
        ...(tinted ? { "data-tone": tinted } : {}),
      })
    ),
  })
}

function ItemContent({
  className,
  render,
  ...props
}: useRender.ComponentProps<"div">) {
  return useRender({
    defaultTagName: "div",
    render,
    props: mergeProps<"div">(
      {
        className: cn(
          "flex min-w-0 flex-1 flex-col gap-1 group-data-[size=sm]/item:gap-0 group-data-[size=xs]/item:gap-0",
          className
        ),
      },
      props,
      dataAttributes("item-content")
    ),
  })
}

function ItemTitle({
  className,
  render,
  ...props
}: useRender.ComponentProps<"div">) {
  return useRender({
    defaultTagName: "div",
    render,
    props: mergeProps<"div">(
      {
        className: cn(
          "line-clamp-1 flex w-fit max-w-full min-w-0 items-center gap-2 text-sm leading-snug font-medium wrap-anywhere",
          className
        ),
      },
      props,
      dataAttributes("item-title")
    ),
  })
}

function ItemDescription({
  className,
  render,
  ...props
}: useRender.ComponentProps<"p">) {
  return useRender({
    defaultTagName: "p",
    render,
    props: mergeProps<"p">(
      {
        className: cn(
          "line-clamp-2 min-w-0 text-start text-sm leading-normal font-normal text-pretty wrap-anywhere text-muted-foreground group-data-[size=xs]/item:text-xs [&_a:not([data-slot])]:text-foreground [&_a:not([data-slot])]:underline [&_a:not([data-slot])]:decoration-foreground/30 [&_a:not([data-slot])]:underline-offset-4 [&_a:not([data-slot])]:hover:decoration-foreground",
          className
        ),
      },
      props,
      dataAttributes("item-description") as React.ComponentProps<"p">
    ),
  })
}

function ItemActions({
  className,
  render,
  ...props
}: useRender.ComponentProps<"div">) {
  return useRender({
    defaultTagName: "div",
    render,
    props: mergeProps<"div">(
      { className: cn("flex shrink-0 items-center gap-2", className) },
      props,
      dataAttributes("item-actions")
    ),
  })
}

function ItemHeader({
  className,
  render,
  ...props
}: useRender.ComponentProps<"div">) {
  return useRender({
    defaultTagName: "div",
    render,
    props: mergeProps<"div">(
      {
        className: cn(
          "flex min-w-0 basis-full items-center justify-between gap-2",
          className
        ),
      },
      props,
      dataAttributes("item-header")
    ),
  })
}

function ItemFooter({
  className,
  render,
  ...props
}: useRender.ComponentProps<"div">) {
  return useRender({
    defaultTagName: "div",
    render,
    props: mergeProps<"div">(
      {
        className: cn(
          "flex min-w-0 basis-full items-center justify-between gap-2",
          className
        ),
      },
      props,
      dataAttributes("item-footer")
    ),
  })
}

function ItemChevron({
  className,
  ...props
}: React.ComponentProps<typeof IconChevronRight>) {
  return (
    <IconChevronRight
      aria-hidden="true"
      data-slot="item-chevron"
      className={cn(
        "size-4 shrink-0 text-muted-foreground transition-[translate,color] duration-200 ease-out-quint group-data-highlighted/item:text-foreground motion-safe:group-data-highlighted/item:translate-x-0.5 motion-reduce:transition-colors rtl:-scale-x-100 motion-safe:rtl:group-data-highlighted/item:-translate-x-0.5 [@media(hover:hover)]:group-data-interactive/item:group-hover/item:text-foreground motion-safe:[@media(hover:hover)]:group-data-interactive/item:group-hover/item:translate-x-0.5 motion-safe:[@media(hover:hover)]:rtl:group-data-interactive/item:group-hover/item:-translate-x-0.5",
        className
      )}
      {...props}
    />
  )
}

export {
  Item,
  ItemActions,
  ItemChevron,
  ItemContent,
  ItemDescription,
  ItemFooter,
  ItemGroup,
  ItemHeader,
  ItemMedia,
  ItemSeparator,
  ItemTitle,
  itemGroupVariants,
  itemMediaVariants,
  itemVariants,
}
export type { ItemGroupProps, ItemMediaProps, ItemMediaTone, ItemProps }
```

```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 {
  Item,
  ItemActions,
  ItemChevron,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item"
```

```tsx
<ItemGroup variant="grouped">
  <Item render={<a href="/settings/profile" />}>
    <ItemMedia variant="icon">
      <IconUserCircle />
    </ItemMedia>
    <ItemContent>
      <ItemTitle>Profile</ItemTitle>
      <ItemDescription>Name, photo and handle</ItemDescription>
    </ItemContent>
    <ItemChevron />
  </Item>
</ItemGroup>
```

Give icon media a `tone` for colored tiles like iOS Settings, or leave it unset for a neutral tile. An item becomes interactive when it renders as a link, button or label through `render`. Only then does it get hover, press and focus styles, so static rows never pretend to be clickable.

## Composition

```text
ItemGroup
├── Item
│   ├── ItemHeader
│   ├── ItemMedia
│   ├── ItemContent
│   │   ├── ItemTitle
│   │   └── ItemDescription
│   ├── ItemActions
│   ├── ItemChevron
│   └── ItemFooter
└── ItemSeparator
```

## Examples

### Grouped

`variant="grouped"` puts items on one surface. Separators are drawn for you and inset to the text, the first and last rows own the outer corners, and the separators next to a hovered or focused row fade out.

```tsx title="components/examples/item/pressed.tsx"
"use client"

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

import {
  Item,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemTitle,
} from "@/components/ui/item"

const themes = [
  { id: "system", title: "System", description: "Follow your device" },
  { id: "light", title: "Light", description: "Always light" },
  { id: "dark", title: "Dark", description: "Always dark" },
]

export function ItemPressed() {
  const [theme, setTheme] = React.useState("system")

  return (
    <ItemGroup variant="grouped" className="max-w-sm">
      {themes.map((option) => (
        <Item
          key={option.id}
          size="sm"
          render={<button type="button" />}
          aria-pressed={theme === option.id}
          onClick={() => setTheme(option.id)}
        >
          <ItemContent>
            <ItemTitle>{option.title}</ItemTitle>
            <ItemDescription>{option.description}</ItemDescription>
          </ItemContent>
          {theme === option.id && (
            <IconCheck className="size-4 text-foreground" aria-hidden="true" />
          )}
        </Item>
      ))}
    </ItemGroup>
  )
}
```

### Hover highlight

Interactive items in a group share one highlight that glides to the row under the pointer and takes on its corners. It only follows a mouse or pen, never touch, and turn it off with `highlight={false}`. Image media corners are concentric with the row.

```tsx title="components/examples/item/image.tsx"
import {
  Item,
  ItemChevron,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item"

const albums = [
  {
    title: "Night Drive",
    artist: "Lune Avenue",
    src: "https://images.unsplash.com/photo-1514525253161-7a46d19cd819?w=120&h=120&fit=crop",
  },
  {
    title: "Paper Gardens",
    artist: "Mara Lin",
    src: "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?w=120&h=120&fit=crop",
  },
  {
    title: "Low Tide",
    artist: "The Harbour",
    src: "https://images.unsplash.com/photo-1511379938547-c1f69419868d?w=120&h=120&fit=crop",
  },
]

export function ItemImage() {
  return (
    <ItemGroup className="max-w-sm">
      {albums.map((album) => (
        <Item key={album.title} size="sm" render={<a href="#" />}>
          <ItemMedia variant="image">
            <img src={album.src} alt="" width={40} height={40} />
          </ItemMedia>
          <ItemContent>
            <ItemTitle>{album.title}</ItemTitle>
            <ItemDescription>{album.artist}</ItemDescription>
          </ItemContent>
          <ItemChevron />
        </Item>
      ))}
    </ItemGroup>
  )
}
```

### Selectable

Render an item as a `<label>` around a Checkbox and the whole row toggles it. A checked control, or `aria-selected`, `aria-checked` or `aria-pressed` on the item, marks the row selected.

```tsx title="components/examples/item/selectable.tsx"
import { Checkbox } from "@/components/ui/checkbox"
import {
  Item,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemTitle,
} from "@/components/ui/item"

const plans = [
  {
    id: "analytics",
    title: "Analytics",
    description: "Page views and funnels",
  },
  { id: "backups", title: "Daily backups", description: "Kept for 30 days" },
  { id: "sso", title: "Single sign-on", description: "SAML and OIDC" },
]

export function ItemSelectable() {
  return (
    <ItemGroup className="max-w-sm">
      {plans.map((plan, index) => (
        <Item key={plan.id} variant="outline" size="sm" render={<label />}>
          <Checkbox
            name="addons"
            value={plan.id}
            defaultChecked={index === 0}
          />
          <ItemContent>
            <ItemTitle>{plan.title}</ItemTitle>
            <ItemDescription>{plan.description}</ItemDescription>
          </ItemContent>
        </Item>
      ))}
    </ItemGroup>
  )
}
```

### Variants

`default` has no surface, `outline` draws a hairline and `muted` sits on a soft fill.

```tsx title="components/examples/item/variants.tsx"
import { IconFileText } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item"

const variants = ["default", "outline", "muted"] as const

export function ItemVariants() {
  return (
    <div className="flex w-full max-w-md flex-col gap-4">
      {variants.map((variant) => (
        <Item key={variant} variant={variant}>
          <ItemMedia variant="icon">
            <IconFileText />
          </ItemMedia>
          <ItemContent>
            <ItemTitle>Quarterly report.pdf</ItemTitle>
            <ItemDescription>2.4 MB · {variant}</ItemDescription>
          </ItemContent>
          <ItemActions>
            <Button variant="outline" size="sm">
              Open
            </Button>
          </ItemActions>
        </Item>
      ))}
    </div>
  )
}
```

### Sizes

`size` scales padding, gap, corner radius and media together. A group tightens its gap to match.

```tsx title="components/examples/item/sizes.tsx"
import { IconMusic } from "@tabler/icons-react"

import {
  Item,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item"

const sizes = ["default", "sm", "xs"] as const

export function ItemSizes() {
  return (
    <ItemGroup className="max-w-sm">
      {sizes.map((size) => (
        <Item key={size} size={size} variant="outline">
          <ItemMedia variant="icon">
            <IconMusic />
          </ItemMedia>
          <ItemContent>
            <ItemTitle>Size {size}</ItemTitle>
            <ItemDescription>
              Padding, gap and media scale together.
            </ItemDescription>
          </ItemContent>
        </Item>
      ))}
    </ItemGroup>
  )
}
```

### Avatar

The default `<ItemMedia />` only centers its content, so an Avatar or AvatarGroup drops straight in.

```tsx title="components/examples/item/avatar.tsx"
import { IconPlus } from "@tabler/icons-react"

import {
  Avatar,
  AvatarFallback,
  AvatarGroup,
  AvatarImage,
} from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item"

const team = [
  { handle: "preetsuthar17", initials: "PS" },
  { handle: "emilkowalski", initials: "EK" },
  { handle: "rauchg", initials: "GR" },
]

export function ItemAvatar() {
  return (
    <div className="flex w-full max-w-lg flex-col gap-6">
      <Item variant="outline">
        <ItemMedia>
          <Avatar size="lg">
            <AvatarImage src="https://github.com/preetsuthar17.png" alt="" />
            <AvatarFallback>PS</AvatarFallback>
          </Avatar>
        </ItemMedia>
        <ItemContent>
          <ItemTitle>Preet Suthar</ItemTitle>
          <ItemDescription>Maintainer of HextaUI</ItemDescription>
        </ItemContent>
        <ItemActions>
          <Button
            variant="outline"
            size="icon-sm"

            aria-label="Invite Preet Suthar"
          >
            <IconPlus />
          </Button>
        </ItemActions>
      </Item>
      <Item variant="outline">
        <ItemMedia>
          <AvatarGroup>
            {team.map((person) => (
              <Avatar key={person.handle}>
                <AvatarImage
                  src={`https://github.com/${person.handle}.png`}
                  alt=""
                />
                <AvatarFallback>{person.initials}</AvatarFallback>
              </Avatar>
            ))}
          </AvatarGroup>
        </ItemMedia>
        <ItemContent>
          <ItemTitle>No team members</ItemTitle>
          <ItemDescription>Invite your team to collaborate.</ItemDescription>
        </ItemContent>
        <ItemActions>
          <Button size="sm" variant="outline">
            Invite
          </Button>
        </ItemActions>
      </Item>
    </div>
  )
}
```

### People

A grouped list of people. Separators start where the text starts, whatever sits in the media slot, so they line up under the names.

```tsx title="components/examples/item/people.tsx"
import { IconPlus } from "@tabler/icons-react"

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item"

const people = [
  { handle: "preetsuthar17", name: "Preet Suthar" },
  { handle: "emilkowalski", name: "Emil Kowalski" },
  { handle: "rauchg", name: "Guillermo Rauch" },
  { handle: "leerob", name: "Lee Robinson" },
]

export function ItemPeople() {
  return (
    <ItemGroup variant="grouped" className="max-w-sm">
      {people.map((person) => (
        <Item key={person.handle} size="sm">
          <ItemMedia>
            <Avatar size="lg">
              <AvatarImage
                src={`https://github.com/${person.handle}.png`}
                alt=""
              />
              <AvatarFallback>
                {person.name
                  .split(" ")
                  .map((part) => part[0])
                  .join("")}
              </AvatarFallback>
            </Avatar>
          </ItemMedia>
          <ItemContent>
            <ItemTitle>{person.name}</ItemTitle>
            <ItemDescription>@{person.handle}</ItemDescription>
          </ItemContent>
          <ItemActions>
            <Button
              variant="ghost"
              size="icon-sm"

              aria-label={`Invite ${person.name}`}
            >
              <IconPlus />
            </Button>
          </ItemActions>
        </Item>
      ))}
    </ItemGroup>
  )
}
```

### Actions

`<ItemActions />` holds buttons at the end of the row. Keep the item itself static when it holds buttons, so there's never a button inside a link.

```tsx title="components/examples/item/actions.tsx"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item"

const invites = [
  { initials: "MO", name: "Mira Okafor", email: "mira@example.com" },
  { initials: "JP", name: "Jun Park", email: "jun@example.com" },
]

export function ItemActionsDemo() {
  return (
    <ItemGroup variant="grouped" className="max-w-md">
      {invites.map((invite) => (
        <Item key={invite.email} size="sm">
          <ItemMedia>
            <Avatar>
              <AvatarFallback>{invite.initials}</AvatarFallback>
            </Avatar>
          </ItemMedia>
          <ItemContent>
            <ItemTitle>{invite.name}</ItemTitle>
            <ItemDescription>{invite.email}</ItemDescription>
          </ItemContent>
          <ItemActions>
            <Button variant="ghost" size="sm">
              Decline
            </Button>
            <Button variant="outline" size="sm">
              Accept
            </Button>
          </ItemActions>
        </Item>
      ))}
    </ItemGroup>
  )
}
```

### Header and footer

`<ItemHeader />` and `<ItemFooter />` take a full row above and below the content.

```tsx title="components/examples/item/header-footer.tsx"
import { Badge } from "@/components/ui/badge"
import {
  Item,
  ItemContent,
  ItemDescription,
  ItemFooter,
  ItemHeader,
  ItemTitle,
} from "@/components/ui/item"

export function ItemHeaderFooter() {
  return (
    <Item variant="outline" className="max-w-sm" render={<a href="#" />}>
      <ItemHeader>
        <span className="text-xs text-muted-foreground">hextaui/hextaui</span>
        <Badge>Open</Badge>
      </ItemHeader>
      <ItemContent>
        <ItemTitle>Rework the item component</ItemTitle>
        <ItemDescription>
          Grouped surfaces, a gliding hover highlight and selectable rows.
        </ItemDescription>
      </ItemContent>
      <ItemFooter>
        <span className="text-xs text-muted-foreground">
          #482 · 3 reviewers
        </span>
        <span className="text-xs text-muted-foreground">2h ago</span>
      </ItemFooter>
    </Item>
  )
}
```

### Separator

In a default group, place an `<ItemSeparator />` between items yourself.

```tsx title="components/examples/item/separator.tsx"
import {
  Item,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemSeparator,
  ItemTitle,
} from "@/components/ui/item"

export function ItemSeparatorDemo() {
  return (
    <ItemGroup className="max-w-sm">
      <Item size="sm">
        <ItemContent>
          <ItemTitle>Storage</ItemTitle>
          <ItemDescription>18.2 GB of 50 GB used</ItemDescription>
        </ItemContent>
      </Item>
      <ItemSeparator />
      <Item size="sm">
        <ItemContent>
          <ItemTitle>Bandwidth</ItemTitle>
          <ItemDescription>312 GB this month</ItemDescription>
        </ItemContent>
      </Item>
    </ItemGroup>
  )
}
```

### Long content

Titles clamp to one line and descriptions to two. Unbroken strings wrap instead of widening the row.

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

import { Button } from "@/components/ui/button"
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item"

export function ItemLongContent() {
  return (
    <Item variant="outline" className="max-w-sm">
      <ItemMedia variant="icon">
        <IconFile />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>
          final-final-v3-approved-by-legal-and-design-do-not-edit-this-copy.pdf
        </ItemTitle>
        <ItemDescription>
          Uploaded to /projects/2026/client-work/very-long-folder-names/archive
          by someone@an-extremely-long-domain-name.example.com and shared with
          the whole organisation.
        </ItemDescription>
      </ItemContent>
      <ItemActions>
        <Button variant="outline" size="sm">
          Share
        </Button>
      </ItemActions>
    </Item>
  )
}
```

### Right to left

Padding, separator insets and the chevron follow the reading direction.

```tsx title="components/examples/item/rtl.tsx"
import { IconBell, IconUserCircle } from "@tabler/icons-react"

import {
  Item,
  ItemChevron,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item"

export function ItemRtl() {
  return (
    <div dir="rtl" className="w-full max-w-sm">
      <ItemGroup variant="grouped">
        <Item size="sm" render={<a href="#" />}>
          <ItemMedia variant="icon">
            <IconUserCircle />
          </ItemMedia>
          <ItemContent>
            <ItemTitle>الملف الشخصي</ItemTitle>
            <ItemDescription>الاسم والصورة</ItemDescription>
          </ItemContent>
          <ItemChevron />
        </Item>
        <Item size="sm" render={<a href="#" />}>
          <ItemMedia variant="icon">
            <IconBell />
          </ItemMedia>
          <ItemContent>
            <ItemTitle>الإشعارات</ItemTitle>
            <ItemDescription>الإشارات والردود</ItemDescription>
          </ItemContent>
          <ItemChevron />
        </Item>
      </ItemGroup>
    </div>
  )
}
```

## Keyboard

| Key | Action |
| --- | --- |
| `Tab` | Moves to the next interactive item, in source order like any link or button. |
| `Enter` | Follows a link item or presses a button item. |
| `Space` | Presses a button item or toggles a label item. |

## Accessibility

- A group is a list when its items are plain rows. Items rendered as links or buttons keep their own role, and the group drops the list role so the markup stays valid. Render the group as `<ul>` and items as `<li>` wrapping links if you want both.
- `<ItemMedia variant="icon" />` and the chevron are hidden from screen readers. Give images real alt text when they carry meaning.
- The highlight is decoration. Keyboard focus shows the focus ring, and the highlight never moves focus or changes the tab order.

## API reference

Every part renders a `<div>` by default and accepts `render` and its element's attributes.

### Item

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"default" \| "outline" \| "muted"` | `"default"` |  |
| `size` | `"default" \| "sm" \| "xs"` | `"default"` |  |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<div>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="item"` | Target items in CSS. |
| `data-variant` | The current variant. |
| `data-size` | The current size. |
| `data-interactive` | Present when the item renders as a link, button or label. |
| `data-highlighted` | Present while the group highlight is on it. |
| `--item-radius` | Corner radius. Media corners derive from it. Set by size. |
| `--item-px / --item-py / --item-gap` | Padding and gap. Set by size. |
| `--item-media-size` | Size of icon and image media. Set by size. |
| `--item-inset` | Where a grouped separator starts. Measured to line up with ItemContent, whatever the media is. |

### ItemGroup

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"default" \| "grouped"` | `"default"` | grouped puts items on one surface with automatic separators. |
| `highlight` | `boolean` | `true` | Show one highlight that glides between interactive items on hover. |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<div>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="item-group"` | Target groups in CSS. |
| `data-variant` | The current variant. |
| `data-highlight` | Present when the hover highlight is on. |
| `--item-group-radius` | Corner radius of a grouped surface. The first and last items follow it. |
| `data-slot="item-highlight"` | The highlight element, with data-visible and data-pressed. |

### ItemMedia

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"default" \| "icon" \| "image"` | `"default"` | icon puts the icon on a tile and hides it from screen readers. image crops to a square. Both have corners concentric with the item. |
| `tone` | `"gray" \| "red" \| "orange" \| "yellow" \| "green" \| "teal" \| "sky" \| "blue" \| "indigo" \| "purple" \| "pink"` | – | With the icon variant, fills the tile with a solid color and turns the icon white, like iOS Settings. Leave it unset for a neutral tile. |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<div>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="item-media"` | Target media in CSS. |
| `data-variant` | The current variant. |
| `data-tone` | The tile color, when tone is set on an icon. |
| `--item-media-tone` | The tile color. Set it to any color for a custom tone. |

### ItemContent

| Attribute | Description |
| --- | --- |
| `data-slot="item-content"` | Grows to fill the row. A second content block hugs its text. |

### ItemTitle

| Attribute | Description |
| --- | --- |
| `data-slot="item-title"` | Clamped to one line. |

### ItemDescription

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<p>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="item-description"` | Clamped to two lines. |

### ItemActions

| Attribute | Description |
| --- | --- |
| `data-slot="item-actions"` | Target the actions in CSS. |

### ItemHeader and ItemFooter

| Attribute | Description |
| --- | --- |
| `data-slot="item-header"` | A full-width row above the content. |
| `data-slot="item-footer"` | A full-width row below the content. |

### ItemChevron

A trailing chevron that nudges toward the reading direction when its item is hovered. Accepts every Tabler icon prop.

| Attribute | Description |
| --- | --- |
| `data-slot="item-chevron"` | Target the chevron in CSS. |

### ItemSeparator

| Attribute | Description |
| --- | --- |
| `data-slot="item-separator"` | A hairline with role="separator". |

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