# Item

> List row with media, title, description and actions, for settings, people, files and notifications.

Category: Layout. Docs: https://neonkit.heyaadi.com/components/item

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/item.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/item`.

npm dependencies: `@radix-ui/react-slot`. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item"

<Item variant="outline">
  <ItemMedia variant="icon">
    <KeyRound />
  </ItemMedia>
  <ItemContent>
    <ItemTitle>Passkeys</ItemTitle>
    <ItemDescription>2 registered</ItemDescription>
  </ItemContent>
  <ItemActions>
    <Button variant="ghost" size="sm">Manage</Button>
  </ItemActions>
</Item>
```

## Examples

### Settings

Rows with a switch, a button and a link. The link row lights its icon and edge on hover.

```tsx
"use client";

import * as React from "react";
import { ChevronRight, KeyRound, Laptop, ShieldCheck, Smartphone } from "@/components/ui/icons";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemMedia,
  ItemSeparator,
  ItemTitle,
} from "@/components/ui/item";

export default function ItemDemo() {
  const [twoFactor, setTwoFactor] = React.useState(true);

  return (
    <div className="min-w-0 w-full max-w-xl">
      <h3 className="px-4 pb-2 text-[15px] font-semibold text-white/90">Sign-in and security</h3>
      <ItemGroup>
        <Item>
          <ItemMedia variant="icon">
            <ShieldCheck />
          </ItemMedia>
          <ItemContent>
            <ItemTitle id="two-factor">Two-factor authentication</ItemTitle>
            <ItemDescription lines="none">
              {twoFactor ? "A code from your authenticator app at every sign-in." : "Off. Anyone with your password can sign in."}
            </ItemDescription>
          </ItemContent>
          <ItemActions>
            <Switch aria-labelledby="two-factor" checked={twoFactor} onCheckedChange={setTwoFactor} />
          </ItemActions>
        </Item>
        <ItemSeparator inset={70} />
        <Item>
          <ItemMedia variant="icon">
            <KeyRound />
          </ItemMedia>
          <ItemContent>
            <ItemTitle>Passkeys</ItemTitle>
            <ItemDescription lines="none">2 registered. Last used on a MacBook Pro, 3 days ago.</ItemDescription>
          </ItemContent>
          <ItemActions>
            <Button variant="ghost" size="sm">
              Manage
            </Button>
          </ItemActions>
        </Item>
        <ItemSeparator inset={70} />
        <Item asChild>
          <a href="#sessions">
            <ItemMedia variant="icon">
              <Laptop />
            </ItemMedia>
            <ItemContent>
              <ItemTitle>Active sessions</ItemTitle>
              <ItemDescription lines="none">4 devices in Lisbon and Berlin</ItemDescription>
            </ItemContent>
            <ItemActions>
              <ChevronRight className="size-4 text-white/35 transition-transform duration-200 group-hover/item:translate-x-0.5" />
            </ItemActions>
          </a>
        </Item>
        <ItemSeparator inset={70} />
        <Item>
          <ItemMedia variant="icon">
            <Smartphone />
          </ItemMedia>
          <ItemContent>
            <ItemTitle>Recovery codes</ItemTitle>
            <ItemDescription lines="none">Generated 14 March. 8 of 10 unused.</ItemDescription>
          </ItemContent>
          <ItemActions>
            <Button variant="ghost" size="sm">
              Regenerate
            </Button>
          </ItemActions>
        </Item>
      </ItemGroup>
    </div>
  );
}
```

### People

Avatars as media, a badge in the title, and a muted row for a pending invite.

