Skip to content

RTL tabs: order, indicators and keyboard

Build RTL tabs with predictable keyboard movement, separate focus and selection, aligned active indicators and usable overflow scrolling.

By , Founder of Ritla

Guide12 min read

An Arabic tab strip can start on the right while its keyboard handler still moves focus in the opposite direction from the key pressed. Its active underline may have a third interpretation, using a physical left offset calculated before the tab labels changed width. Fixing the strip's alignment does not fix either behavior.

Treat tab order, focus, selected content and indicator geometry as separate responsibilities. They should describe the same interface, but they do not always change together. In particular, a manually activated tab can have focus while a different tab remains selected.

This guide uses a weather-station record with readings, calibration and maintenance panels. The first tab begins at the right in Arabic. Its source order stays readings, calibration, maintenance in either language; direction changes the presentation and horizontal keyboard mapping.

Confirm that these controls are tabs#

Use a tab interface when a set of controls switches between associated panels in one interface. If the controls simply link to separate record pages, ordinary navigation links may be the appropriate model. Giving those links tab roles introduces selection and keyboard expectations that styling alone cannot satisfy.

The WAI tabs pattern describes the relationship among a tab list, its tabs and the displayed panel. Apply that relationship before considering RTL animation. One selected tab should identify the visible panel, and every panel should have a reliable connection to its controlling tab.

Do not use aria-current as a substitute for tab selection. A current navigation destination and a selected panel control describe different states. Tabs use aria-selected, with the selected tab styled distinctly and its corresponding panel exposed.

Choose whether focus automatically selects a tab. Automatic activation suits panels that can appear without noticeable delay. Manual activation lets a visitor move among tab names before pressing Enter or Space to request the panel. The station example uses manual activation so keyboard exploration does not change the displayed record section.

Preserve source order and establish direction once#

Put the tab buttons in the intended conceptual order. In a horizontal RTL flex row, the first source item starts on the right already. Reversing the data or adding row-reverse introduces a second ordering decision that the keyboard handler must somehow compensate for.

A particularly fragile combination is an RTL row, reversed data and an index-based underline. A screenshot might show the desired labels, while tab-to-panel associations or arrow movement still follow one of the other sequences. Remove the competing reversals before changing the event handler.

Declare Arabic language and RTL direction on the document root in the application. The local declarations below make this example usable on its own. Language identifies the content; it does not set layout direction. A local LTR wrapper around the tab list can also interrupt inheritance, so inspect that element's computed direction when diagnosing order.

The markup represents an enhanced widget whose script runs after the elements exist. Production integration should provide its loading or no-script fallback through the application rather than presenting nonfunctional panel selectors indefinitely.

html
<div class="station-tabs" lang="ar" dir="rtl">  <div class="station-tabs__viewport">    <div class="station-tabs__list" role="tablist"         aria-label="أقسام سجل محطة الطقس">      <button id="station-tab-readings" type="button" role="tab"              aria-selected="true" aria-controls="station-panel-readings"              tabindex="0">القراءات</button>      <button id="station-tab-calibration" type="button" role="tab"              aria-selected="false" aria-controls="station-panel-calibration"              tabindex="-1">المعايرة</button>      <button id="station-tab-maintenance" type="button" role="tab"              aria-selected="false" aria-controls="station-panel-maintenance"              tabindex="-1">سجل الصيانة</button>    </div>  </div>   <div id="station-panel-readings" role="tabpanel"       aria-labelledby="station-tab-readings" tabindex="0">    <p>تظهر هنا القراءات المسجلة للمحطة.</p>  </div>  <div id="station-panel-calibration" role="tabpanel"       aria-labelledby="station-tab-calibration" tabindex="0" hidden>    <p>راجع آخر معايرة لأجهزة القياس.</p>  </div>  <div id="station-panel-maintenance" role="tabpanel"       aria-labelledby="station-tab-maintenance" tabindex="0" hidden>    <p>تظهر هنا أعمال الصيانة المسجلة.</p>  </div></div>

