shadimage
dominik-ui
uicommand-tabssource: ok · hit

Command Tabs

A copyable command block with attached agent tabs and a sliding active pill — tabs can sit above or below the command.

npx shadcn add "https://dominikkoch.dev/r/command-tabs.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
icons@phosphor-icons/react

Registry dependencies

button

Files1 · source cached 10/14/2026, 11:44:55 AM

components/ui/command-tabs.tsx
"use client";

import { CheckIcon, CopyIcon } from "@phosphor-icons/react";
import {
  type KeyboardEvent as ReactKeyboardEvent,
  type ReactNode,
  type PointerEvent as ReactPointerEvent,
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
} from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

interface CommandTabsItem {
  value: string;
  label: string;
  command: string;
  icon?: ReactNode;
}

interface CommandTabsProps {
  items: CommandTabsItem[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  tabsPosition?: "top" | "bottom" | "none";
  invertActiveIcon?: boolean;
  highlight?: boolean;
  label?: string;
  copyLabel?: string;
  onCopy?: (command: string) => void;
  className?: string;
}

const COPY_FEEDBACK_MS = 2000;
const SCROLLBAR_HIDE_MS = 800;
const PERCENT = 100;
const SCROLL_STEP_PX = 40;

type CommandTokenType = "command" | "string" | "flag" | "plain";

interface CommandToken {
  text: string;
  type: CommandTokenType;
  start: number;
}

const COMMAND_TOKEN_RE = /"[^"]*"?|'[^']*'?|\s+|[^\s"']+/g;
const WHITESPACE_RE = /^\s+$/;

const TOKEN_CLASS: Record<CommandTokenType, string | undefined> = {
  command: "text-sky-600 dark:text-sky-400",
  string: "text-emerald-600 dark:text-emerald-400",
  flag: "text-muted-foreground",
  plain: undefined,
};

function classifyToken(text: string, isFirstWord: boolean): CommandTokenType {
  if (text.startsWith('"') || text.startsWith("'")) {
    return "string";
  }
  if (text.startsWith("-")) {
    return "flag";
  }
  return isFirstWord ? "command" : "plain";
}

function tokenizeCommand(command: string): CommandToken[] {
  const tokens: CommandToken[] = [];
  let firstWord = true;
  let start = 0;
  for (const text of command.match(COMMAND_TOKEN_RE) ?? []) {
    if (WHITESPACE_RE.test(text)) {
      tokens.push({ text, type: "plain", start });
    } else {
      tokens.push({ text, type: classifyToken(text, firstWord), start });
      firstWord = false;
    }
    start += text.length;
  }
  return tokens;
}

function HighlightedCommand({ command }: { command: string }) {
  return tokenizeCommand(command).map((token) => (
    <span className={TOKEN_CLASS[token.type]} key={token.start}>
      {token.text}
    </span>
  ));
}

interface CommandTabProps {
  item: CommandTabsItem;
  isActive: boolean;
  invertIcon: boolean;
  onSelect: (value: string) => void;
}

function CommandTab({ item, isActive, invertIcon, onSelect }: CommandTabProps) {
  return (
    <button
      aria-pressed={isActive}
      className={cn(
        "relative z-10 flex shrink-0 cursor-pointer items-center gap-2 rounded-[calc(var(--radius-xl)-6px)] px-3 py-1.5 font-medium text-sm outline-none transition-colors duration-300 ease-out focus-visible:ring-2 focus-visible:ring-ring",
        isActive
          ? "text-primary-foreground dark:text-white"
          : "text-muted-foreground hover:text-foreground"
      )}
      data-value={item.value}
      onClick={() => onSelect(item.value)}
      type="button"
    >
      {item.icon && (
        <span
          className={cn(
            "flex shrink-0 items-center [&_svg]:size-4",
            isActive &&
              invertIcon &&
              "[&_svg]:fill-current [&_svg_path]:fill-current"
          )}
        >
          {item.icon}
        </span>
      )}
      <span className="whitespace-nowrap">{item.label}</span>
    </button>
  );
}

function useActivePill(activeValue: string | undefined) {
  const menuRef = useRef<HTMLMenuElement>(null);
  const [pill, setPill] = useState<{ left: number; width: number } | null>(
    null
  );

  const measure = useCallback(() => {
    const menu = menuRef.current;
    if (!menu || activeValue === undefined) {
      setPill(null);
      return;
    }
    const active = menu.querySelector<HTMLButtonElement>(
      `[data-value="${CSS.escape(activeValue)}"]`
    );
    if (!active) {
      setPill(null);
      return;
    }
    setPill({ left: active.offsetLeft, width: active.offsetWidth });
  }, [activeValue]);

  useLayoutEffect(() => {
    measure();
  }, [measure]);

  useEffect(() => {
    const menu = menuRef.current;
    if (!menu) {
      return;
    }
    const observer = new ResizeObserver(measure);
    observer.observe(menu);
    for (const tab of menu.querySelectorAll("[data-value]")) {
      observer.observe(tab);
    }
    return () => observer.disconnect();
  }, [measure]);

  return { menuRef, pill };
}