```tsx
import { Ellipsis } from "@/components/ui/icons";
import { Avatar } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item";

const members = [
  { name: "Ana Lima", email: "ana@acme.dev", role: "Owner", initials: "AL", status: "online" as const, you: true },
  { name: "Tomás Okafor", email: "tomas@acme.dev", role: "Admin", initials: "TO", status: "away" as const },
  { name: "Mei Takahashi", email: "mei@acme.dev", role: "Member", initials: "MT", status: "offline" as const },
];

export default function ItemPeople() {
  return (
    <ItemGroup className="min-w-0 w-full max-w-xl gap-1">
      {members.map((member) => (
        <Item key={member.email} size="sm">
          <ItemMedia>
            <Avatar fallback={member.initials} status={member.status} alt={member.name} />
          </ItemMedia>
          <ItemContent>
            <ItemTitle>
              <span>{member.name}</span>
              {member.you && (
                <Badge size="sm" variant="soft">
                  You
                </Badge>
              )}
            </ItemTitle>
            <ItemDescription lines={1}>
              {member.email}
              <span className="sm:hidden"> · {member.role}</span>
            </ItemDescription>
          </ItemContent>
          <ItemActions>
            <span className="hidden text-[13px] text-white/55 sm:inline">{member.role}</span>
            <Button variant="ghost" size="icon" className="size-8" aria-label={`Options for ${member.name}`}>
              <Ellipsis />
            </Button>
          </ItemActions>
        </Item>
      ))}
      <Item size="sm" variant="muted">
        <ItemMedia>
          <span className="flex size-9 items-center justify-center rounded-full border border-dashed border-white/20 text-[12px] text-white/50">
            JR
          </span>
        </ItemMedia>
        <ItemContent>
          <ItemTitle className="text-white/70">jonas@riverline.studio</ItemTitle>
          <ItemDescription lines="none">Invited by Ana, 2 days ago. Expires Friday.</ItemDescription>
        </ItemContent>
        <ItemActions>
          <Button variant="ghost" size="sm">
            Resend
          </Button>
        </ItemActions>
      </Item>
    </ItemGroup>
  );
}
```

### Notifications

active lights the edge of unread rows. Clicking one marks it read.

```tsx
"use client";

import * as React from "react";
import { GitMerge, Rocket, TriangleAlert } from "@/components/ui/icons";
import {
  Item,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item";

const initial = [
  {
    id: "n1",
    icon: TriangleAlert,
    title: "Build failed on main",
    body: "Type error in checkout/summary.tsx:48, from Tomás’s last push.",
    time: "2m",
    unread: true,
  },
  {
    id: "n2",
    icon: GitMerge,
    title: "Mei merged #1284",
    body: "Move invoices to the new ledger API. 14 files, +312 −190.",
    time: "1h",
    unread: true,
  },
  {
    id: "n3",
    icon: Rocket,
    title: "Production deploy finished",
    body: "dpl_8f2k1 is live in fra1, iad1 and sfo1. Built in 41s.",
    time: "3h",
    unread: false,
  },
];

export default function ItemNotifications() {
  const [items, setItems] = React.useState(initial);
  const unread = items.filter((item) => item.unread).length;

  return (
    <div className="min-w-0 w-full max-w-xl">
      <div className="flex items-baseline justify-between px-1 pb-3">
        <h3 className="text-[15px] font-semibold text-white/90">Notifications</h3>
        <span className="text-[13px] tabular-nums text-white/55">{unread ? `${unread} unread` : "All read"}</span>
      </div>
      <ItemGroup className="gap-2">
        {items.map(({ id, icon: Icon, title, body, time, unread: isUnread }) => (
          <Item
            key={id}
            asChild
            variant="outline"
            active={isUnread}
          >
            <button
              type="button"
              onClick={() => setItems((list) => list.map((item) => (item.id === id ? { ...item, unread: false } : item)))}
            >
              <ItemMedia variant="icon" className="self-start">
                <Icon />
              </ItemMedia>
              <ItemContent>
                <ItemTitle>
                  <span>{title}</span>
                  <span className="ml-auto shrink-0 text-xs font-normal tabular-nums text-white/50">{time}</span>
                </ItemTitle>
                <ItemDescription>{body}</ItemDescription>
              </ItemContent>
            </button>
          </Item>
        ))}
      </ItemGroup>
    </div>
  );
}
```

### Files

asChild buttons as a selectable list. The lit edge follows the selection.