The panel-to-tab references work in both directions: each tab controls a panel, and each panel is named by its tab. Use instance-specific IDs if the page can render several station records. Array positions are not suitable IDs when permissions or record state can remove a tab.

Keep the tab's name about the panel it selects. The list's accessible name identifies the group, while the individual names identify readings, calibration and maintenance. Repeating the full station-record title on every button can make exploring a short list unnecessarily verbose. Conversely, replacing each visible Arabic name with an unrelated English aria-label creates a mismatch between what visitors see and hear.

Do not put another interactive control inside a tab button. A close action, settings menu or editable name needs its own interaction design; nesting a button inside the tab's button is not that design. Text, decorative icons and a supplementary count can form the label, but the complete button should still perform one selection action.

The panels here contain paragraphs, so the active panel itself is focusable. If a production panel begins with a suitable focusable content element, review whether an additional panel stop helps. MDN's tab role reference covers these semantics and keyboard responsibilities.

Show selection independently of keyboard focus#

Use one visible focus style and another selected style. Otherwise a visitor moving to calibration may see the readings underline disappear even though the readings panel has not changed. In manual mode, that distinction is part of the interaction, not a transient rendering defect.

The initial layout uses a border on the selected button as its indicator. Each tab owns its own underline, eliminating any need to calculate a shared bar's position:

css
.station-tabs {  min-inline-size: 0;} .station-tabs [hidden] {  display: none;} .station-tabs__viewport {  overflow-x: auto;  padding: 0.5rem;} .station-tabs__list {  display: flex;  position: relative;  inline-size: max-content;  min-inline-size: 100%;  gap: 0.75rem;  padding: 0;  border: 0;} .station-tabs [role="tab"] {  flex: none;  font: inherit;  min-block-size: 2.75rem;  padding-block: 0.625rem;  padding-inline: 1rem;  border: 0;  border-block-end: 0.2rem solid transparent;  background: transparent;  color: inherit;} .station-tabs [role="tab"][aria-selected="true"] {  border-block-end-color: currentColor;  font-weight: bold;} .station-tabs [role="tab"]:focus-visible,.station-tabs [role="tabpanel"]:focus-visible {  outline: 3px solid currentColor;  outline-offset: 3px;}

The transparent border reserves the indicator's thickness on every tab. The selected style changes its color rather than adding a new border that changes the button's height. Bold text can still change width, so a shared measured indicator must account for the final selected styling.

The hidden rule preserves panel visibility when other component styles set display properties. Hiding a panel through opacity alone would leave a different interaction problem: its controls could still be available even though the content appears absent.

Map horizontal arrows to the visual neighbors#

This example follows a spatial convention: ArrowLeft moves to the tab on the left, and ArrowRight to the tab on the right, wrapping at the ends. With source-first placement at the right in Arabic, ArrowLeft increases the source index; in English it decreases it.

Home and End remain tied to the first and last source tabs. Home therefore reaches readings at the right in Arabic. Do not reinterpret Home as “physical left” while the rest of the component follows reading order.

The APG tabs pattern supplies the tab interaction model. Direction-aware implementations also need an RTL interpretation of the horizontal keys: for example, Radix's roving-focus implementation swaps the horizontal key mapping for RTL. Verify the documented behavior of your chosen component before adding another inversion around it.

The script below handles the three enabled, horizontal tabs in the markup. It keeps focus movement separate from selection, and uses the native buttons' click activation for Enter and Space:

