Skip to contentVibraUI
Data display

Item

A row of media, title, description and actions, for lists of things.

shadcn's base-nova item with three changes: its hover is timed on --duration-fast rather than a fixed 100ms, so it stops with everything else under reduced motion; a row rendered as a link or a button wears the kit's focus-ring, the solid 2px accent outline, where shadcn's 3px halo at half strength measured 1.98:1 on white; and ItemSeparator is hidden from the accessibility tree, so a group's list owns its items alone. Media, title, description and actions sit in a wrapping row, in three treatments and three sizes. ItemGroup is a list, so give each Item role="listitem" inside one — or, for a row that is a link, wrap it in an element that is one, since the role on the anchor would take its link role away.

Install

npx shadcn@latest add @vibra/item

Needs the @vibra registry in your components.json — set it up once.

Examples

With a face

People as rows, each face hidden beside the name it pictures. The invite button says whose invite it resends.

With actions

The common action in the row and the rest in its menu, every button named for the file it acts on.

As a link

Whole rows as links, inside list items rather than being them. The external one says it opens a new tab, and the chevron turns for right to left.

Settings row

A switch named by the row's title and described by its line, so it reads whole on its own.

Group with separators

Orders parted by ItemSeparator, drawn for the eye and hidden from the list, whose members are the orders alone.

Selectable

Each row is its checkbox's label. A chosen row takes the selected plane with the tick, and a status line counts the choice.

Dense and regular

The same rows at the regular size for a page and the dense one for a side panel, switched in place.

Props

PropTypeDefaultDescription
variant"default" | "outline" | "muted""default"Bare, bounded by a hairline, or on half the muted plane.
size"default" | "sm" | "xs""default"The row's padding and its media's size.
renderReact.ReactElement—Renders the row as another element: a link or a button, which hovers on the muted plane and takes the focus outline, or the label of a checkbox inside it.
ItemMedia.variant"default" | "icon" | "image""default"Anything, a 16px icon, or an image cropped to a square.

Dependencies

Source

components/ui/item.tsx
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
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 w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
        className
      )}
      {...props}
    />
  )
}

// ItemGroup is a list, and a list owns list items alone: a separator among
// them is a member with the wrong role. The rule is for the eye — the items
// already part — so it is hidden from the accessibility tree.
function ItemSeparator({
  className,
  ...props
}: React.ComponentProps<typeof Separator>) {
  return (
    <Separator
      data-slot="item-separator"
      orientation="horizontal"
      aria-hidden="true"
      className={cn("my-2", className)}
      {...props}
    />
  )
}

// A row rendered as a link or a button takes keyboard focus, and wears the
// kit's one focus treatment — a solid 2px outline in the accent, not shadcn's
// 3px halo at half strength, which measured 1.98:1 on white.
const itemVariants = cva(
  "group/item flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-(--duration-fast) focus-ring [a]:transition-colors [a]:hover:bg-muted",
  {
    variants: {
      variant: {
        default: "border-transparent",
        outline: "border-border",
        muted: "border-transparent bg-muted/50",
      },
      size: {
        default: "gap-2.5 px-3 py-2.5",
        sm: "gap-2.5 px-3 py-2.5",
        xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

function Item({
  className,
  variant = "default",
  size = "default",
  render,
  ...props
}: useRender.ComponentProps<"div"> & VariantProps<typeof itemVariants>) {
  return useRender({
    defaultTagName: "div",
    props: mergeProps<"div">(
      {
        className: cn(itemVariants({ variant, size, className })),
      },
      props
    ),
    render,
    state: {
      slot: "item",
      variant,
      size,
    },
  })
}

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: "[&_svg:not([class*='size-'])]:size-4",
        image:
          "size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_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 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
        className
      )}
      {...props}
    />
  )
}

function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="item-title"
      className={cn(
        "line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4",
        className
      )}
      {...props}
    />
  )
}

function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
  return (
    <p
      data-slot="item-description"
      className={cn(
        "line-clamp-2 text-start text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>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,
}