A row of media, text and actions for lists, settings and pickers, with a grouped surface and a hover highlight that glides between rows.
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>
)
}pnpm dlx shadcn@latest add https://hextaui.com/r/item.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
- 1
Add the theme tokens to your global CSS, if you haven’t yet.
- 2
Install the dependencies.
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cn - 3
Copy and paste the following code into your project.
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 }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 } - 4
Update the import paths to match your project setup.
import {
Item,
ItemActions,
ItemChevron,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from "@/components/ui/item"<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.
ItemGroup
├── Item
│ ├── ItemHeader
│ ├── ItemMedia
│ ├── ItemContent
│ │ ├── ItemTitle
│ │ └── ItemDescription
│ ├── ItemActions
│ ├── ItemChevron
│ └── ItemFooter
└── ItemSeparatorGrouped#
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.
"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.
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.
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.
2.4 MB · default
2.4 MB · outline
2.4 MB · muted
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.
Padding, gap and media scale together.
Padding, gap and media scale together.
Padding, gap and media scale together.
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.
Maintainer of HextaUI
Invite your team to collaborate.
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.
@preetsuthar17
@emilkowalski
@rauchg
@leerob
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.
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: "[email protected]" },
{ initials: "JP", name: "Jun Park", email: "[email protected]" },
]
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.
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.
18.2 GB of 50 GB used
312 GB this month
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.
Uploaded to /projects/2026/client-work/very-long-folder-names/archive by [email protected] and shared with the whole organisation.
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 [email protected] 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.
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>
)
}| 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. |
- 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.
Every part renders a <div> by default and accepts render and its element's attributes.
| Prop | Type | Default |
|---|---|---|
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. |
| Prop | Type | Default |
|---|---|---|
variantgrouped puts items on one surface with automatic separators. | "default" | "grouped" | "default" |
highlightShow one highlight that glides between interactive items on hover. | boolean | true |
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. |
| Prop | Type | Default |
|---|---|---|
varianticon puts the icon on a tile and hides it from screen readers. image crops to a square. Both have corners concentric with the item. | "default" | "icon" | "image" | "default" |
toneWith 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. | "gray" | "red" | "orange" | "yellow" | "green" | "teal" | "sky" | "blue" | "indigo" | "purple" | "pink" | – |
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. |
| Attribute | Description |
|---|---|
data-slot="item-content" | Grows to fill the row. A second content block hugs its text. |
| Attribute | Description |
|---|---|
data-slot="item-title" | Clamped to one line. |
| Prop | Type | Default |
|---|---|---|
render | ReactElement | (props, state) => ReactElement | <p> |
| Attribute | Description |
|---|---|
data-slot="item-description" | Clamped to two lines. |
| Attribute | Description |
|---|---|
data-slot="item-actions" | Target the actions in CSS. |
| Attribute | Description |
|---|---|
data-slot="item-header" | A full-width row above the content. |
data-slot="item-footer" | A full-width row below the content. |
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. |
| Attribute | Description |
|---|---|
data-slot="item-separator" | A hairline with role="separator". |