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ầng | Ví dụ | Ai dùng |
|---|---|---|
| Hook headless | useTabs() trả về state và prop getter | Người cần toàn quyền với markup |
| Compound component | Tabs.Root, Tabs.List, Tabs.Trigger không có style | Team xây design system |
| Component có style | SettingsTabs với token và class của design system | Dev 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:
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 đó:
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
refnội bộ. Nếu getter trả vềref: internalRefthì 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ớiclosest. data-statecho phép style bằng CSS ([data-state="active"]) mà không cần thêm prop className nào.valuecủa tab đi vàoid, 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:
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:
undefinednghĩa là "không controlled". Vì vậy với state kiểu "chưa chọn gì", hãy dùngnullthay vìundefined, nếu không component sẽ lặng lẽ chuyển sang uncontrolled.- Không gọi
onChangekhi giá trị không đổi. Click lại tab đang chọn mà vẫn bắnonValueChangesẽ 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
onChangebên trong updater củasetState. 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ắcuseReducer.
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ím | Hành vi |
|---|---|
| Tab | Và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 / End | Về tab đầu hoặc cuối |
| Enter / Space | Kí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:
"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:
<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ách | Ví dụ | Ưu | Nhược |
|---|---|---|---|
Prop as | as="label" | Dễ hiểu | Type generic nặng, thông báo lỗi khó đọc, khó bọc tiếp |
asChild | Radix | Type đơn giản, JSX tự nhiên | Gộp props ngầm ở runtime, chỉ một phần tử con |
| Render prop | prop render của Base UI | Tường minh, nhận được cả state | JSX dài hơn một chút |
Prop as có type đúng trông như sau:
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ện | Phù hợp khi | Ghi chú |
|---|---|---|
| Radix Primitives | Cần bộ primitive quen thuộc, nhiều tài liệu | Dùng asChild, đã có package hợp nhất radix-ui, chưa có Combobox chính thức |
| Base UI | Bắt đầu design system mới | Stable từ v1.0 (12/2025), dùng render prop, có Combobox; shadcn/ui chọn làm mặc định từ 7/2026 |
| React Aria | Yêu cầu accessibility và i18n khắt khe | Có cả hook (react-aria) lẫn component, mạnh về date, RTL, locale; API nhiều khái niệm hơn |
| Downshift | Chỉ cần combobox hoặc select tuỳ biến | Nơ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
useControllableStatedùng chung; dùngnullthay vìundefinedcho "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
asChildhoặc render prop cho component tương tác; giữ propascho 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.