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.
Installation
Run this in your project. It adds the files, installs the npm packages and anything this builds on, and sets up the theme:
npx shadcn@latest add https://kryptonite-ui.vercel.app/r/item.jsonInstall manually
Install the dependencies:
npm install @base-ui/react @tabler/icons-react class-variance-authority cnIt builds on these, so copy them first:
Copy this into
components/ui/item.tsx: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 }
Examples
Actions
Avatar
Hover highlight
Long content
People
Grouped
Right to left
Selectable
Separator
Sizes
Variants
Feedback
Questions, bugs or ideas for Item? Post them here, or reply to someone else. Sign in with GitHub to comment.