component — display

Grid List

Keyboard-navigable, selectable list built on react-aria-components and styled with the quebi design system. Supports single and multiple selection with row checkboxes, drag handles, sections, and labels/descriptions.

  • list
  • selection
  • collection
  • drag
  • interactive

04 examples

Default

A single-selection list. Click or use the keyboard to select a row.

React
Solid
Svelte
Vue

Multiple selection

selectionMode="multiple" renders a checkbox on each row.

Read
Write
Administer

Label and description

Compose rows with GridListLabel and GridListDescription.

Ada Lovelaceada@quebi.de
Alan Turingalan@quebi.de

Disabled item

Disabled rows are dimmed and not selectable.

Free
Pro
Enterprise (coming soon)

source

grid-list.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import type { GridListItemProps, GridListProps, TextProps } from "react-aria-components"
import {
  Button,
  composeRenderProps,
  GridListHeader as GridListHeaderPrimitive,
  GridListItem as GridListItemPrimitive,
  GridList as GridListPrimitive,
  GridListSection as GridListSectionPrimitive,
  Text,
} from "react-aria-components"
import { Checkbox } from "@/components/checkbox"
import { cn } from "@/lib/utils"

/**
 * GridList — quebi design system
 *
 * Built on react-aria-components. A keyboard-navigable, selectable list with
 * optional drag handles and per-row checkboxes, drawn as the index list: a
 * strong rule on top, a hairline under every row, no boxes. A hovered row is
 * raised, a selected one takes the `selected` ground with its icon in signal;
 * a row with an `href` also shifts right on hover. A drop target is outlined
 * in signal. Focus is an inset ring, since the rows have no room around them.
 */
const GridList = <T extends object>({ className, ...props }: GridListProps<T>) => (
  <GridListPrimitive
    data-slot="grid-list"
    className={composeRenderProps(className, (resolved) =>
      cn(
        "relative flex flex-col sm:text-sm/6",
        "border-t border-quebi-rule has-data-[slot=grid-list-section]:border-t-0",
        "*:data-[drop-target]:border *:data-[drop-target]:border-quebi-signal",
        "has-data-[slot=grid-list-section]:gap-y-8",
        resolved,
      ),
    )}
    {...props}
  />
)

const GridListSection = <T extends object>({
  className,
  ...props
}: React.ComponentProps<typeof GridListSectionPrimitive<T>>) => {
  return (
    <GridListSectionPrimitive
      data-slot="grid-list-section"
      className={cn("flex flex-col", className)}
      {...props}
    />
  )
}

const GridListHeader = ({
  className,
  ...props
}: React.ComponentProps<typeof GridListHeaderPrimitive>) => {
  return (
    <GridListHeaderPrimitive
      data-slot="grid-list-header"
      className={cn("quebi-eyebrow border-b border-quebi-rule pb-2", className)}
      {...props}
    />
  )
}

