Component Tabs trong design system của team mình từng có 23 prop: tabClassName, activeTabClassName, renderTabLabel, iconPosition, showBadge... Mỗi màn hình mới lại cần thêm một prop, vì component vừa quản lý hành vi (tab nào đang chọn, bàn phím, ARIA) vừa quyết định markup. Đến khi một team cần tab dọc với badge nằm bên phải, mình nhận ra vấn đề không phải thiếu prop, mà là đã gói hai thứ khác nhau vào một chỗ. Bài này là cách mình thiết kế lại theo hướng headless: component sở hữu hành vi, còn người dùng sở hữu giao diện.

Hành vi và giao diện là hai hợp đồng khác nhau#

Một component tương tác luôn có hai phần. Hợp đồng hành vi gồm state, chuyển đổi state, quản lý focus, phím tắt, thuộc tính ARIA và các callback. Hợp đồng giao diện gồm thẻ HTML nào, class nào, icon nằm đâu. Phần đầu khó viết đúng và nên giống nhau ở mọi nơi. Phần sau thay đổi theo từng sản phẩm.

Headless nghĩa là đóng gói phần đầu và nhường phần sau. Mình chia thành ba tầng:

TầngVí dụAi dùng
Hook headlessuseTabs() trả về state và prop getterNgười cần toàn quyền với markup
Compound componentTabs.Root, Tabs.List, Tabs.Trigger không có styleTeam xây design system
Component có styleSettingsTabs với token và class của design systemDev làm tính năng

Điều quan trọng nhất: accessibility thuộc về tầng hành vi, không phải thứ người dùng tự thêm vào sau. Nếu hook trả về aria-selected sai hoặc bàn phím không chạy, không có lớp CSS nào cứu được.

Tầng 1: hook headless và prop getter#

Cách đơn giản nhất là hook trả về state và object props. Nhưng nếu hook trả về { onClick, onKeyDown } và người dùng cũng muốn gắn onClick để gửi analytics, ai thắng? Spread sau thì ghi đè, spread trước thì bị ghi đè. Prop getter, pattern được Downshift phổ biến với getInputProps hay getItemProps, giải quyết bằng cách nhận props của người dùng và trả về props đã được hợp nhất:

TypeScript
import type { SyntheticEvent } from "react";

export function composeHandlers<E extends SyntheticEvent>(
  external: ((event: E) => void) | undefined,
  internal: (event: E) => void,
) {
  return (event: E) => {
    external?.(event);
    if (!event.defaultPrevented) internal(event);
  };
}

Handler của người dùng chạy trước, và nếu họ gọi event.preventDefault() thì hành vi mặc định của component bị bỏ qua. Đây là "cửa thoát hiểm" giúp người dùng tuỳ biến mà không phải fork component. Hook useTabs dựng trên đó:

TypeScript
import { useId, type ComponentPropsWithRef, type KeyboardEvent } from "react";
import { composeHandlers } from "./compose-handlers";
import { useControllableState } from "./use-controllable-state";

export type UseTabsOptions = {
  value?: string;
  defaultValue: string;
  onValueChange?: (value: string) => void;
  orientation?: "horizontal" | "vertical";
  activationMode?: "automatic" | "manual";
  dir?: "ltr" | "rtl";
};

type TabInput = ComponentPropsWithRef<"button"> & { value: string };
type PanelInput = ComponentPropsWithRef<"div"> & { value: string };

