Lists

Lists are continuous, vertical indexes of text and images

Usage

Cards are used to display content and actions on a single topic.

Instalation

Run the following command if is not already installed:

npm i @radix-ui/react-slot

Copy and paste the following code into your project.

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

import { cn } from '@/lib/utils'

const ListRoot = React.forwardRef<
  HTMLUListElement,
  React.HTMLAttributes<HTMLUListElement> & { asChild?: boolean }
>(({ className, asChild, ...props }, ref) => {
  const Comp = asChild ? Slot : 'ul'
  return (
    <Comp
      ref={ref}
      className={cn('w-full bg-surface py-2', className)}
      {...props}
    />
  )
})
ListRoot.displayName = 'ListRoot'

const ListItem = React.forwardRef<
  HTMLLIElement,
  React.HTMLAttributes<HTMLLIElement> & { asChild?: boolean }
>(({ className, asChild, ...props }, ref) => {
  const Comp = asChild ? Slot : 'li'
  return (
    <Comp
      ref={ref}
      className={cn(
        'group/item relative flex min-h-14 w-full items-center gap-4 bg-surface px-4 py-2 text-onSurfaceVariant outline-none last:border-none focus:outline-none',
        className
      )}
      {...props}
    />
  )
})
ListItem.displayName = 'ListItem'

const ListActionArea = React.forwardRef<
  HTMLButtonElement,
  React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean }
>(({ className, children, asChild, ...props }, ref) => {
  const Comp = asChild ? Slot : 'button'

  return (
    <>
      <Comp
        ref={ref}
        className={cn(
          'group/action z-0 flex h-full w-full items-center gap-4 text-onSurfaceVariant outline-none focus:outline-none',
          className
        )}
        {...props}
      >
        <Slottable>{children}</Slottable>
        <span className="state-layer absolute inset-0 z-[-1] bg-onSurfaceVariant opacity-0 transition-opacity group-focus-within/item:opacity-4 group-hover/item:opacity-4 group-active/action:opacity-8" />
      </Comp>
    </>
  )
})
ListActionArea.displayName = 'ListActionArea'

const ListHeadline = React.forwardRef<
  HTMLHeadingElement,
  React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
  <h3
    ref={ref}
    className={cn('text-body-lg text-onSurface', className)}
    {...props}
  />
))
ListHeadline.displayName = 'ListHeadline'

const ListSupportingText = React.forwardRef<
  HTMLParagraphElement,
  React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
  <p ref={ref} className={cn('text-body-md', className)} {...props} />
))
ListSupportingText.displayName = 'ListSupportingText'

const ListEdgeSection = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn('flex shrink-0 items-center', className)}
    {...props}
  />
))

const ListContent = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div ref={ref} className={cn('grow', className)} {...props} />
))

const List = Object.assign(ListRoot, {
  Item: ListItem,
  Headline: ListHeadline,
  SupportingText: ListSupportingText,
  ActionArea: ListActionArea,
  EdgeSection: ListEdgeSection,
  Content: ListContent,
})

export {
  List,
  ListRoot,
  ListItem,
  ListHeadline,
  ListSupportingText,
  ListActionArea,
  ListEdgeSection,
  ListContent,
}

Update the import paths to match your project setup.

Examples

Thumbnail and trailing supporting text

  • Cactus

    Cactus

    In Stock

    $9.99

  • Succulent

    Succulent

    In Stock

    $19.99

  • Bamboo

    Bamboo

    In Stock

    $29.99

  • Orchid

    Orchid

    Out of Stock

    $39.99

  • Bonsai

    Bonsai

    In Stock

    $49.99

import { List } from '@/components/ui/list'

import { mockStore } from './data'

export const ListWithThumbnailAndTrailingSupportingText = () => {
  return (
    <List>
      {mockStore.map(({ sku, name, stock, price, thumbnail }) => (
        <List.Item key={sku}>
          <img
            src={thumbnail}
            alt={name}
            className="bg-outlineVariant size-14 shrink-0"
          />
          <div className="grow">
            <List.Headline>{name}</List.Headline>
            <List.SupportingText>
              {stock ? 'In Stock' : 'Out of Stock'}
            </List.SupportingText>
          </div>
          <p className="text-label-lg">${price}</p>
        </List.Item>
      ))}
    </List>
  )
}