const GridListItem = ({ className, children, ...props }: GridListItemProps) => {
  const textValue = typeof children === "string" ? children : undefined
  return (
    <GridListItemPrimitive
      textValue={textValue}
      {...props}
      className={composeRenderProps(
        className,
        (className, { isHovered, isFocusVisible, isSelected, isDisabled }) =>
          cn(
            "group relative min-w-0 outline-hidden",
            "border-b border-quebi-hairline px-1 py-3.5",
            "flex min-w-0 cursor-default items-center gap-2 sm:gap-2.5",
            "text-quebi-fg transition-[padding,background-color] duration-300",
            "data-[dragging]:cursor-grab data-[dragging]:opacity-70",
            "**:data-[slot=icon]:size-5 **:data-[slot=icon]:shrink-0 **:data-[slot=icon]:text-quebi-fg-muted sm:**:data-[slot=icon]:size-4",
            (isHovered || isFocusVisible) && "bg-quebi-raised",
            isSelected &&
              "bg-quebi-selected text-quebi-on-selected **:data-[slot=icon]:text-quebi-signal",
            isFocusVisible && "ring-2 ring-inset ring-quebi-focus",
            isDisabled && "opacity-50",
            "href" in props && "cursor-pointer",
            "href" in props && isHovered && "pl-3",
            className,
          ),
      )}
    >
      {(values) => (
        <>
          {values.allowsDragging && (
            <Button slot="drag" className="text-quebi-fg-muted outline-hidden">
              <svg
                aria-hidden="true"
                data-slot="drag-icon"
                className="size-5 sm:size-4"
                xmlns="http://www.w3.org/2000/svg"
                viewBox="0 0 24 24"
                fill="none"
              >
                <path
                  d="M11 5.5C11 6.32843 10.3284 7 9.5 7C8.67157 7 8 6.32843 8 5.5C8 4.67157 8.67157 4 9.5 4C10.3284 4 11 4.67157 11 5.5Z"
                  fill="currentColor"
                />
                <path
                  d="M16 5.5C16 6.32843 15.3284 7 14.5 7C13.6716 7 13 6.32843 13 5.5C13 4.67157 13.6716 4 14.5 4C15.3284 4 16 4.67157 16 5.5Z"
                  fill="currentColor"
                />
                <path
                  d="M11 18.5C11 19.3284 10.3284 20 9.5 20C8.67157 20 8 19.3284 8 18.5C8 17.6716 8.67157 17 9.5 17C10.3284 17 11 17.6716 11 18.5Z"
                  fill="currentColor"
                />
                <path
                  d="M16 18.5C16 19.3284 15.3284 20 14.5 20C13.6716 20 13 19.3284 13 18.5C13 17.6716 13.6716 17 14.5 17C15.3284 17 16 17.6716 16 18.5Z"
                  fill="currentColor"
                />
                <path
                  d="M11 12C11 12.8284 10.3284 13.5 9.5 13.5C8.67157 13.5 8 12.8284 8 12C8 11.1716 8.67157 10.5 9.5 10.5C10.3284 10.5 11 11.1716 11 12Z"
                  fill="currentColor"
                />
                <path
                  d="M16 12C16 12.8284 15.3284 13.5 14.5 13.5C13.6716 13.5 13 12.8284 13 12C13 11.1716 13.6716 10.5 14.5 10.5C15.3284 10.5 16 11.1716 16 12Z"
                  fill="currentColor"
                />
              </svg>
            </Button>
          )}

          {values.selectionMode === "multiple" && values.selectionBehavior === "toggle" && (
            <Checkbox slot="selection" />
          )}
          {typeof children === "function" ? children(values) : children}
        </>
      )}
    </GridListItemPrimitive>
  )
}

const GridListEmptyState = ({ ref, className, ...props }: React.ComponentProps<"div">) => (
  <div ref={ref} className={cn("p-6 text-quebi-fg-muted", className)} {...props} />
)

const GridListSpacer = ({ className, ref, ...props }: React.ComponentProps<"div">) => {
  return <div ref={ref} aria-hidden className={cn("-ms-4 flex-1", className)} {...props} />
}

const GridListStart = ({ className, ref, ...props }: React.ComponentProps<"div">) => {
  return (
    <div
      ref={ref}
      className={cn("relative flex items-center gap-x-2.5 sm:gap-x-3", className)}
      {...props}
    />
  )
}

interface GridListTextProps extends TextProps {
  ref?: React.Ref<HTMLDivElement>
}

const GridListLabel = ({ className, ref, ...props }: GridListTextProps) => (
  <Text
    ref={ref}
    className={cn("font-display text-lg/6 font-light tracking-wide text-quebi-fg", className)}
    {...props}
  />
)

const GridListDescription = ({ className, ref, ...props }: GridListTextProps) => (
  <Text
    slot="description"
    ref={ref}
    className={cn("text-quebi-body-s text-quebi-fg-muted", className)}
    {...props}
  />
)

export type { GridListItemProps, GridListProps }
export {
  GridList,
  GridListDescription,
  GridListEmptyState,
  GridListHeader,
  GridListItem,
  GridListLabel,
  GridListSection,
  GridListSpacer,
  GridListStart,
}