export function useTabs({
  value,
  defaultValue,
  onValueChange,
  orientation = "horizontal",
  activationMode = "automatic",
  dir = "ltr",
}: UseTabsOptions) {
  const [selectedValue, setSelectedValue] = useControllableState({
    value,
    defaultValue,
    onChange: onValueChange,
  });
  const baseId = useId();
  const tabId = (tabValue: string) => `${baseId}-tab-${tabValue}`;
  const panelId = (tabValue: string) => `${baseId}-panel-${tabValue}`;

  function findTargetTab(current: HTMLElement, key: string) {
    const tabList = current.closest('[role="tablist"]');
    const tabs = Array.from(tabList?.querySelectorAll<HTMLElement>('[role="tab"]:not([disabled])') ?? []);
    const index = tabs.indexOf(current);
    const forwardKey = orientation === "vertical" ? "ArrowDown" : dir === "rtl" ? "ArrowLeft" : "ArrowRight";
    const backwardKey = orientation === "vertical" ? "ArrowUp" : dir === "rtl" ? "ArrowRight" : "ArrowLeft";
    if (key === forwardKey) return tabs[(index + 1) % tabs.length];
    if (key === backwardKey) return tabs[(index - 1 + tabs.length) % tabs.length];
    if (key === "Home") return tabs[0];
    if (key === "End") return tabs.at(-1);
    return undefined;
  }

  function handleTabKeyDown(event: KeyboardEvent<HTMLElement>) {
    const target = findTargetTab(event.currentTarget, event.key);
    if (!target) return;
    event.preventDefault();
    target.focus();
    const targetValue = target.dataset.value;
    if (activationMode === "automatic" && targetValue) setSelectedValue(targetValue);
  }

  return {
    selectedValue,
    setSelectedValue,
    getTabListProps: (props: ComponentPropsWithRef<"div"> = {}) => ({
      ...props,
      role: "tablist",
      "aria-orientation": orientation,
    }),
    getTabProps: ({ value: tabValue, onClick, onKeyDown, ...props }: TabInput) => {
      const isSelected = tabValue === selectedValue;
      return {
        ...props,
        id: tabId(tabValue),
        role: "tab",
        tabIndex: isSelected ? 0 : -1,
        "aria-selected": isSelected,
        "aria-controls": panelId(tabValue),
        "data-value": tabValue,
        "data-state": isSelected ? "active" : "inactive",
        onClick: composeHandlers(onClick, () => setSelectedValue(tabValue)),
        onKeyDown: composeHandlers(onKeyDown, handleTabKeyDown),
      };
    },
    getPanelProps: ({ value: tabValue, ...props }: PanelInput) => ({
      ...props,
      id: panelId(tabValue),
      role: "tabpanel",
      tabIndex: 0,
      hidden: tabValue !== selectedValue,
      "aria-labelledby": tabId(tabValue),
    }),
  };
}

Vài chi tiết mình học được qua bug thật:

  • Thứ tự tab lấy từ DOM, không phải từ một mảng đăng ký trong state. Tab có thể được render có điều kiện, bọc trong component khác, hoặc sắp xếp lại; DOM luôn là thứ tự người dùng nhìn thấy. Đổi lại, mỗi lần nhấn phím có một lần query DOM, chi phí không đáng kể với vài chục tab.
  • Getter không tự gắn ref nội bộ. Nếu getter trả về ref: internalRef thì ref của người dùng truyền vào bị ghi đè âm thầm. Hoặc phải merge ref, hoặc tránh cần ref như ở đây với closest.
  • data-state cho phép style bằng CSS ([data-state="active"]) mà không cần thêm prop className nào.
  • value của tab đi vào id, nên nó phải là chuỗi kiểu slug, không có khoảng trắng.

Controlled và uncontrolled với useControllableState#

Một component tốt cho phép cả hai cách: defaultValue khi không quan tâm state, value cộng onValueChange khi cần điều khiển từ bên ngoài, ví dụ đồng bộ tab với URL. Thay vì lặp logic này trong mọi component, mình gom vào một hook:

TypeScript
import { useCallback, useEffect, useRef, useState, type SetStateAction } from "react";

type UseControllableStateParams<T> = {
  value?: T;
  defaultValue: T;
  onChange?: (value: T) => void;
};

export function useControllableState<T>({ value, defaultValue, onChange }: UseControllableStateParams<T>) {
  const [internalValue, setInternalValue] = useState(defaultValue);
  const isControlled = value !== undefined;
  const currentValue = isControlled ? value : internalValue;

  const wasControlled = useRef(isControlled);
  useEffect(() => {
    if (process.env.NODE_ENV !== "production" && wasControlled.current !== isControlled) {
      console.warn("useControllableState: component switched between controlled and uncontrolled.");
    }
    wasControlled.current = isControlled;
  }, [isControlled]);

  const setValue = useCallback(
    (next: SetStateAction<T>) => {
      const resolved = typeof next === "function" ? (next as (previous: T) => T)(currentValue) : next;
      if (Object.is(resolved, currentValue)) return;
      if (!isControlled) setInternalValue(resolved);
      onChange?.(resolved);
    },
    [currentValue, isControlled, onChange],
  );

  return [currentValue, setValue] as const;
}

Những điểm dễ sai:

  • undefined nghĩa là "không controlled". Vì vậy với state kiểu "chưa chọn gì", hãy dùng null thay vì undefined, nếu không component sẽ lặng lẽ chuyển sang uncontrolled.
  • Không gọi onChange khi giá trị không đổi. Click lại tab đang chọn mà vẫn bắn onValueChange sẽ làm phía ngoài fetch lại hoặc push thêm một bước lịch sử.
  • Không gọi onChange bên trong updater của setState. Updater phải thuần; ở Strict Mode React gọi nó hai lần và callback sẽ chạy hai lần.
  • Functional updater đọc giá trị của lần render hiện tại. Gọi setValue((v) => v + 1) hai lần trong cùng một event sẽ chỉ tăng một. Với tabs, select hay disclosure thì điều này không quan trọng; với counter thì có, và đó là lúc nên cân nhắc useReducer.

Bàn phím và ARIA là một phần của API#

