HextaUI

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.

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.json

Adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

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
└── ItemSeparator

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.

"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.

Quarterly report.pdf

2.4 MB · default

Quarterly report.pdf

2.4 MB · outline

Quarterly report.pdf

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.

Size default

Padding, gap and media scale together.

Size sm

Padding, gap and media scale together.

Size xs

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.

PS
Preet Suthar

Maintainer of HextaUI

PSEKGR
No team members

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.

PS
Preet Suthar

@preetsuthar17

EK
Emil Kowalski

@emilkowalski

GR
Guillermo Rauch

@rauchg

LR
Lee Robinson

@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.

MO
JP
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>
  )
}

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

Storage

18.2 GB of 50 GB used

Bandwidth

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.

final-final-v3-approved-by-legal-and-design-do-not-edit-this-copy.pdf

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>
  )
}
KeyAction
TabMoves to the next interactive item, in source order like any link or button.
EnterFollows a link item or presses a button item.
SpacePresses 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.

PropTypeDefault
variant
"default" | "outline" | "muted""default"
size
"default" | "sm" | "xs""default"
render
ReactElement | (props, state) => ReactElement<div>
AttributeDescription
data-slot="item"Target items in CSS.
data-variantThe current variant.
data-sizeThe current size.
data-interactivePresent when the item renders as a link, button or label.
data-highlightedPresent while the group highlight is on it.
--item-radiusCorner radius. Media corners derive from it. Set by size.
--item-px / --item-py / --item-gapPadding and gap. Set by size.
--item-media-sizeSize of icon and image media. Set by size.
--item-insetWhere a grouped separator starts. Measured to line up with ItemContent, whatever the media is.
PropTypeDefault
variantgrouped puts items on one surface with automatic separators.
"default" | "grouped""default"
highlightShow one highlight that glides between interactive items on hover.
booleantrue
render
ReactElement | (props, state) => ReactElement<div>
AttributeDescription
data-slot="item-group"Target groups in CSS.
data-variantThe current variant.
data-highlightPresent when the hover highlight is on.
--item-group-radiusCorner 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.
PropTypeDefault
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>
AttributeDescription
data-slot="item-media"Target media in CSS.
data-variantThe current variant.
data-toneThe tile color, when tone is set on an icon.
--item-media-toneThe tile color. Set it to any color for a custom tone.
AttributeDescription
data-slot="item-content"Grows to fill the row. A second content block hugs its text.
AttributeDescription
data-slot="item-title"Clamped to one line.
PropTypeDefault
render
ReactElement | (props, state) => ReactElement<p>
AttributeDescription
data-slot="item-description"Clamped to two lines.
AttributeDescription
data-slot="item-actions"Target the actions in CSS.
AttributeDescription
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.

AttributeDescription
data-slot="item-chevron"Target the chevron in CSS.
AttributeDescription
data-slot="item-separator"A hairline with role="separator".