Skip to main content
Nexus Design System
GitHub

Item

Item lays out a row with media, a title, a description and actions. Use it for file lists, settings rows and search results. For tabular data, use Table.

Report.pdf

2.4 MB · edited 3 days ago

'use client';

import { FileIcon } from '@phosphor-icons/react/ssr';

import { Button } from '@/components/nxds/components/button/button';
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from '@/components/nxds/components/item/item';

export default function ItemDefault() {
  return (
    <Item variant="outline" className="nx:w-96">
      <ItemMedia variant="icon">
        <FileIcon />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>Report.pdf</ItemTitle>
        <ItemDescription>2.4 MB · edited 3 days ago</ItemDescription>
      </ItemContent>
      <ItemActions>
        <Button variant="outline" size="sm">
          Open
        </Button>
      </ItemActions>
    </Item>
  );
}

This example also needs: Button.

Installation

npx nxds-cli@latest add item

Run npx nxds-cli@latest init first if nxds isn’t set up in your app yet. See Install.

Usage

import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemGroup,
  ItemMedia,
  ItemSeparator,
  ItemTitle,
} from '@/components/nxds/components/item/item';

Composition

Item
├── ItemMedia
├── ItemContent
│   ├── ItemTitle
│   └── ItemDescription
└── ItemActions
ItemGroup
├── Item
│   └── ItemContent
│       └── ItemTitle
└── ItemSeparator

Examples

Variants

Pick a transparent, bordered or filled surface for the row.

Default

Transparent, borderless.

Outline

Bordered surface.

Muted

Filled surface.

'use client';

import {
  Item,
  ItemContent,
  ItemDescription,
  ItemTitle,
} from '@/components/nxds/components/item/item';

export default function ItemVariants() {
  return (
    <div className="nx:flex nx:w-96 nx:flex-col nx:gap-3">
      <Item variant="default">
        <ItemContent>
          <ItemTitle>Default</ItemTitle>
          <ItemDescription>Transparent, borderless.</ItemDescription>
        </ItemContent>
      </Item>
      <Item variant="outline">
        <ItemContent>
          <ItemTitle>Outline</ItemTitle>
          <ItemDescription>Bordered surface.</ItemDescription>
        </ItemContent>
      </Item>
      <Item variant="muted">
        <ItemContent>
          <ItemTitle>Muted</ItemTitle>
          <ItemDescription>Filled surface.</ItemDescription>
        </ItemContent>
      </Item>
    </div>
  );
}

Sizes

Use the sm size for a denser row.

Default size
Small size
'use client';

import { Item, ItemContent, ItemTitle } from '@/components/nxds/components/item/item';

export default function ItemSizes() {
  return (
    <div className="nx:flex nx:w-96 nx:flex-col nx:gap-3">
      <Item variant="outline" size="default">
        <ItemContent>
          <ItemTitle>Default size</ItemTitle>
        </ItemContent>
      </Item>
      <Item variant="outline" size="sm">
        <ItemContent>
          <ItemTitle>Small size</ItemTitle>
        </ItemContent>
      </Item>
    </div>
  );
}

Media

Lead the row with an icon tile or an image thumbnail.

Icon media
Image media
'use client';

import { FileIcon } from '@phosphor-icons/react/ssr';

import {
  Item,
  ItemContent,
  ItemMedia,
  ItemTitle,
} from '@/components/nxds/components/item/item';

const THUMB =
  "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Crect width='40' height='40' fill='%23999'/%3E%3C/svg%3E";

export default function ItemMediaExample() {
  return (
    <div className="nx:flex nx:w-96 nx:flex-col nx:gap-3">
      <Item variant="outline">
        <ItemMedia variant="icon">
          <FileIcon />
        </ItemMedia>
        <ItemContent>
          <ItemTitle>Icon media</ItemTitle>
        </ItemContent>
      </Item>
      <Item variant="outline">
        <ItemMedia variant="image">
          <img src={THUMB} alt="" />
        </ItemMedia>
        <ItemContent>
          <ItemTitle>Image media</ItemTitle>
        </ItemContent>
      </Item>
    </div>
  );
}