function useFloatingScrollbar(activeCommand: string | undefined) {
  const scrollRef = useRef<HTMLElement>(null);
  const hideTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
  const [thumb, setThumb] = useState({ widthPct: 0, leftPct: 0 });
  const [scrolling, setScrolling] = useState(false);

  const measure = useCallback(() => {
    const el = scrollRef.current;
    if (!el) {
      return;
    }
    const { scrollWidth, clientWidth, scrollLeft } = el;
    if (scrollWidth <= clientWidth) {
      setThumb({ widthPct: 0, leftPct: 0 });
      return;
    }
    setThumb({
      widthPct: (clientWidth / scrollWidth) * PERCENT,
      leftPct: (scrollLeft / scrollWidth) * PERCENT,
    });
  }, []);

  const handleScroll = useCallback(() => {
    measure();
    setScrolling(true);
    if (hideTimeout.current) {
      clearTimeout(hideTimeout.current);
    }
    hideTimeout.current = setTimeout(
      () => setScrolling(false),
      SCROLLBAR_HIDE_MS
    );
  }, [measure]);

  useEffect(() => {
    const el = scrollRef.current;
    if (!el) {
      return;
    }
    const observer = new ResizeObserver(measure);
    observer.observe(el);
    return () => observer.disconnect();
  }, [measure]);

  useEffect(() => {
    const el = scrollRef.current;
    if (el && activeCommand !== undefined) {
      el.scrollLeft = 0;
    }
    measure();
  }, [measure, activeCommand]);

  useEffect(
    () => () => {
      if (hideTimeout.current) {
        clearTimeout(hideTimeout.current);
      }
    },
    []
  );

  const dragStart = useRef<{ x: number; scrollLeft: number } | null>(null);
  const [dragging, setDragging] = useState(false);

  const handleThumbPointerDown = useCallback(
    (event: ReactPointerEvent<HTMLElement>) => {
      const el = scrollRef.current;
      if (!el) {
        return;
      }
      event.preventDefault();
      event.currentTarget.setPointerCapture(event.pointerId);
      dragStart.current = { x: event.clientX, scrollLeft: el.scrollLeft };
      setDragging(true);
    },
    []
  );

  const handleThumbPointerMove = useCallback(
    (event: ReactPointerEvent<HTMLElement>) => {
      const el = scrollRef.current;
      const start = dragStart.current;
      if (!(el && start)) {
        return;
      }
      const delta = event.clientX - start.x;
      el.scrollLeft =
        start.scrollLeft + delta * (el.scrollWidth / el.clientWidth);
    },
    []
  );

  const handleThumbPointerEnd = useCallback(() => {
    dragStart.current = null;
    setDragging(false);
  }, []);

  const handleThumbKeyDown = useCallback(
    (event: ReactKeyboardEvent<HTMLElement>) => {
      const el = scrollRef.current;
      if (!el) {
        return;
      }
      if (event.key === "ArrowLeft") {
        event.preventDefault();
        el.scrollLeft -= SCROLL_STEP_PX;
      } else if (event.key === "ArrowRight") {
        event.preventDefault();
        el.scrollLeft += SCROLL_STEP_PX;
      }
    },
    []
  );

  return {
    scrollRef,
    thumb,
    visible: scrolling || dragging,
    dragging,
    handleScroll,
    handleThumbPointerDown,
    handleThumbPointerMove,
    handleThumbPointerEnd,
    handleThumbKeyDown,
  };
}

interface ScrollbarThumbProps {
  controls: string;
  thumb: { widthPct: number; leftPct: number };
  visible: boolean;
  dragging: boolean;
  onKeyDown: (event: ReactKeyboardEvent<HTMLElement>) => void;
  onPointerDown: (event: ReactPointerEvent<HTMLElement>) => void;
  onPointerMove: (event: ReactPointerEvent<HTMLElement>) => void;
  onPointerEnd: () => void;
}

function ScrollbarThumb({
  controls,
  thumb,
  visible,
  dragging,
  onKeyDown,
  onPointerDown,
  onPointerMove,
  onPointerEnd,
}: ScrollbarThumbProps) {
  const scrollableTrackPct = PERCENT - thumb.widthPct;
  const valueNow =
    scrollableTrackPct > 0
      ? Math.round((thumb.leftPct / scrollableTrackPct) * PERCENT)
      : 0;

  return (
    <div
      aria-controls={controls}
      aria-label="Scroll command"
      aria-orientation="horizontal"
      aria-valuemax={PERCENT}
      aria-valuemin={0}
      aria-valuenow={valueNow}
      className={cn(
        "absolute bottom-0 flex h-2.5 cursor-grab touch-none items-end transition-opacity duration-300",
        "focus-visible:opacity-100 focus-visible:outline-none group-hover:opacity-100",
        dragging && "cursor-grabbing",
        visible ? "opacity-100" : "opacity-0"
      )}
      onKeyDown={onKeyDown}
      onPointerCancel={onPointerEnd}
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={onPointerEnd}
      role="scrollbar"
      style={{
        width: `${thumb.widthPct}%`,
        left: `${thumb.leftPct}%`,
      }}
      tabIndex={0}
    >
      <span
        className={cn(
          "h-1 w-full rounded-full bg-foreground/25",
          dragging && "bg-foreground/40"
        )}
      />
    </div>
  );
}