js
const tabList = document.querySelector(".station-tabs__list");const stationTabs = [...tabList.querySelectorAll(':scope > [role="tab"]')];const stationPanels = stationTabs.map(tab =>  document.getElementById(tab.getAttribute("aria-controls"))); function setTabEntry(index) {  stationTabs.forEach((tab, i) => { tab.tabIndex = i === index ? 0 : -1; });} function activateStationTab(index) {  stationTabs.forEach((tab, i) => {    tab.setAttribute("aria-selected", String(i === index));    stationPanels[i].hidden = i !== index;  });  setTabEntry(index);} function focusStationTab(index) {  setTabEntry(index);  stationTabs[index].focus({ preventScroll: true });  stationTabs[index].scrollIntoView({ block: "nearest", inline: "nearest" });} stationTabs.forEach((tab, index) => {  tab.addEventListener("click", () => {    activateStationTab(index);    focusStationTab(index);  });   tab.addEventListener("keydown", event => {    if (event.altKey || event.ctrlKey || event.metaKey) return;    const rtl = getComputedStyle(tabList).direction === "rtl";    let destination;     if (event.key === "Home") destination = 0;    else if (event.key === "End") destination = stationTabs.length - 1;    else if (event.key === "ArrowLeft") destination = index + (rtl ? 1 : -1);    else if (event.key === "ArrowRight") destination = index + (rtl ? -1 : 1);    else return;     event.preventDefault();    focusStationTab((destination + stationTabs.length) % stationTabs.length);  });}); tabList.addEventListener("focusout", event => {  if (!tabList.contains(event.relatedTarget)) {    setTabEntry(stationTabs.findIndex(tab =>      tab.getAttribute("aria-selected") === "true"    ));  }}); activateStationTab(0);

Arrow movement changes the roving Tab entry without changing aria-selected or the visible panel. Activation changes selection. When focus leaves the list, its entry returns to the selected tab, so coming back does not unexpectedly enter an unactivated choice.

The handler does not capture Tab, Up or Down. Tab leaves the composite widget, while vertical arrow keys retain their ordinary page behavior in this horizontal design. The manual-activation APG example explains the separation between focus and activation; test your completed component with assistive technology before shipping.

Reveal focused tabs without assuming a scroll sign#

The overflow viewport contains a content-width row. As labels grow, the row can exceed the available width while the panels remain within the page. A long translated label should enlarge its tab rather than silently lose the words distinguishing it from another tab.

Calling scrollIntoView with inline: "nearest" asks for the focused tab to become visible without assigning a physical horizontal scroll amount. MDN documents those alignment options in its scrollIntoView reference. It can affect ancestor scrolling too, so check the outer page and any sticky header while testing.

Do not assume scrollLeft grows positively toward later RTL tabs. MDN's scrollLeft reference describes RTL cases where the start is zero and movement toward the content end is negative. A positive-only clamp or “scroll left by a positive amount” helper can therefore fail at the starting edge.

Test pointer, keyboard and touch access to the last tab. A fade at the viewport edge can hint at more content, but it does not make an off-screen focused tab visible. If scroll buttons are present, name them for their operation and define their disabled boundaries from the actual scroll model.

A button that reveals more of the tab strip should not select whichever tab happens to move into view. Scrolling changes the viewport; activating a tab changes the panel. Keep the selected state intact when the user drags the strip, uses a scroll button or scrolls back to inspect an earlier label. Likewise, a selected tab remaining off-screen after a deliberate pointer scroll is different from keyboard focus becoming invisible during arrow navigation.

Keep the keyboard handler on the tabs themselves, as in the example. Calibration content may later contain a slider or numeric input that also uses arrow keys. A handler attached indiscriminately to the entire widget can intercept those keys and move to another tab while the visitor is editing a panel. Test that panel controls retain their own keyboard behavior.

Keep this horizontal tab list on one row. Wrapping tab buttons creates a two-dimensional visual arrangement that the left/right-only handler above does not model. If a design changes to vertical tabs, update its orientation and keyboard behavior together instead of changing only the flex direction.

Inspect the ancestors if the overflow viewport stretches the whole page instead of scrolling internally. The nearest flex or grid item can retain a content-based minimum even though the viewport itself permits scrolling. Identify the box that refuses to shrink and resolve its inline sizing. Adding a scrollbar rule to a descendant does not establish the available width of its parent.

