UIDex

Tabs

التبويبات

role="tablist"

also calledtabbed interface، tab group، tablist، tabpanel، tabbed panels، tab control، تابس، التابات، الألسنة، علامات التبويب، شريط التبويبات

Tabs are a row of labels where each label reveals one panel of content in the same slot, and exactly one panel is visible at a time. The defining condition is that the panels are equal siblings, at the same level with no order between them, and that moving between them costs nothing and loses nothing the user typed. Tabs are not a stepper, because steps run in order from a start to an end and carry a completion state, while tabs are never "finished" and nobody has to visit them all. They are not a segmented control either, since a segmented control picks a value for one field and is usually built on radiogroup rather than tablist, whereas a tab swaps an entire panel of content. The biggest difference of all is with links: anything that changes the URL and loads a new page is a navigation link rather than a tab. It stays an <a href> inside <nav>, and putting role="tab" on it breaks both open-in-new-tab and the back button. As a working rule, if the address bar changes it is navigation, and if only the region below changes it is a tab.

If you called it…

"the row of buttons that swaps what is underneath""the little underline that slides to the selected label""browser-style tabs but inside the page""sections next to each other where only one shows""switching sections without the page reloading"

Live specimen

Interact with the demo. Every part is real and numbered.

ثلاثة طلبات قيد التنفيذ، واثنا عشر طلباً مكتملاً هذا الشهر.
RTLركّز على تبويب وجرّب الأسهم، معناها ينقلب مع الاتجاه

Anatomy: every part, named

Hover a row to locate it in the demo above.

Build prompt

Build an ARIA-pattern tab group. The container is an element with role="tablist" and a clear aria-label; each tab is a <button type="button" role="tab"> with an id and an aria-controls pointing at its panel, with aria-selected="true" on exactly one and an explicit "false" on the rest. Implement roving tabindex, with 0 on the selected tab and -1 on the others. Add arrow navigation with wraparound, Home and End for the first and last tab, and invert the meaning of ArrowLeft and ArrowRight whenever the container direction is rtl, read at runtime from getComputedStyle(list).direction rather than from the UI language. Use manual activation (Enter or Space) when a panel fetches data, automatic activation otherwise. Each panel is role="tabpanel" with aria-labelledby pointing back at its tab and tabindex="0" when it holds nothing focusable; inactive panels are hidden with the hidden attribute, not just visually. Compute the active underline offset from the logical start with getBoundingClientRect instead of offsetLeft, and move it with inset-inline-start or with translateX multiplied by a --dir variable. On overflow, scroll the strip horizontally with overflow-x: auto plus scroll-padding-inline-start, call scrollIntoView({ inline: "nearest" }) on the selected tab at first paint and on every change, and anchor the two fade overlays with inset-inline-start and inset-inline-end while flipping the linear-gradient angle under [dir="rtl"]. Use logical properties for all spacing and borders (padding-inline, margin-inline, border-inline-start) and write no left or right anywhere. Keep the active tab in the URL so it survives a refresh and a shared link.

How it behaves right-to-left

This section is ours alone.

The arrow keys swap

mirrors

For a horizontal tablist the ARIA pattern says ArrowRight moves to the next tab in LTR. In RTL that meaning inverts, so ArrowLeft becomes "next" and ArrowRight becomes "previous". Read direction at runtime from getComputedStyle(list).direction or list.matches(":dir(rtl)") and multiply your step by -1. Never infer it from the UI language, since any single container can be flipped on its own. Home and End do not swap: Home is always the first tab in DOM order, which in Arabic is the rightmost one.

The active underline

mirrors

offsetLeft measures from the left edge whatever the direction, so a bar position derived from it lands under the wrong tab in RTL. Measure from the logical start instead: in LTR tab.getBoundingClientRect().left − list.left, in RTL list.right − tab.right. Then move the bar with inset-inline-start, or with translateX multiplied by a --dir variable set to -1 under [dir="rtl"], because translateX itself is physical and does not mirror with the page.

Scrolling an overflowing strip

mirrors

The strip rests at its logical start, which in Arabic is the right edge. In current browsers scrollLeft is 0 at that edge and goes negative as you scroll leftwards, so a "next" button written as el.scrollLeft += 200 scrolls the wrong way, and any comparison against the scroll offset has to go through Math.abs. Use el.scrollBy({ left: delta * dir }) or, better, scrollIntoView({ inline: "nearest" }), which works on the logical axis. Also replace scroll-padding-left with scroll-padding-inline-start.

The scroll-shadow gradients

mirrors

to right and to left are always physical, because linear-gradient accepts no logical direction keywords. The fade that says "there is more" will therefore not mirror itself, however the page direction is set. Anchor the two overlays with inset-inline-start and inset-inline-end so they move to the correct side on their own, then flip the gradient angle under [dir="rtl"] or feed it from a --fade-angle variable. The same limit applies to mask-image if you fade by masking instead.

What must never flip

never mirrors

Do not reverse the tab order with flex-direction: row-reverse. direction: rtl alone already puts the first tab on the right, whereas row-reverse flips the picture and leaves DOM order untouched. The spec states outright that flexbox visual reordering does not affect navigation order, so the arrow keys end up moving against what the eye sees. Likewise aria-orientation describes the layout axis, not the language, so it stays "horizontal" in Arabic. Neutral tab icons (a settings gear, a cart, a bell) never mirror.

In code

Each row is one framework's word for the same thing. Take the row your project speaks.

ARIArole="tablist" + aria-selectedExactly one tab selected, everything else an explicit aria-selected="false".
HTML<button type="button" role="tab" aria-controls>A tab is a button, not a link; anything that changes the URL stays an <a href> inside <nav>.
Radix UI<Tabs.Root activationMode="manual">The switch that turns arrow-key auto-activation into activate-on-Enter.
shadcn/ui<Tabs><TabsList><TabsTrigger value>Built on Radix; the value string is what pairs each trigger with its TabsContent.
Material UI<Tabs variant="scrollable" scrollButtons>Material handles the overflow scroller and the sliding indicator element for you.
Ant Design<Tabs items={items} tabPosition="left" />Tabs are passed as an items array, and tabPosition is named physically (left/right), not logically.
CSS:dir(rtl)The selector that reads the resolved direction of the element itself, so you flip a linear-gradient angle without tying the rule to the page language.

See also

Updated · 2026-08-19