Aura Design System

Listbox

A list of options with arrow-key selection.

Preview

Loading...
import { Listbox } from "@/components/ui/Listbox";

export function ListboxDemo() {
  return (
    <Listbox
      defaultValue="type"
      items={[
        { value: "color", label: "Color" },
        { value: "type", label: "Typography" },
        { value: "space", label: "Spacing" },
      ]}
    />
  );
}

Installation

Make sure that namespace is set in your component.json file. Namespace docs: Learn more about namespaces

pnpm dlx shadcn@latest add @aura/listbox

Manual

Copy and paste the class names utility into your utils/class-names.ts file.

import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

Copy and paste the Listbox component into your components/ui/Listbox.tsx file.

"use client";

import * as React from "react";
import { cn } from "@/utils/class-names";

type ListboxItem = {
  value: string;
  label: string;
  disabled?: boolean;
};

function Listbox({
  className,
  items,
  value,
  defaultValue,
  onValueChange,
  label = "Options",
}: {
  className?: string;
  items: ListboxItem[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  label?: string;
}) {
  const [uncontrolled, setUncontrolled] = React.useState(defaultValue ?? items[0]?.value);
  const current = value ?? uncontrolled;
  const enabled = items.filter((item) => !item.disabled);

  const select = (next: string) => {
    if (value === undefined) setUncontrolled(next);
    onValueChange?.(next);
  };

  return (
    <div
      role="listbox"
      aria-label={label}
      tabIndex={0}
      data-slot="listbox"
      className={cn(
        "flex w-full max-w-xl flex-col rounded-sm border border-gray-6 bg-gray-1 p-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-8",
        className,
      )}
      onKeyDown={(event) => {
        const index = enabled.findIndex((item) => item.value === current);
        if (event.key === "ArrowDown") {
          event.preventDefault();
          const next = enabled[(index + 1) % enabled.length];
          if (next) select(next.value);
        } else if (event.key === "ArrowUp") {
          event.preventDefault();
          const next = enabled[(index - 1 + enabled.length) % enabled.length];
          if (next) select(next.value);
        }
      }}
    >
      {items.map((item) => {
        const selected = item.value === current;
        return (
          <button
            key={item.value}
            type="button"
            role="option"
            aria-selected={selected}
            disabled={item.disabled}
            className="rounded-sm px-1 py-0.5 text-start text-gray-12 hover:bg-gray-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-8 disabled:opacity-50 aria-selected:bg-accent-3"
            onClick={() => select(item.value)}
          >
            {item.label}
          </button>
        );
      })}
    </div>
  );
}

export { Listbox };
export type { ListboxItem };