```tsx
"use client";

import * as React from "react";
import { FileArchive, FileCode2, FileImage, FileText } from "@/components/ui/icons";
import {
  Item,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item";

const files = [
  { name: "brand-guidelines-v4.pdf", meta: "PDF · 18.2 MB · edited Tuesday", icon: FileText },
  { name: "hero-poster@2x.png", meta: "PNG · 4.1 MB · 3200 × 1800", icon: FileImage },
  { name: "tokens.json", meta: "JSON · 12 KB · synced from Figma", icon: FileCode2 },
  { name: "launch-assets.zip", meta: "ZIP · 212 MB · 46 files", icon: FileArchive },
];

export default function ItemFiles() {
  const [selected, setSelected] = React.useState(files[1].name);

  return (
    <ItemGroup aria-label="Files" className="min-w-0 w-full max-w-md gap-0.5">
      {files.map(({ name, meta, icon: Icon }) => (
        <Item key={name} asChild size="sm" active={selected === name}>
          <button type="button" aria-pressed={selected === name} onClick={() => setSelected(name)}>
            <ItemMedia variant="icon" className="size-9">
              <Icon />
            </ItemMedia>
            <ItemContent>
              <ItemTitle className="font-mono text-[13px] font-normal">{name}</ItemTitle>
              <ItemDescription lines={1} className="text-xs">{meta}</ItemDescription>
            </ItemContent>
          </button>
        </Item>
      ))}
    </ItemGroup>
  );
}
```

### Variants and sizes

```tsx
import { CreditCard } from "@/components/ui/icons";
import { Button } from "@/components/ui/button";
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from "@/components/ui/item";

const rows = [
  { variant: "default", size: "sm", label: "Default, sm" },
  { variant: "outline", size: "md", label: "Outline, md" },
  { variant: "muted", size: "lg", label: "Muted, lg" },
] as const;

export default function ItemVariants() {
  return (
    <div className="flex min-w-0 w-full max-w-xl flex-col gap-3">
      {rows.map((row) => (
        <Item key={row.label} variant={row.variant} size={row.size}>
          <ItemMedia variant="icon">
            <CreditCard />
          </ItemMedia>
          <ItemContent>
            <ItemTitle>Visa ending 4242</ItemTitle>
            <ItemDescription>{row.label} · expires 08/29</ItemDescription>
          </ItemContent>
          <ItemActions>
            <Button variant="ghost" size="sm">
              Edit
            </Button>
          </ItemActions>
        </Item>
      ))}
    </div>
  );
}
```

## API reference

### Item

A row: media, content and actions. Renders a div and accepts every native div attribute.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"default" \| "outline" \| "muted"` | `"default"` | Bare, bordered, or on a faint fill. |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | Minimum height 48, 64 or 80px, with matching padding. |
| `active` | `boolean` | `false` | Lights the leading edge, for the selected or unread row. |
| `asChild` | `boolean` | `false` | Renders the single child (a link or button) as the row, with hover and focus styles. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. |

### ItemMedia

The leading slot.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"default" \| "icon" \| "image"` | `"default"` | Bare, an icon on a tile that lights with the row, or a photo cropped to a circle. |

### ItemDescription

Secondary text under the title.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `lines` | `1 \| 2 \| "none"` | `2` | Clamp to one line with an ellipsis or two, or "none" to wrap in full. Two or more lines pin the media to the top. |

### ItemGroup, ItemSeparator

A list of rows and a hairline between them.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `ItemGroup` | `div` | — | role="list"; rows inside become listitems, links get a listitem wrapper. |
| `inset` | `number` | `0` | ItemSeparator: start the rule this many px in, to line up with the text. |

### ItemContent, ItemTitle, ItemActions

Layout parts. Each accepts native div attributes.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `ItemTitle` | `div` | — | A flex row, so badges and timestamps can sit beside the title. Wrap the title text in a span to truncate it. |
| `ItemActions` | `div` | — | Trailing controls, kept at their natural width. |

## Accessibility

- Item is layout only. Make a row interactive with asChild and a real <a> or <button>, never an onClick on the div.
- Inside ItemGroup, rows are listitems of a list, so screen readers announce the count.
- Interactive rows get the standard focus ring; the lit edge is an extra signal, not the only one.

## Source

`components/ui/item.tsx`

