Item
A list and row primitive with 3 variants and 2 sizes, composed from media, content, title, description and actions parts, grouped with separators.
Visa ending in 4242
Weekly product digest
import { CreditCard, MoreVertical } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemSeparator,
ItemTitle,
} from "@/components/ui/item"
export default function ItemDemo() {
return (
<ItemGroup className="w-full max-w-sm rounded-xl border border-border">
<Item>
<ItemMedia variant="icon">
<CreditCard />
</ItemMedia>
<ItemContent>
<ItemTitle>Billing</ItemTitle>
<ItemDescription>Visa ending in 4242</ItemDescription>
</ItemContent>
<ItemActions>
<Button variant="ghost" size="icon-sm" aria-label="More">
<MoreVertical />
</Button>
</ItemActions>
</Item>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>Notifications</ItemTitle>
<ItemDescription>Weekly product digest</ItemDescription>
</ItemContent>
<ItemActions>
<Button variant="outline" size="sm">
Manage
</Button>
</ItemActions>
</Item>
</ItemGroup>
)
}Installation
Run the following command
npx shadcn@latest add @ai2/itemDependencies, the @ai2/separator component used by ItemSeparator, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install class-variance-authority@^0.7.1 radix-ui@^1.6.1Add the cn util
lib/utils.tsimport { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/* Adds a source-attribution ref param to a URL (the inspiration exports mark their
outbound links with an ai2.design attribution). An invalid URL is returned as is.
This file is SHOWN TO THE CONSUMER: the docs component pages render the source of
`cn` in a code block, so a Turkish comment here would reach every one of those
pages. Keep it English. */
export function withRef(url: string, ref = "ai2.design"): string {
try {
const u = new URL(url)
u.searchParams.set("ref", ref)
return u.toString()
} catch {
return url
}
}Copy the source code
components/ui/item.tsximport type * as React from "react"
import { Slot } from "@/components/ui/primitives"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn("group/item-group flex flex-col", className)}
{...props}
/>
)
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-0", className)}
{...props}
/>
)
}
const itemVariants = cva(
"group/item flex flex-wrap items-center rounded-lg border border-transparent text-sm outline-none transition-colors duration-(--motion-fast) focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [a]:transition-colors [a]:hover:bg-accent/50",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border-border",
muted: "bg-muted/50",
},
size: {
default: "gap-4 p-4",
sm: "gap-2.5 px-4 py-3",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Item({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div"
// ItemGroup carries role="list"; children of a list must be listitem (APG).
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
role="listitem"
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
)
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:translate-y-0.5 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none",
{
variants: {
variant: {
default: "bg-transparent",
icon: "size-8 rounded-md border border-border bg-muted [&_svg:not([class*='size-'])]:size-4 [&_i]:text-base [&_i]:leading-none",
image:
"size-10 overflow-hidden rounded-md [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
)
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
className
)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium leading-snug",
className
)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"line-clamp-2 text-sm font-normal leading-normal text-balance text-muted-foreground",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
itemVariants,
itemMediaVariants,
}Item imports the ai2 Separator for ItemSeparator, so copy that file too. Manual installs also skip the @ai2/tokens theme, so add the token CSS from the theming guide or the muted surface colors will be missing.
Usage
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from "@/components/ui/item"
import { CreditCard } from "lucide-react"
<Item variant="outline">
<ItemMedia variant="icon">
<CreditCard />
</ItemMedia>
<ItemContent>
<ItemTitle>Billing</ItemTitle>
<ItemDescription>Visa ending in 4242</ItemDescription>
</ItemContent>
<ItemActions>
<Button variant="outline" size="sm">Manage</Button>
</ItemActions>
</Item>Every part is optional. Compose a row from the parts you need, then wrap several rows in an ItemGroup with ItemSeparator between them to build a list.
Examples
Variants
Transparent surface.
Bordered standalone row.
Soft filled surface.
Sizes
Comfortable gap and padding.
Compact rows for dense lists.
Media
A small bordered muted tile.
A rounded box clipping an image.
Group with separators
Visa ending in 4242
Weekly product digest
Whole-row link
With asChild, pass exactly one child element and put every row part inside it, so Radix Slot has a single node to merge the row styling onto.
Props
These axes live on the root Item.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "outline" | "muted" | "default" | Surface treatment on the row: transparent, bordered, or a soft muted fill. |
size | "default" | "sm" | "default" | Row density. default uses gap-4 with p-4; sm tightens to gap-2.5 with px-4 py-3. |
asChild | boolean | false | Merge the row styling onto the single child element (for example an a tag) via Radix Slot, so the whole row becomes one link or button. |
ItemMedia carries its own variant axis. The remaining parts (ItemGroup, ItemContent, ItemTitle, ItemDescription, ItemActions, ItemHeader and ItemFooter) are plain wrappers, and ItemSeparator forwards to the ai2 Separator.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "icon" | "image" | "default" | Media treatment on ItemMedia. default is a transparent wrapper, icon is a small bordered muted tile, and image is a larger rounded box that clips an img to cover. |
ai2 Item: a list and row primitive for React
The ai2 Item is a shadcn-compatible row primitive for React, styled with Tailwind CSS v4 on top of the shared ai2 token file. It is the building block for settings lists, notification feeds, file rows, command results and any horizontal row that pairs a piece of media with a title, a description and a set of actions.
Because it ships through the shadcn registry format, you install it with one CLI command, an MCP agent, or a copy-paste, and the source lands in your own project. You own the file; there is no runtime dependency on ai2 itself. The live example above is the exact component you get.
What is the ai2 Item?
It is a composition of ten parts. The row itself is Item, and it holds ItemMedia, ItemContent (which wraps ItemTitle and ItemDescription), ItemActions, plus ItemHeader and ItemFooter for full-width rows above and below the main content.
To build a list, wrap rows in ItemGroup and divide them with ItemSeparator, which is the ai2 Separator preset for zero vertical margin. The root carries two cva axes, variant and size, and asChild turns the whole row into a single link or button.
Why use it
- Three surface variants: default is transparent for rows inside a bordered group, outline draws a border around a standalone row, and muted gives a soft filled surface.
- Two density sizes: size="default" uses gap-4 and p-4 for comfortable rows; size="sm" tightens to gap-2.5 with px-4 py-3 for compact lists.
- Three media treatments: ItemMedia offers default (transparent), icon (a small bordered tile) and image (a larger rounded box that clips an image to cover).
- Groups and separators: ItemGroup lays out a role="list" column and ItemSeparator adds a flush divider, so a full settings list is a group of rows with separators between them.
- Whole-row links: asChild merges the row onto an a or button element through Radix Slot, so the entire row is one accessible click target with hover styling.
- Agent-readable metadata: The registry item describes the variant matrix and every part in plain words, so an MCP agent can find, inspect and install it without guessing.
Features
- shadcn registry install: One command adds the component, the @ai2/separator dependency and the @ai2/tokens theme to your project.
- Description-aware media: When a row has an ItemDescription, ItemMedia nudges to the top and self-aligns, so the media lines up with the title on multi-line rows.
- Icon and remixicon compatible: The icon media tile sizes a lucide <svg> and a remixicon <i> child the same way, so it works with either icon family.
- Focus ring on interactive rows: The row carries a focus-visible ring, so a row turned into a link or button through asChild is keyboard-accessible out of the box.
- Data attributes for styling: The row exposes data-slot="item", data-variant and data-size, and every part has its own data-slot, so CSS overrides can target exact regions.
- Exported cva functions: itemVariants and itemMediaVariants are exported, so you can reuse the row and media treatments on other elements.
Production tips
- Use default inside a group: Rows in an ItemGroup usually want variant="default" so the group border and separators do the framing. Reserve outline for a single standalone row.
- Match size to the list: Dense settings and command lists read best with size="sm"; the default size fits primary content rows and cards.
- Reach for asChild for navigation: When a whole row should navigate, wrap a single a tag with asChild rather than nesting a stretched link, so the row itself is the click target.
- Keep exactly one child under asChild: Radix Slot needs a single element child, so put all row parts inside the one a or button you pass, not as siblings of it.
- Put secondary actions in ItemActions: ItemActions is a right-aligned flex row with a small gap, made for buttons or an icon menu, so every row in a list keeps the same rhythm.
Works with the rest of ai2
Item is a host for the rest of the registry. Drop an ai2 Avatar into the image media slot, a ai2 Badge next to the title, and ai2 Button or a ai2 Dropdown Menu into the actions slot.
The row divider reuses the ai2 Separator, and when a list has nothing to show, swap it for an ai2 Empty state. Everything shares one token source, so any combination stays consistent in both light and dark mode.