export function CommandTabs({
  items,
  value,
  defaultValue,
  onValueChange,
  tabsPosition = "bottom",
  invertActiveIcon = true,
  highlight = false,
  label = "Choose a tool",
  copyLabel = "Copy",
  onCopy,
  className,
}: CommandTabsProps) {
  const [internalValue, setInternalValue] = useState(
    defaultValue ?? items[0]?.value
  );
  const [copied, setCopied] = useState(false);
  const copyTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);

  const activeValue = value ?? internalValue;
  const activeItem = items.find((item) => item.value === activeValue);
  const { menuRef, pill } = useActivePill(
    tabsPosition === "none" ? undefined : activeValue
  );
  const {
    scrollRef,
    thumb,
    visible,
    dragging,
    handleScroll,
    handleThumbPointerDown,
    handleThumbPointerMove,
    handleThumbPointerEnd,
    handleThumbKeyDown,
  } = useFloatingScrollbar(activeItem?.command);
  const commandId = useId();

  useEffect(
    () => () => {
      if (copyTimeout.current) {
        clearTimeout(copyTimeout.current);
      }
    },
    []
  );

  const handleSelect = (next: string) => {
    setInternalValue(next);
    onValueChange?.(next);
  };

  const handleCopy = async () => {
    if (!activeItem) {
      return;
    }
    try {
      await navigator.clipboard.writeText(activeItem.command);
      onCopy?.(activeItem.command);
      setCopied(true);
      if (copyTimeout.current) {
        clearTimeout(copyTimeout.current);
      }
      copyTimeout.current = setTimeout(
        () => setCopied(false),
        COPY_FEEDBACK_MS
      );
    } catch {
      setCopied(false);
    }
  };

  return (
    <div className={cn("flex w-full flex-col", className)}>
      <div
        className={cn(
          "relative z-10 flex items-center gap-2 rounded-xl border bg-muted/50 py-1.5 pr-1.5 pl-4",
          tabsPosition === "top" && "order-last"
        )}
      >
        <div className="group relative min-w-0 flex-1">
          <code
            className="block overflow-x-auto whitespace-nowrap py-1.5 font-mono text-foreground text-sm [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
            id={commandId}
            onScroll={handleScroll}
            ref={scrollRef}
          >
            {activeItem &&
              (highlight ? (
                <HighlightedCommand command={activeItem.command} />
              ) : (
                activeItem.command
              ))}
          </code>
          {thumb.widthPct > 0 && (
            <ScrollbarThumb
              controls={commandId}
              dragging={dragging}
              onKeyDown={handleThumbKeyDown}
              onPointerDown={handleThumbPointerDown}
              onPointerEnd={handleThumbPointerEnd}
              onPointerMove={handleThumbPointerMove}
              thumb={thumb}
              visible={visible}
            />
          )}
        </div>
        <Button
          className="shrink-0 cursor-pointer"
          onClick={handleCopy}
          size="sm"
          variant="outline"
        >
          {copied ? <CheckIcon /> : <CopyIcon />}
          {copied ? "Copied" : copyLabel}
        </Button>
      </div>
      {tabsPosition !== "none" && (
        <menu
          aria-label={label}
          className={cn(
            "relative m-0 flex w-fit max-w-[calc(100%-2rem)] list-none items-center gap-1 self-start overflow-x-auto overscroll-none border bg-muted/50 p-1.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
            tabsPosition === "bottom"
              ? "order-last -mt-px ml-4 rounded-b-xl border-t-0"
              : "order-first -mb-px ml-4 rounded-t-xl border-b-0"
          )}
          ref={menuRef}
        >
          {pill && (
            <span
              aria-hidden="true"
              className="pointer-events-none absolute inset-y-1.5 rounded-[calc(var(--radius-xl)-6px)] bg-primary shadow-sm transition-[left,width] duration-300 ease-out"
              style={{ left: pill.left, width: pill.width }}
            />
          )}
          {items.map((item) => (
            <CommandTab
              invertIcon={invertActiveIcon}
              isActive={item.value === activeValue}
              item={item}
              key={item.value}
              onSelect={handleSelect}
            />
          ))}
        </menu>
      )}
    </div>
  );
}

export type { CommandTabsItem, CommandTabsProps };

More from dominik-ui

Browse all components in this library →