```tsx
"use client";

import * as React from "react";
import { Slot, Slottable } from "@radix-ui/react-slot";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

/** The single element in `node`. Server Components can pass it as a lazy reference, which Children resolves. */
function resolveElement(node: React.ReactNode): React.ReactElement | null {
  const [child] = React.Children.toArray(node);
  return React.isValidElement(child) ? child : null;
}

type RenderProp = { asChild?: boolean; render?: React.ReactElement; nativeButton?: boolean; children?: React.ReactNode };

/**
 * Base UI spells asChild as `render={<Link />}` plus `nativeButton`, and the shadcn CLI rewrites
 * asChild into that form in base-* projects. Folds it back into asChild and children.
 */
function fromRenderProp<P extends RenderProp>({ render, nativeButton, ...props }: P): Omit<P, "render" | "nativeButton"> {
  void nativeButton; // Only meaningful to Base UI.
  if (!render) return props;
  const children = props.children === undefined ? render : React.cloneElement(render, undefined, props.children);
  return { ...props, asChild: true, children };
}

const ItemGroupContext = React.createContext(false);

const variants = {
  default: "border-transparent",
  outline: "border-white/10",
  muted: "border-transparent bg-white/[0.03]",
} as const;

const sizes = {
  sm: "min-h-12 gap-3 rounded-lg px-3 py-2",
  md: "min-h-16 gap-3.5 rounded-xl px-4 py-3",
  lg: "min-h-20 gap-4 rounded-xl px-5 py-4",
} as const;

// Links, buttons and anything focusable get a hover wash and a focus ring; static rows stay still.
const interactive = cn(
  "[&:is(a,button,[tabindex]):hover]:bg-white/[0.035] [&:is(a,button,[tabindex])]:cursor-pointer",
  "[&:is(a,button,[tabindex])]:active:scale-[0.995]",
  "focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]",
);

export interface ItemProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "color"> {
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  variant?: keyof typeof variants;
  size?: keyof typeof sizes;
  /** Lights the row's edge, for the selected or current row in a list. */
  active?: boolean;
  /** Render the single child (e.g. a link) as the row. */
  asChild?: boolean;
  /** Base UI-style asChild: `render={<Link href="/" />}`. */
  render?: React.ReactElement;
  /** Accepted for Base UI compatibility; no effect. */
  nativeButton?: boolean;
}

const Item = React.forwardRef<HTMLDivElement, ItemProps>((raw, ref) => {
  const {
    className,
    style,
    color,
    variant = "default",
    size = "md",
    active = false,
    asChild = false,
    children,
    ...props
  } = fromRenderProp(raw);
  const inGroup = React.useContext(ItemGroupContext);
  const Comp = asChild ? Slot : "div";

  const row = (
    <Comp
      ref={ref}
      role={inGroup && !asChild ? "listitem" : undefined}
      data-variant={variant}
      data-size={size}
      data-active={active ? "" : undefined}
      style={neonVars(color, style)}
      className={cn(
        "[--neon:var(--neon-accent,34_211_238)]",
        "group/item relative flex w-full min-w-0 items-center border text-left text-white/90 outline-none",
        "transition-[background-color,border-color,transform,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none",
        interactive,
        variants[variant],
        sizes[size],
        active &&
          (variant === "default"
            ? "bg-[rgb(var(--neon)/0.06)]"
            : "border-[rgb(var(--neon)/0.3)] bg-[rgb(var(--neon)/0.05)] shadow-[inset_0_0_24px_-12px_rgb(var(--neon)/CALC(0.5*var(--neon-glow,1)))]"),
        className,
      )}
      {...props}
    >
      <Filament />
      {asChild ? <Slottable>{resolveElement(children) ?? children}</Slottable> : children}
    </Comp>
  );

  // A link can't also be a listitem, so in a group it gets a wrapper that is.
  if (inGroup && asChild) {
    return (
      <div role="listitem" className="contents">
        {row}
      </div>
    );
  }
  return row;
});
Item.displayName = "Item";

/** The thin tube on the leading edge: dim on hover, fully lit on the active row. */
function Filament() {
  return (
    <span
      aria-hidden
      className={cn(
        "pointer-events-none absolute bottom-3 left-0 top-3 w-0.5 rounded-full bg-[rgb(var(--neon))] opacity-0",
        "transition-[opacity,box-shadow] duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]",
        "group-[:is(a,button,[tabindex]):hover]/item:opacity-40",
        "group-data-[active]/item:opacity-100 group-data-[active]/item:shadow-[0_0_6px_rgb(var(--neon)/CALC(0.9*var(--neon-glow,1))),0_0_16px_rgb(var(--neon)/CALC(0.45*var(--neon-glow,1)))]",
      )}
    />
  );
}

const mediaVariants = {
  default: "",
  icon: cn(
    "size-10 rounded-[10px] border border-white/[0.08] bg-white/[0.03] text-white/60 [&_svg]:size-[18px]",
    "transition-[color,border-color,box-shadow,background-color] duration-200",
    "group-[:is(a,button,[tabindex]):hover]/item:text-[rgb(var(--neon))] group-[:is(a,button,[tabindex]):hover]/item:[&_svg]:[filter:drop-shadow(0_0_5px_rgb(var(--neon)/CALC(0.6*var(--neon-glow,1))))]",
    "group-data-[active]/item:border-[rgb(var(--neon)/0.35)] group-data-[active]/item:text-[rgb(var(--neon))] group-data-[active]/item:[&_svg]:[filter:drop-shadow(0_0_5px_rgb(var(--neon)/CALC(0.6*var(--neon-glow,1))))]",
  ),
  image: "size-10 overflow-hidden rounded-full bg-white/[0.04] [&_img]:size-full [&_img]:object-cover",
} as const;

export interface ItemMediaProps extends React.HTMLAttributes<HTMLDivElement> {
  /** `icon` boxes an icon that lights on hover; `image` crops a photo or avatar to a circle. */
  variant?: keyof typeof mediaVariants;
}

const ItemMedia = React.forwardRef<HTMLDivElement, ItemMediaProps>(
  ({ className, variant = "default", ...props }, ref) => (
    <div
      ref={ref}
      data-variant={variant}
      className={cn(
        "relative flex shrink-0 items-center justify-center self-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
        // Rows with two lines of description pin the media to the top instead.
        "group-has-[[data-item-description][data-lines='2']]/item:self-start group-has-[[data-item-description][data-lines='none']]/item:self-start",
        mediaVariants[variant],
        className,
      )}
      {...props}
    />
  ),
);
ItemMedia.displayName = "ItemMedia";

const ItemContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, ...props }, ref) => (
    <div ref={ref} className={cn("flex min-w-0 flex-1 flex-col gap-0.5", className)} {...props} />
  ),
);
ItemContent.displayName = "ItemContent";

const ItemTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, ...props }, ref) => (
    <div
      ref={ref}
      className={cn(
        "flex min-w-0 items-center gap-2 text-sm font-medium leading-5 text-white/90 [&>span:first-child]:truncate",
        className,
      )}
      {...props}
    />
  ),
);
ItemTitle.displayName = "ItemTitle";

export interface ItemDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {
  /** Clamp to this many lines, or `"none"` to let it wrap in full. */
  lines?: 1 | 2 | "none";
}

const ItemDescription = React.forwardRef<HTMLParagraphElement, ItemDescriptionProps>(
  ({ className, lines = 2, ...props }, ref) => (
    <p
      ref={ref}
      data-item-description=""
      data-lines={lines}
      className={cn(
        "text-[13px] leading-5 text-white/55 [&_a]:text-white/80 [&_a]:underline [&_a]:decoration-white/25 [&_a]:underline-offset-2",
        lines === 1 ? "truncate" : lines === 2 ? "line-clamp-2" : "text-pretty",
        className,
      )}
      {...props}
    />
  ),
);
ItemDescription.displayName = "ItemDescription";

const ItemActions = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, ...props }, ref) => (
    <div ref={ref} className={cn("relative flex shrink-0 items-center gap-2", className)} {...props} />
  ),
);
ItemActions.displayName = "ItemActions";

const ItemGroup = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
  ({ className, ...props }, ref) => (
    <ItemGroupContext.Provider value>
      <div ref={ref} role="list" className={cn("flex flex-col", className)} {...props} />
    </ItemGroupContext.Provider>
  ),
);
ItemGroup.displayName = "ItemGroup";

export interface ItemSeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Start the rule after the media column instead of at the row's edge, in px. */
  inset?: number;
}

/** A hairline between rows. Decorative, so lists keep only listitems as children. */
const ItemSeparator = React.forwardRef<HTMLDivElement, ItemSeparatorProps>(
  ({ className, inset = 0, style, ...props }, ref) => (
    <div
      ref={ref}
      aria-hidden
      style={{ marginLeft: inset || undefined, ...style }}
      className={cn("h-px shrink-0 bg-white/[0.06]", className)}
      {...props}
    />
  ),
);
ItemSeparator.displayName = "ItemSeparator";

export { Item, ItemMedia, ItemContent, ItemTitle, ItemDescription, ItemActions, ItemGroup, ItemSeparator };
```