Leave enough inset around the scrollable row for the focus outline. A tab can be technically inside the viewport while its outline is clipped at the top, bottom or outer edge. Test the first and last focused buttons at the scroll limits, and do not rely solely on the scrollbar thumb as evidence that the complete control is visible.

Measure a sliding indicator in the row's coordinates#

Use the per-tab border unless the design specifically needs a bar moving between tabs. A shared bar introduces a coordinate problem: a value measured from the viewport's left edge is not automatically an inline-start offset inside a scrolled RTL row.

For a sliding version, add this decorative element inside the tab list and replace the selected button's colored border with the shared indicator treatment:

html
<span class="station-tabs__indicator" aria-hidden="true" hidden></span>
css
.station-tabs__indicator {  position: absolute;  inset-inline-start: var(--indicator-start, 0px);  inset-block-end: 0;  inline-size: var(--indicator-size, 0px);  block-size: 0.2rem;  background: currentColor;  pointer-events: none;  transition: inset-inline-start 160ms ease, inline-size 160ms ease;} @media (prefers-reduced-motion: reduce) {  .station-tabs__indicator {    transition: none;  }}

This function assumes the unscaled, borderless, padding-free row from the example. It measures the selected button relative to that row, using the appropriate starting edge:

js
function updateStationIndicator() {  const row = document.querySelector(".station-tabs__list");  const selected = row.querySelector('[aria-selected="true"]');  const bar = row.querySelector(".station-tabs__indicator");  const rowBounds = row.getBoundingClientRect();  const tabBounds = selected.getBoundingClientRect();  const rtl = getComputedStyle(row).direction === "rtl";  const start = rtl    ? rowBounds.right - tabBounds.right    : tabBounds.left - rowBounds.left;   bar.style.setProperty("--indicator-start", `${start}px`);  bar.style.setProperty("--indicator-size", `${tabBounds.width}px`);  bar.hidden = false;}

Both rectangles share viewport coordinates, as documented for getBoundingClientRect(). Subtracting matching edges expresses the offset within the row; the row and tab moving together during scrolling does not require an extra scrollLeft correction. Adding one would count that movement again.

For a concrete geometry check, suppose the row extends from physical left minus 140 to right 500, and the selected tab extends from left 260 to right 404. Its width is 144. These are illustrative coordinates in CSS pixels, not a browser benchmark.

QuantityCalculationValue
RTL inline-start offsetRow right minus tab right96
LTR inline-start offsetTab left minus row left400
Indicator widthTab right minus tab left144

Applying the LTR value as an RTL inline-start offset would target a different position. If scrolling moves both rectangles right by 72 pixels, the RTL difference is still 96. This gives you a test for a mixed-coordinate bug without guessing which physical scroll sign the component should add.

inset-inline-start maps that logical distance to the relevant side. Selection, not the focused-but-unactivated tab, supplies the bar's target. A scale transform on the row would require additional coordinate handling beyond this calculation.

Call the function after activation has applied the selected styling and once after the optional bar is installed. Recalculate when layout or language changes. Observe the row and every tab with ResizeObserver, because another tab changing width can move the selected tab even if its own width is unchanged. A direction change also needs an update even when dimensions stay constant.

Honor the reduced-motion preference without delaying selection or panel visibility until an animation ends. The bar is decoration; it must not own the content state.

Test the selected treatment in high-contrast or forced-colors configurations as well. A correctly positioned decorative bar is not useful if it becomes indistinguishable from the background. Retain another selected cue, and keep keyboard focus visible independently. Do not remove the focus outline merely because the animated bar appears beneath the focused button in an automatic-activation demo.

Change orientation as a complete interaction#

A narrow layout may replace a horizontal tab row with a vertical list beside or above the panel. That requires aria-orientation="vertical" and a corresponding keyboard policy: Up and Down move through the vertical sequence. RTL does not reverse the vertical order of the three station sections.

