Highlight Tabs

A Motion-powered tab list with a shared highlight indicator and keyboard navigation.

navigationshared-layout

Overview

3 recent changes

"use client";

import * as React from "react";
import { LayoutGroup, motion, useReducedMotion } from "motion/react";

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

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

export type HighlightTabsProps = Omit<
  React.ComponentPropsWithoutRef<"div">,
  "defaultValue" | "onChange"
> & {
  tabs: HighlightTab[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  selectOnHover?: boolean;
  listClassName?: string;
  tabClassName?: string;
  indicatorClassName?: string;
  "aria-label"?: string;
};

function getEnabledTabs(tabs: HighlightTab[]) {
  return tabs.filter((tab) => !tab.disabled);
}

function getInitialValue(tabs: HighlightTab[], value?: string) {
  const enabledTabs = getEnabledTabs(tabs);

  return enabledTabs.some((tab) => tab.value === value)
    ? value
    : enabledTabs[0]?.value;
}

export function HighlightTabs({
  tabs,
  value,
  defaultValue,
  onValueChange,
  selectOnHover = true,
  className,
  listClassName,
  tabClassName,
  indicatorClassName,
  "aria-label": ariaLabel = "Tabs",
  ...props
}: HighlightTabsProps) {
  const shouldReduceMotion = useReducedMotion();
  const reactId = React.useId();
  const layoutId = `highlight-tabs-${reactId}`;
  const isControlled = value !== undefined;
  const tabRefs = React.useRef(new Map<string, HTMLButtonElement>());
  const [uncontrolledValue, setUncontrolledValue] = React.useState(
    () => getInitialValue(tabs, defaultValue) ?? "",
  );

  const enabledTabs = React.useMemo(() => getEnabledTabs(tabs), [tabs]);
  const activeValue = isControlled
    ? value
    : enabledTabs.some((tab) => tab.value === uncontrolledValue)
      ? uncontrolledValue
      : enabledTabs[0]?.value;

  const setActiveValue = React.useCallback(
    (nextValue: string) => {
      const nextTab = tabs.find((tab) => tab.value === nextValue);

      if (!nextTab || nextTab.disabled || nextValue === activeValue) return;

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

      onValueChange?.(nextValue);
    },
    [activeValue, isControlled, onValueChange, tabs],
  );

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

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

      const currentIndex = enabledTabs.findIndex(
        (tab) => tab.value === currentValue,
      );
      const lastIndex = enabledTabs.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 nextValue = enabledTabs[nextIndex]?.value;

      if (!nextValue) return;

      setActiveValue(nextValue);
      focusTab(nextValue);
    },
    [enabledTabs, focusTab, setActiveValue],
  );

  const transition = shouldReduceMotion
    ? { duration: 0 }
    : ({ type: "spring", duration: 0.24, bounce: 0 } as const);

  return (
    <div
      data-slot="highlight-tabs"
      className={cn("inline-flex", className)}
      {...props}
    >
      <LayoutGroup id={layoutId}>
        <ul
          role="tablist"
          aria-label={ariaLabel}
          data-slot="highlight-tabs-list"
          className={cn(
            "inline-flex items-center gap-1 rounded-lg bg-muted/70 p-1",
            listClassName,
          )}
        >
          {tabs.map((tab) => {
            const isActive = activeValue === tab.value;

            return (
              <li key={tab.value} role="presentation" className="relative">
                <button
                  type="button"
                  role="tab"
                  aria-selected={isActive}
                  disabled={tab.disabled}
                  tabIndex={isActive ? 0 : -1}
                  ref={(element) => {
                    if (element) {
                      tabRefs.current.set(tab.value, element);
                    } else {
                      tabRefs.current.delete(tab.value);
                    }
                  }}
                  data-slot="highlight-tabs-trigger"
                  data-active={isActive ? "" : undefined}
                  onClick={() => setActiveValue(tab.value)}
                  onFocus={() => setActiveValue(tab.value)}
                  onPointerEnter={() => {
                    if (selectOnHover) {
                      setActiveValue(tab.value);
                    }
                  }}
                  onKeyDown={(event) => handleKeyDown(event, tab.value)}
                  className={cn(
                    "relative inline-flex h-8 items-center justify-center whitespace-nowrap rounded-md px-3 text-sm font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 data-active:text-foreground",
                    tabClassName,
                  )}
                >
                  {isActive ? (
                    <motion.span
                      layoutId="highlight-tabs-indicator"
                      aria-hidden="true"
                      transition={transition}
                      className={cn(
                        "absolute inset-0 rounded-md bg-background shadow-sm",
                        indicatorClassName,
                      )}
                    />
                  ) : null}
                  <span className="relative z-10">{tab.label}</span>
                </button>
              </li>
            );
          })}
        </ul>
      </LayoutGroup>
    </div>
  );
}

Installation

pnpm dlx shadcn@latest add https://ui.ericts.com/r/highlight-tabs.json