This example also needs:

npm install @phosphor-icons/react@^2.1.10

Grouped

Stack related rows in an ItemGroup with separators between them.

First
Second
'use client';

import {
  Item,
  ItemContent,
  ItemGroup,
  ItemSeparator,
  ItemTitle,
} from '@/components/nxds/components/item/item';

export default function ItemGrouped() {
  return (
    <ItemGroup className="nx:w-96">
      <Item>
        <ItemContent>
          <ItemTitle>First</ItemTitle>
        </ItemContent>
      </Item>
      <ItemSeparator />
      <Item>
        <ItemContent>
          <ItemTitle>Second</ItemTitle>
        </ItemContent>
      </Item>
    </ItemGroup>
  );
}

As child

Render the whole row as a link with asChild.

'use client';

import { FileIcon } from '@phosphor-icons/react/ssr';

import {
  Item,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from '@/components/nxds/components/item/item';

export default function ItemAsChild() {
  return (
    <Item asChild variant="outline" className="nx:w-96">
      <a href="/files/report">
        <ItemMedia variant="icon">
          <FileIcon />
        </ItemMedia>
        <ItemContent>
          <ItemTitle>Open report</ItemTitle>
          <ItemDescription>Navigates to the file.</ItemDescription>
        </ItemContent>
      </a>
    </Item>
  );
}

This example also needs:

npm install @phosphor-icons/react@^2.1.10

Props

Item

Item props
PropTypeDefaultDescription
asChildbooleanfalse

Renders the child element, such as a link, with the item styling. A link child gets hover styles with no extra classes.

size"default" | "sm" | null"default"No description
variant"default" | "outline" | "muted" | null"default"No description

ItemActions

ItemActions adds no props of its own; it accepts the props of the element or primitive it wraps.

ItemContent

ItemContent adds no props of its own; it accepts the props of the element or primitive it wraps.

ItemDescription

ItemDescription adds no props of its own; it accepts the props of the element or primitive it wraps.

ItemFooter adds no props of its own; it accepts the props of the element or primitive it wraps.

ItemGroup

ItemGroup adds no props of its own; it accepts the props of the element or primitive it wraps.

ItemHeader

ItemHeader adds no props of its own; it accepts the props of the element or primitive it wraps.

ItemMedia

ItemMedia props
PropTypeDefaultDescription
variant"default" | "icon" | "image" | null"default"No description

ItemSeparator

ItemSeparator adds no props of its own; it accepts the props of the element or primitive it wraps.

ItemTitle

ItemTitle adds no props of its own; it accepts the props of the element or primitive it wraps.

Item lays out a row with media, a title, a description and actions. Use it for file lists, settings rows and search results. For tabular data, use Table.

<script setup lang="ts">
import { PhFile } from '@phosphor-icons/vue';

import { Button } from '@/components/button';
import {
  Item,
  ItemActions,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from '@/components/item';
</script>

<template>
  <Item variant="outline" class="nx:w-96">
    <ItemMedia variant="icon"><PhFile /></ItemMedia>
    <ItemContent>
      <ItemTitle>Report.pdf</ItemTitle>
      <ItemDescription>2.4 MB · edited 3 days ago</ItemDescription>
    </ItemContent>
    <ItemActions>
      <Button variant="outline" size="sm">Open</Button>
    </ItemActions>
  </Item>
</template>

This example also needs: Button.

npm install @phosphor-icons/vue@^2.2.1

Installation

  1. Install these components first:

    Separator.

  2. Install the following dependencies:

    npm install class-variance-authority@^0.7.1
    
  3. Copy and paste the following code into your project.

    components/item/Item.vue
    <script setup lang="ts">
    import { Primitive, type PrimitiveProps } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import { type ItemVariants, itemVariants } from '.';
    
    export interface ItemProps extends PrimitiveProps {
      variant?: ItemVariants['variant'];
      size?: ItemVariants['size'];
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<ItemProps>(), {
      as: 'div',
      variant: 'default',
      size: 'default',
    });
    </script>
    
    <template>
      <Primitive
        data-slot="item"
        :data-variant="variant"
        :data-size="size"
        :as="as"
        :as-child="asChild"
        :class="cn(itemVariants({ variant, size }), props.class)"
      >
        <slot />
      </Primitive>
    </template>
    
    components/item/ItemActions.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface ItemActionsProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<ItemActionsProps>();
    </script>
    
    <template>
      <div
        data-slot="item-actions"
        :class="cn('nx:flex nx:items-center nx:gap-2', props.class)"
      >
        <slot />
      </div>
    </template>
    
    components/item/ItemContent.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface ItemContentProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<ItemContentProps>();
    
    // data-item-part is a structural hook: wrappers rename data-slot, so Item's CSS keys off it.
    </script>
    
    <template>
      <div
        data-slot="item-content"
        data-item-part="content"
        :class="
          cn(
            'nx:flex nx:min-w-0 nx:flex-1 nx:flex-col nx:gap-1 nx:[&+[data-item-part=content]]:flex-none',
            props.class
          )
        "
      >
        <slot />
      </div>
    </template>
    
    components/item/ItemDescription.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface ItemDescriptionProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<ItemDescriptionProps>();
    </script>
    
    <template>
      <p
        data-slot="item-description"
        data-item-part="description"
        :class="
          cn(
            'nx:m-0 nx:line-clamp-2 nx:typography-body-default nx:text-balance nx:text-muted-foreground nx:[&>a]:text-inherit nx:[&>a]:underline nx:[&>a]:underline-offset-4 nx:[&>a:hover]:text-primary-subtle-foreground',
            props.class
          )
        "
      >
        <slot />
      </p>
    </template>
    
    components/item/ItemFooter.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface ItemFooterProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<ItemFooterProps>();
    </script>
    
    <template>
      <div
        data-slot="item-footer"
        :class="
          cn(
            'nx:flex nx:basis-full nx:items-center nx:justify-between nx:gap-2',
            props.class
          )
        "
      >
        <slot />
      </div>
    </template>
    
    components/item/ItemGroup.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface ItemGroupProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<ItemGroupProps>();
    </script>
    
    <template>
      <div
        data-slot="item-group"
        :class="cn('nx:group/item-group nx:flex nx:flex-col', props.class)"
      >
        <slot />
      </div>
    </template>
    
    components/item/ItemHeader.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface ItemHeaderProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<ItemHeaderProps>();
    </script>
    
    <template>
      <div
        data-slot="item-header"
        :class="
          cn(
            'nx:flex nx:basis-full nx:items-center nx:justify-between nx:gap-2',
            props.class
          )
        "
      >
        <slot />
      </div>
    </template>
    
    components/item/ItemMedia.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    import { type ItemMediaVariants, itemMediaVariants } from '.';
    
    export interface ItemMediaProps {
      variant?: ItemMediaVariants['variant'];
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<ItemMediaProps>(), {
      variant: 'default',
    });
    </script>
    
    <template>
      <div
        data-slot="item-media"
        :data-variant="variant"
        :class="cn(itemMediaVariants({ variant }), props.class)"
      >
        <slot />
      </div>
    </template>
    
    components/item/ItemSeparator.vue
    <script setup lang="ts">
    import { reactiveOmit } from '@vueuse/core';
    import type { SeparatorProps } from 'reka-ui';
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    import { Separator } from '../separator';
    
    export interface ItemSeparatorProps extends SeparatorProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = withDefaults(defineProps<ItemSeparatorProps>(), {
      orientation: 'horizontal',
      decorative: true,
    });
    
    const delegated = reactiveOmit(props, 'class');
    </script>
    
    <template>
      <Separator
        v-bind="delegated"
        data-slot="item-separator"
        :class="cn('nx:my-0', props.class)"
      />
    </template>
    
    components/item/ItemTitle.vue
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue';
    
    import { cn } from '../../lib/utils';
    
    export interface ItemTitleProps {
      class?: HTMLAttributes['class'];
    }
    
    const props = defineProps<ItemTitleProps>();
    </script>
    
    <template>
      <div
        data-slot="item-title"
        :class="
          cn(
            'nx:flex nx:w-fit nx:items-center nx:gap-2 nx:typography-label-default',
            props.class
          )
        "
      >
        <slot />
      </div>
    </template>
    
    components/item/index.ts
    import { cva, type VariantProps } from 'class-variance-authority';
    
    export { default as Item, type ItemProps } from './Item.vue';
    export {
      default as ItemActions,
      type ItemActionsProps,
    } from './ItemActions.vue';
    export {
      default as ItemContent,
      type ItemContentProps,
    } from './ItemContent.vue';
    export {
      default as ItemDescription,
      type ItemDescriptionProps,
    } from './ItemDescription.vue';
    export { default as ItemFooter, type ItemFooterProps } from './ItemFooter.vue';
    export { default as ItemGroup, type ItemGroupProps } from './ItemGroup.vue';
    export { default as ItemHeader, type ItemHeaderProps } from './ItemHeader.vue';
    export { default as ItemMedia, type ItemMediaProps } from './ItemMedia.vue';
    export {
      default as ItemSeparator,
      type ItemSeparatorProps,
    } from './ItemSeparator.vue';
    export { default as ItemTitle, type ItemTitleProps } from './ItemTitle.vue';
    
    export const itemVariants = cva(
      'nx:text-inherit nx:no-underline nx:group/item nx:flex nx:flex-wrap nx:items-center nx:rounded-md nx:border-default nx:border-transparent nx:typography-body-default nx:transition-control nx:duration-faster nx:focus-visible:outline-2 nx:focus-visible:outline-focus-default nx:[a]:hover:bg-background-hover',
      {
        variants: {
          variant: {
            default: 'nx:bg-transparent',
            outline: 'nx:border-border-default',
            muted: 'nx:bg-muted',
          },
          size: {
            default: 'nx:gap-4 nx:p-4',
            sm: 'nx:gap-2.5 nx:px-4 nx:py-3',
          },
        },
        defaultVariants: {
          variant: 'default',
          size: 'default',
        },
      }
    );
    
    export const itemMediaVariants = cva(
      'nx:flex nx:shrink-0 nx:items-center nx:justify-center nx:gap-2 nx:group-has-[[data-item-part=description]]/item:translate-y-0.5 nx:group-has-[[data-item-part=description]]/item:self-start nx:[&_svg]:pointer-events-none',
      {
        variants: {
          variant: {
            default: 'nx:bg-transparent',
            icon: 'nx:size-8 nx:rounded-sm nx:border-default nx:border-border-default nx:bg-muted nx:[&_svg]:size-4',
            image:
              'nx:relative nx:size-10 nx:overflow-hidden nx:rounded-sm nx:[&_img]:size-full nx:[&_img]:object-cover nx:after:pointer-events-none nx:after:absolute nx:after:inset-0 nx:after:rounded-[inherit] nx:after:outline nx:after:outline-1 nx:after:-outline-offset-1 nx:after:outline-border-hairline',
          },
        },
        defaultVariants: {
          variant: 'default',
        },
      }
    );
    
    export type ItemVariants = VariantProps<typeof itemVariants>;
    export type ItemMediaVariants = VariantProps<typeof itemMediaVariants>;
    
  4. Keep each file at the path shown above it.

    Paths are relative to packages/vue/src/. Keep the same folders under your @/ root, and the imports between files work without edits.

Usage

import { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemSeparator, ItemTitle } from '@/components/item';

Examples

Variants

Pick a transparent, bordered or filled surface for the row.

<script setup lang="ts">
import {
  Item,
  ItemContent,
  ItemDescription,
  ItemTitle,
} from '@/components/item';
</script>

<template>
  <div class="nx:flex nx:w-96 nx:flex-col nx:gap-3">
    <Item variant="default">
      <ItemContent>
        <ItemTitle>Default</ItemTitle>
        <ItemDescription>Transparent, borderless.</ItemDescription>
      </ItemContent>
    </Item>
    <Item variant="outline">
      <ItemContent>
        <ItemTitle>Outline</ItemTitle>
        <ItemDescription>Bordered surface.</ItemDescription>
      </ItemContent>
    </Item>
    <Item variant="muted">
      <ItemContent>
        <ItemTitle>Muted</ItemTitle>
        <ItemDescription>Filled surface.</ItemDescription>
      </ItemContent>
    </Item>
  </div>
</template>

Sizes

Use the sm size for a denser row.

<script setup lang="ts">
import { Item, ItemContent, ItemTitle } from '@/components/item';
</script>

<template>
  <div class="nx:flex nx:w-96 nx:flex-col nx:gap-3">
    <Item variant="outline" size="default">
      <ItemContent>
        <ItemTitle>Default size</ItemTitle>
      </ItemContent>
    </Item>
    <Item variant="outline" size="sm">
      <ItemContent>
        <ItemTitle>Small size</ItemTitle>
      </ItemContent>
    </Item>
  </div>
</template>

Media

Lead the row with an icon tile or an image thumbnail.

<script setup lang="ts">
import { PhFile } from '@phosphor-icons/vue';

import { Item, ItemContent, ItemMedia, ItemTitle } from '@/components/item';
</script>

<template>
  <div class="nx:flex nx:w-96 nx:flex-col nx:gap-3">
    <Item variant="outline">
      <ItemMedia variant="icon"><PhFile /></ItemMedia>
      <ItemContent>
        <ItemTitle>Icon media</ItemTitle>
      </ItemContent>
    </Item>
    <Item variant="outline">
      <ItemMedia variant="image">
        <img
          src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Crect width='40' height='40' fill='%23999'/%3E%3C/svg%3E"
          alt=""
        />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>Image media</ItemTitle>
      </ItemContent>
    </Item>
  </div>
</template>

Grouped

Stack related rows in an ItemGroup with separators between them.

<script setup lang="ts">
import {
  Item,
  ItemContent,
  ItemGroup,
  ItemSeparator,
  ItemTitle,
} from '@/components/item';
</script>

<template>
  <ItemGroup class="nx:w-96">
    <Item>
      <ItemContent>
        <ItemTitle>First</ItemTitle>
      </ItemContent>
    </Item>
    <ItemSeparator />
    <Item>
      <ItemContent>
        <ItemTitle>Second</ItemTitle>
      </ItemContent>
    </Item>
  </ItemGroup>
</template>

As child

Render the whole row as a link with as-child.

<script setup lang="ts">
import { PhFile } from '@phosphor-icons/vue';

import {
  Item,
  ItemContent,
  ItemDescription,
  ItemMedia,
  ItemTitle,
} from '@/components/item';
</script>

<template>
  <Item as-child variant="outline" class="nx:w-96">
    <a href="/files/report" data-testid="item-link">
      <ItemMedia variant="icon"><PhFile /></ItemMedia>
      <ItemContent>
        <ItemTitle>Open report</ItemTitle>
        <ItemDescription>Navigates to the file.</ItemDescription>
      </ItemContent>
    </a>
  </Item>
</template>

Props

Item

Item props
PropTypeDefaultDescription
size"default" | "sm" | null"default"No description
variant"default" | "outline" | "muted" | null"default"No description

ItemActions

ItemActions adds no props of its own; it accepts the props of the element or primitive it wraps.

ItemContent

ItemContent adds no props of its own; it accepts the props of the element or primitive it wraps.

ItemDescription

ItemDescription adds no props of its own; it accepts the props of the element or primitive it wraps.

ItemFooter adds no props of its own; it accepts the props of the element or primitive it wraps.

ItemGroup

ItemGroup adds no props of its own; it accepts the props of the element or primitive it wraps.

ItemHeader

ItemHeader adds no props of its own; it accepts the props of the element or primitive it wraps.

ItemMedia

ItemMedia props
PropTypeDefaultDescription
variant"default" | "icon" | "image" | null"default"No description

ItemSeparator

ItemSeparator adds no props of its own; it accepts the props of the element or primitive it wraps.

ItemTitle

ItemTitle adds no props of its own; it accepts the props of the element or primitive it wraps.