Decide how Home, End and wrapping behave in that presentation, then verify focus against the displayed neighbors. The horizontal handler in this guide is intentionally insufficient for it. If the orientation can change while a tab has focus, preserve that tab's identity and apply the new keyboard policy without resetting selection to the first panel.

The indicator also changes geometry. A horizontal bottom bar's width and inline-start position do not describe a vertical side marker's height and block-start position. Define the new marker relative to the list and panel arrangement, or retain a per-tab selected border that requires no shared moving element. Reusing the old measurement function after switching axes can produce an indicator that follows the right index on the wrong dimension.

Handle loading and structural changes explicitly#

Manual activation is useful when calibration or maintenance content must be fetched. Arrowing past a tab should not launch a request or announce a new panel before the visitor chooses it. If you adopt automatic activation, evaluate the panel's actual display latency rather than selecting that mode because it requires fewer event handlers.

Keep loading, selection and request identity separate. If a maintenance request completes after the visitor activates readings, it must not replace the readings panel. Cache or discard the response according to the application model, and show any error inside the panel associated with the requested tab.

Do not place focus inside a panel while hiding it. A route update or external record switch can change the selected tab while the user is editing panel content; decide where focus should move before that content disappears. Keep stable IDs and preserve form state according to the record's editing policy.

The sample captures a fixed set of enabled tabs. Dynamic removal, disabled items and nested tab groups need additional collection and focus logic. A disabled tab must not become an activation target through the arrow handler, and removing the selected tab needs a defined replacement. Do not filter only the focus array while leaving panel associations tied to old indexes.

When using a component library, configure its direction, orientation and activation mode through its API. For example, Radix Tabs exposes those settings. Putting RTL CSS around a component whose internal keyboard logic is still configured for LTR can produce the original mismatch even when its labels and underline look correct.

Preserve selection by a stable panel key when the station record or locale changes. Translating “maintenance” changes its label, not which panel it identifies. If a reload or deep link restores calibration, initialize the selected state, panel visibility and Tab entry from that restored choice before measuring the indicator. The sample initializes readings explicitly; replace that initialization when the application has a different valid restored state.

Test focus, selected content and geometry separately#

Use Arabic and English fixtures with the same panel identities. Keep a visible focus outline while testing; an underline alone cannot show whether manual focus and selection have diverged correctly.

  1. Enter with Tab and confirm the selected tab receives focus. Move left and right, including wrapping at both ends. Repeat in LTR and RTL, checking the neighbor reached rather than only the source index.
  2. In manual mode, focus calibration without activating it. Readings should remain selected and visible. Activate calibration with Enter and Space in separate runs, then verify its panel and selected state.
  3. Press Tab from an unactivated focused tab, then return to the list. Confirm the selected entry policy and the active panel's place in the page sequence.
  4. Narrow the viewport and use an approved longer label such as سجل الصيانة الدورية وأعمال الإصلاح. Reach the last tab by keyboard and touch; inspect page scrolling and focus clipping.
  5. For a shared indicator, select each tab before and after scrolling. Delay font loading, enlarge text, change locale and resize. Compare the selected button's bounds with the bar, including when another label changes width.
  6. Test reduced motion, failed panel loads and rapid activation. If tabs can disappear or become disabled, verify the resulting focus target and panel associations.

Inspect accessible names, selected states and panel relationships with the browser and assistive technologies the product supports. A screenshot cannot establish keyboard behavior, and a correct accessibility tree does not establish that a selected underline sits under the right label.

Run a free Ritla scan on the tab interface for untranslated interactive labels with R011 and document-level horizontal overflow with R040. Verify arrow-key movement and indicator alignment by interaction; if the tab strip's layout remains reversed, continue with RTL flexbox, or use debugging RTL layout problems to inspect its coordinate boundaries.

Checks in this guide

See what your Arabic pages are hiding

Paste a URL. Ritla renders the page on desktop and mobile, runs every check, and shows the top issues with screenshot evidence.

Scan a page freeBrowse the checks