Action area

import { Icon } from '@/components/ui/icon'
import { IconButton } from '@/components/ui/icon-button'
import { List } from '@/components/ui/list'

import { mockFolders } from './data'

export const LinksListExample = () => {
  return (
    <List asChild>
      <nav>
        {mockFolders.map(({ url, name, updated, icon }) => (
          <List.Item asChild key={url}>
            <div>
              <List.ActionArea asChild>
                <a href={url}>
                  <div className="bg-primaryContainer text-onPrimaryContainer grid size-10 shrink-0 place-items-center rounded-full">
                    <Icon symbol={icon} />
                  </div>
                  <div className="grow">
                    <List.Headline>{name}</List.Headline>
                    <List.SupportingText>{`updated ${updated}`}</List.SupportingText>
                  </div>
                </a>
              </List.ActionArea>
              <IconButton
                variant="standard"
                onClick={() => console.log({ name })}
              >
                <Icon symbol="info" />
              </IconButton>
            </div>
          </List.Item>
        ))}
      </nav>
    </List>
  )
}

With dividers

Place a divider between rows with lots of content, such as those with two- or three-line lists

  • Festivals

    Festivals

    Festivals Food, music, arts, community, tradition, culture, and celebration - a collection of events around the world.

    May 8

  • Arts

    Arts

    Literature, games, music, physical and visual forms - explore the art that has inspired and moved humanity through history and today.

    May 7

  • Family & Friends

    Family & Friends

    The relationships that bring and bind us together and shape our world.

    May 4

import { List } from '@/components/ui/list'

import { mockCategories } from './data'

export const DoubleLineListExample = () => {
  return (
    <List className="max-w-md">
      {mockCategories.map(({ thumbnail, name, description, date }) => (
        <List.Item key={name} className="border-outlineVariant border-b">
          <img
            src={thumbnail}
            alt={name}
            className="bg-outlineVariant size-14 shrink-0 rounded-md object-cover"
          />
          <div className="grow">
            <List.Headline>{name}</List.Headline>
            <List.SupportingText className="line-clamp-2">
              {description}
            </List.SupportingText>
          </div>
          <p className="text-label-sm shrink-0 self-start">{date}</p>
        </List.Item>
      ))}
    </List>
  )
}

Sortable

The drag-and-drop sortable functionality utilizes the @dnd-kit library. To use this feature, you will have to follow these additional steps:

Run the following command:

npm i @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities

Copy and paste the following code into your project.

'use client'

