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.

listselectioncollectiondraginteractive

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

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. Rows carry the signature
 * cyan-tinted border; selected/hovered/focused rows fill with brand teal at
 * 10% and lift their ring to the brand color.
 */
const GridList = <T extends object>({ className, ...props }: GridListProps<T>) => (
  <GridListPrimitive
    data-slot="grid-list"
    className={cn(
      "relative flex flex-col gap-y-1 sm:text-sm/6",
      "*:data-[drop-target]:border *:data-[drop-target]:border-quebi-brand",
      "has-data-[slot=grid-list-section]:gap-y-6",
      className,
    )}
    {...props}
  />
)

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

const GridListHeader = ({
  className,
  ...props
}: React.ComponentProps<typeof GridListHeaderPrimitive>) => {
  return (
    <GridListHeaderPrimitive
      data-slot="grid-list-header"
      className={cn("mb-2 font-semibold text-sm/6 text-quebi-fg-muted", 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",
            "rounded-quebi-sm border border-cyan-500/10 px-3 py-2.5",
            "flex min-w-0 cursor-default items-center gap-2 sm:gap-2.5",
            "text-white transition-colors duration-150",
            "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",
            (isSelected || isHovered || isFocusVisible) &&
              "border-cyan-500/20 bg-quebi-brand/10",
            isFocusVisible &&
              "ring-2 ring-quebi-brand/50 ring-offset-2 ring-offset-quebi-bg",
            isDisabled && "opacity-50",
            "href" in props && "cursor-pointer",
            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-medium text-white", className)} {...props} />
)

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

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