Back to components

Rail List

A controlled or uncontrolled horizontal selector rail with centered labels, roving focus, and an edge-underline indicator on the active item.

navigationindicator-reveal

Where the collection starts and what holds it together.

Where the collection starts and what holds it together.

"use client";

import * as React from "react";

import { cn } from "@/lib/utils";

export type RailListEdge = "top" | "bottom";

export type RailListItem = {
  value: string;
  label: React.ReactNode;
  disabled?: boolean;
  id?: string;
  ariaControls?: string;
};

export type RailListProps = Omit<
  React.ComponentProps<"div">,
  "defaultValue" | "onChange"
> & {
  items: RailListItem[];
  /** Controlled active value. Pass null to render without an active item. */
  value?: string | null;
  /** Initial active value. Omit or pass null to start without an active item. */
  defaultValue?: string | null;
  onValueChange?: (value: string, item: RailListItem) => void;
  /** Which edge of the active item carries the indicator bar. */
  edge?: RailListEdge;
  listClassName?: string;
  itemClassName?: string;
  labelClassName?: string;
  indicatorClassName?: string;
  onItemPointerEnter?: (item: RailListItem) => void;
  onItemFocus?: (item: RailListItem) => void;
  "aria-label"?: string;
};

function getEnabledItems(items: RailListItem[]) {
  return items.filter((item) => !item.disabled);
}

function getSelectableValue(
  items: RailListItem[],
  value: string | null | undefined,
) {
  const enabledItems = getEnabledItems(items);

  if (value == null) return null;

  return enabledItems.some((item) => item.value === value) ? value : null;
}

export function RailList({
  items,
  value,
  defaultValue,
  onValueChange,
  edge = "bottom",
  className,
  listClassName,
  itemClassName,
  labelClassName,
  indicatorClassName,
  onItemPointerEnter,
  onItemFocus,
  "aria-label": ariaLabel = "Options",
  ...props
}: RailListProps) {
  const isControlled = value !== undefined;
  const itemRefs = React.useRef(new Map<string, HTMLButtonElement>());
  const [uncontrolledValue, setUncontrolledValue] = React.useState(() =>
    getSelectableValue(items, defaultValue),
  );

  const enabledItems = React.useMemo(() => getEnabledItems(items), [items]);
  const selectedValue = getSelectableValue(
    items,
    isControlled ? value : uncontrolledValue,
  );
  const focusableValue = selectedValue ?? enabledItems[0]?.value ?? null;

  const setSelectedValue = React.useCallback(
    (nextValue: string) => {
      const nextItem = items.find((item) => item.value === nextValue);

      if (!nextItem || nextItem.disabled || nextValue === selectedValue) {
        return;
      }

      if (!isControlled) {
        setUncontrolledValue(nextValue);
      }

      onValueChange?.(nextValue, nextItem);
    },
    [isControlled, items, onValueChange, selectedValue],
  );

  const focusItem = React.useCallback((nextValue: string) => {
    itemRefs.current.get(nextValue)?.focus();
  }, []);

  const handleKeyDown = React.useCallback(
    (
      event: React.KeyboardEvent<HTMLButtonElement>,
      currentValue: string,
    ) => {
      if (enabledItems.length === 0) return;

      const currentIndex = Math.max(
        enabledItems.findIndex((item) => item.value === currentValue),
        0,
      );
      const lastIndex = enabledItems.length - 1;
      let nextIndex = currentIndex;

      if (event.key === "ArrowRight" || event.key === "ArrowDown") {
        nextIndex = currentIndex >= lastIndex ? 0 : currentIndex + 1;
      } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
        nextIndex = currentIndex <= 0 ? lastIndex : currentIndex - 1;
      } else if (event.key === "Home") {
        nextIndex = 0;
      } else if (event.key === "End") {
        nextIndex = lastIndex;
      } else {
        return;
      }

      event.preventDefault();

      const nextItem = enabledItems[nextIndex];

      if (!nextItem) return;

      setSelectedValue(nextItem.value);
      focusItem(nextItem.value);
    },
    [enabledItems, focusItem, setSelectedValue],
  );

  return (
    <div
      {...props}
      data-slot="rail-list"
      data-edge={edge}
      className={cn("flex w-full min-w-0", className)}
    >
      <ul
        role="tablist"
        aria-label={ariaLabel}
        aria-orientation="horizontal"
        data-slot="rail-list-list"
        className={cn(
          "flex w-full min-w-0 gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
          listClassName,
        )}
      >
        {items.map((item) => {
          const isActive = selectedValue === item.value;

          return (
            <li
              key={item.value}
              role="presentation"
              className="flex min-w-fit flex-1"
            >
              <button
                id={item.id}
                type="button"
                role="tab"
                aria-selected={isActive}
                aria-controls={item.ariaControls}
                disabled={item.disabled}
                tabIndex={item.value === focusableValue ? 0 : -1}
                ref={(element) => {
                  if (element) {
                    itemRefs.current.set(item.value, element);
                  } else {
                    itemRefs.current.delete(item.value);
                  }
                }}
                data-slot="rail-list-trigger"
                data-active={isActive ? "" : undefined}
                onClick={() => setSelectedValue(item.value)}
                onKeyDown={(event) => handleKeyDown(event, item.value)}
                onPointerEnter={() => onItemPointerEnter?.(item)}
                onFocus={() => onItemFocus?.(item)}
                className={cn(
                  "group relative flex min-h-11 w-full items-center justify-center px-3 py-2 text-center text-sm font-medium text-muted-foreground outline-none transition-colors duration-150 ease-out hover:text-foreground focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-current motion-reduce:transition-none disabled:pointer-events-none disabled:opacity-50 data-active:text-foreground",
                  itemClassName,
                )}
              >
                <span
                  aria-hidden="true"
                  data-slot="rail-list-indicator"
                  className={cn(
                    "absolute inset-x-3 h-0.5 scale-x-0 bg-current transition-transform duration-200 ease-out group-data-active:scale-x-100 motion-reduce:transition-none",
                    edge === "top" ? "top-0" : "bottom-0",
                    indicatorClassName,
                  )}
                />
                <span className={cn("truncate", labelClassName)}>
                  {item.label}
                </span>
              </button>
            </li>
          );
        })}
      </ul>
    </div>
  );
}

Installation