Mình viết bảng bàn phím trước khi viết code, dựa trên pattern Tabs của WAI-ARIA Authoring Practices, và coi nó là spec:

PhímHành vi
TabVào tablist thì focus tab đang chọn; nhấn tiếp thì sang panel
Mũi tên trái/phải (lên/xuống nếu dọc)Sang tab trước hoặc sau, vòng lại ở hai đầu, đảo chiều khi RTL
Home / EndVề tab đầu hoặc cuối
Enter / SpaceKích hoạt tab đang focus ở chế độ manual

Đây là lý do roving tabindex quan trọng: chỉ tab đang chọn có tabIndex={0}, các tab khác là -1, nên cả tablist chỉ chiếm một điểm dừng khi nhấn Tab. Một edge case mình từng dính: nếu selectedValue không khớp tab nào (tab đã bị ẩn theo quyền), không tab nào có tabIndex={0} và người dùng bàn phím không vào được tablist. Component nên fallback về tab hợp lệ đầu tiên.

Về automatic hay manual activation: APG khuyến nghị automatic (focus là chọn) khi panel hiển thị ngay không có độ trễ đáng kể. Nếu mỗi panel phải fetch dữ liệu, di chuyển bằng mũi tên qua năm tab sẽ bắn năm request và làm focus ì ạch; lúc đó manual hợp lý hơn.

Nếu bạn chưa viết được bảng bàn phím và danh sách thuộc tính ARIA cho component, bạn chưa có thiết kế API. Test cũng nên viết theo hợp đồng này: query bằng getByRole("tab", { name: "Billing" }), nhấn phím và kiểm tra aria-selected, chứ không kiểm tra class name.

Tầng 2: compound component với context#

Prop getter linh hoạt nhưng dài dòng. Phần lớn người dùng chỉ muốn viết JSX gọn gàng, nên mình bọc hook thành compound component. Context giữ kết quả của useTabs và mỗi phần tự lấy getter mình cần. Đoạn dưới dùng cú pháp React 19: render thẳng TabsContext với prop value thay cho TabsContext.Provider, và use() để đọc context:

TSX
"use client";

import { createContext, use, type ComponentPropsWithRef, type ReactNode } from "react";
import { Slot } from "radix-ui";
import { useTabs, type UseTabsOptions } from "./use-tabs";

const TabsContext = createContext<ReturnType<typeof useTabs> | null>(null);

function useTabsContext(partName: string) {
  const context = use(TabsContext);
  if (!context) throw new Error(`<Tabs.${partName}> must be rendered inside <Tabs.Root>`);
  return context;
}

export function Root({ children, ...options }: UseTabsOptions & { children: ReactNode }) {
  const tabs = useTabs(options);
  return <TabsContext value={tabs}>{children}</TabsContext>;
}

export function List(props: ComponentPropsWithRef<"div">) {
  const { getTabListProps } = useTabsContext("List");
  return <div {...getTabListProps(props)} />;
}

type TriggerProps = ComponentPropsWithRef<"button"> & { value: string; asChild?: boolean };

export function Trigger({ asChild = false, ...props }: TriggerProps) {
  const { getTabProps } = useTabsContext("Trigger");
  const Component = asChild ? Slot.Root : "button";
  return <Component type={asChild ? undefined : "button"} {...getTabProps(props)} />;
}

export function Panel(props: ComponentPropsWithRef<"div"> & { value: string }) {
  const { getPanelProps } = useTabsContext("Panel");
  return <div {...getPanelProps(props)} />;
}

Người dùng import bằng import * as Tabs from "@/components/tabs", giống quy ước của Radix:

TSX
<Tabs.Root defaultValue="profile">
  <Tabs.List aria-label="Account settings" className="tab-list">
    <Tabs.Trigger value="profile">Profile</Tabs.Trigger>
    <Tabs.Trigger value="billing" asChild>
      <Button variant="ghost">Billing</Button>
    </Tabs.Trigger>
  </Tabs.List>
  <Tabs.Panel value="profile">
    <ProfileForm />
  </Tabs.Panel>
  <Tabs.Panel value="billing">
    <BillingSummary />
  </Tabs.Panel>
</Tabs.Root>

Hai điều cần cân nhắc với context. Thứ nhất, object trả về từ useTabs là mới sau mỗi render, nên mọi phần dùng context đều render lại khi Root render. Với tabs thì không sao. Với listbox hàng nghìn option, mình tách context: một context cho giá trị ít đổi (id, cấu hình), một cho state hay đổi, hoặc để mỗi item tự so sánh isSelected và memo. Thứ hai, lỗi rõ ràng khi dùng sai chỗ ("Tabs.Trigger must be rendered inside Tabs.Root") tiết kiệm cho người dùng rất nhiều thời gian debug so với một lỗi undefined mơ hồ.