import { createContext, Fragment, useContext, useMemo, useState } from 'react'
import {
  defaultDropAnimationSideEffects,
  DndContext,
  DragOverlay,
  KeyboardSensor,
  PointerSensor,
  useSensor,
  useSensors,
  type Active,
  type DraggableSyntheticListeners,
  type DragOverlayProps,
  type DropAnimation,
  type UniqueIdentifier,
} from '@dnd-kit/core'
import {
  arrayMove,
  SortableContext,
  sortableKeyboardCoordinates,
  useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'

import { cn } from '@/lib/utils'
import { List, ListItem } from '@/components/ui/list'

interface Context {
  attributes: Record<string, any>
  listeners: DraggableSyntheticListeners
  ref(node: HTMLElement | null): void
}

interface BaseItemProps {
  id: UniqueIdentifier
}

interface SortableListProps<T extends BaseItemProps> {
  items: T[]
  onChange(items: T[]): void
  renderItem(item: T): React.ReactNode
}

const SortableItemContext = createContext<Context>({
  attributes: {},
  listeners: undefined,
  ref() {},
})

const dropAnimationConfig: DropAnimation = {
  sideEffects: defaultDropAnimationSideEffects({
    styles: {
      active: {
        opacity: '0.2',
      },
    },
  }),
}

export function SortableOverlay(props: DragOverlayProps) {
  return <DragOverlay dropAnimation={dropAnimationConfig} {...props} />
}

const SortableListRoot = <T extends BaseItemProps>({
  items,
  onChange,
  renderItem,
}: SortableListProps<T>) => {
  const [active, setActive] = useState<Active | null>(null)
  const activeItem = useMemo(
    () => items.find((item) => item.id === active?.id),
    [active, items]
  )
  const sensors = useSensors(
    useSensor(PointerSensor),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    })
  )

  return (
    <DndContext
      sensors={sensors}
      onDragStart={({ active }) => {
        setActive(active)
      }}
      onDragEnd={({ active, over }) => {
        if (over && active.id !== over?.id) {
          const activeIndex = items.findIndex(({ id }) => id === active.id)
          const overIndex = items.findIndex(({ id }) => id === over.id)

          onChange(arrayMove(items, activeIndex, overIndex))
        }
        setActive(null)
      }}
      onDragCancel={() => {
        setActive(null)
      }}
    >
      <SortableContext items={items}>
        <List>
          {items.map((item) => (
            <Fragment key={item.id}>{renderItem(item)}</Fragment>
          ))}
        </List>
      </SortableContext>
      <SortableOverlay>
        {activeItem ? renderItem(activeItem) : null}
      </SortableOverlay>
    </DndContext>
  )
}

interface SorteableItemProps
  extends BaseItemProps,
    Omit<React.ComponentPropsWithoutRef<typeof ListItem>, 'id'> {}

const SortableItem = ({ className, id, ...props }: SorteableItemProps) => {
  const {
    attributes,
    isDragging,
    listeners,
    setNodeRef,
    setActivatorNodeRef,
    transform,
    transition,
  } = useSortable({ id })
  const context = useMemo(
    () => ({
      attributes,
      listeners,
      ref: setActivatorNodeRef,
    }),
    [attributes, listeners, setActivatorNodeRef]
  )

  const style: React.CSSProperties = {
    opacity: isDragging ? 0.2 : undefined,
    transform: CSS.Translate.toString(transform),
    transition,
  }

  return (
    <SortableItemContext.Provider value={context}>
      <ListItem
        ref={setNodeRef}
        className={className}
        style={style}
        {...props}
      />
    </SortableItemContext.Provider>
  )
}
SortableItem.displayName = 'SortableItem'

const SortableHandle = (props: React.ComponentPropsWithoutRef<'button'>) => {
  const { attributes, listeners, ref } = useContext(SortableItemContext)

  return (
    <button
      ref={ref}
      className={cn('shrink-0 cursor-move', props.className)}
      {...attributes}
      {...listeners}
      {...props}
    />
  )
}

const SortableList = Object.assign(SortableListRoot, {
  Item: SortableItem,
  Handle: SortableHandle,
})

export { SortableList, SortableListRoot, SortableItem, SortableHandle }

Update the import paths to match your project setup.

import { useState } from 'react'

import { Icon } from '@/components/ui/icon'
import { List } from '@/components/ui/list'
import { SortableList } from '@/components/ui/sortable-list'

import { mockDogs } from './data'

export const SortableListExample = () => {
  const [items, setItems] = useState(mockDogs)

  return (
    <SortableList
      items={items}
      onChange={setItems}
      renderItem={({ id, headline, supportingText }) => (
        <SortableList.Item key={id} id={id}>
          <div className="bg-primaryContainer text-onPrimaryContainer grid size-10 shrink-0 place-items-center rounded-full">
            {headline.slice(0, 1).toLocaleUpperCase()}
          </div>
          <div className="grow">
            <List.Headline>{headline}</List.Headline>
            <List.SupportingText className="line-clamp-1">
              {supportingText}
            </List.SupportingText>
          </div>
          <SortableList.Handle>
            <Icon symbol="drag_handle" />
          </SortableList.Handle>
        </SortableList.Item>
      )}
    />
  )
}