asChild, render prop và cái giá của polymorphism#

Trong ví dụ trên, asChild cho phép tab được render bằng Button của design system thay vì một <button> trần. Slot của Radix không render phần tử nào; nó gộp props của nó vào phần tử con duy nhất: handler được nối lại (handler của con chạy trước), ref được gộp, className và style được kết hợp. Điều kiện là component con phải spread props nhận được xuống DOM và nhận ref. Ở React 19, ref là một prop bình thường nên không cần forwardRef nữa, nhưng vẫn có một bẫy kinh điển: Button tự set className sau khi spread, ghi đè className mà Slot đã gộp.

Có ba cách giải quyết bài toán "render thành phần tử khác":

CáchVí dụƯuNhược
Prop asas="label"Dễ hiểuType generic nặng, thông báo lỗi khó đọc, khó bọc tiếp
asChildRadixType đơn giản, JSX tự nhiênGộp props ngầm ở runtime, chỉ một phần tử con
Render propprop render của Base UITường minh, nhận được cả stateJSX dài hơn một chút

Prop as có type đúng trông như sau:

TSX
import type { ComponentPropsWithRef, ElementType } from "react";

type TextOwnProps = { tone?: "default" | "muted" };

type TextProps<C extends ElementType> = TextOwnProps & { as?: C } & Omit<
    ComponentPropsWithRef<C>,
    keyof TextOwnProps | "as"
  >;

export function Text<C extends ElementType = "span">({ as, tone = "default", ...props }: TextProps<C>) {
  const Component: ElementType = as ?? "span";
  return <Component data-tone={tone} {...props} />;
}

Nó chạy: Text với as="label" nhận htmlFor và được kiểm tra type đúng. Nhưng cái giá xuất hiện khi scale: Omit trên props của một generic làm TypeScript chậm thấy rõ trong design system lớn, thông báo lỗi dài cả màn hình, và bọc Text vào memo hay một HOC thường làm mất generic. Mình chỉ dùng as cho vài primitive về typography và layout; với component tương tác, mình chọn asChild hoặc render prop.

Khi nào không nên tự viết#

Viết Tabs như trên mất một buổi chiều. Viết Combobox, Menu có submenu, DatePicker hay Dialog lồng nhau đạt chuẩn trên mọi trình đọc màn hình thì mất vài tháng, và phần lớn thời gian nằm ở những edge case bạn chưa biết là tồn tại: typeahead, hành vi trên VoiceOver iOS, IME, focus khi phần tử bị gỡ khỏi DOM. Tính đến giữa 2026, mình chọn như sau:

Thư việnPhù hợp khiGhi chú
Radix PrimitivesCần bộ primitive quen thuộc, nhiều tài liệuDùng asChild, đã có package hợp nhất radix-ui, chưa có Combobox chính thức
Base UIBắt đầu design system mớiStable từ v1.0 (12/2025), dùng render prop, có Combobox; shadcn/ui chọn làm mặc định từ 7/2026
React AriaYêu cầu accessibility và i18n khắt kheCó cả hook (react-aria) lẫn component, mạnh về date, RTL, locale; API nhiều khái niệm hơn
DownshiftChỉ cần combobox hoặc select tuỳ biếnNơi prop getter trở nên phổ biến

Quy tắc của mình: dùng thư viện cho hành vi, tự sở hữu phần giao diện. Đó cũng là mô hình của shadcn/ui, nay hỗ trợ cả Radix, Base UI và React Aria làm nền. Mình chỉ tự viết khi component đủ đơn giản để viết được bảng bàn phím trong mười dòng (tabs, disclosure, toggle group), hoặc khi hành vi thật sự đặc thù mà không thư viện nào mô hình hoá được.

Tóm tắt#

  • Tách hợp đồng hành vi (state, focus, bàn phím, ARIA) khỏi hợp đồng giao diện; accessibility thuộc về tầng hành vi.
  • Dùng prop getter để hợp nhất props người dùng: nối handler, tôn trọng event.defaultPrevented, không ghi đè ref.
  • Hỗ trợ cả controlled và uncontrolled bằng một useControllableState dùng chung; dùng null thay vì undefined cho "chưa chọn".
  • Viết bảng bàn phím theo WAI-ARIA APG trước khi viết code, dùng roving tabindex, và test bằng role cùng phím bấm.
  • Compound component với context cho JSX gọn gàng; báo lỗi rõ khi dùng sai chỗ, tách context khi danh sách lớn.
  • Ưu tiên asChild hoặc render prop cho component tương tác; giữ prop as cho vài primitive đơn giản.
  • Dùng Radix, Base UI hoặc React Aria cho combobox, menu, date picker; chỉ tự viết khi hành vi đủ nhỏ để kiểm soát hết.