← Blog
19 Sept 2026tab navigationVue accessibilityDOM StudioARIA tabskeyboard navigation

Building Accessible Tab Navigation in Vue

Master accessible tab navigation in Vue. Learn ARIA roles, keyboard interactions, and DOM Studio primitives for production-ready, AI-editable interfaces.

Building Accessible Tab Navigation in Vue

You’re probably dealing with this already. The tabs work in Storybook, the ARIA attributes look right, and keyboard access seems fine until the component lands inside a real app with a sticky header, a cookie banner, a floating chat launcher, and a responsive layout that shifts under zoom.

That’s where most tab navigation guidance stops being useful. Production interfaces don’t fail because developers forgot the Tab key exists. They fail because focus moves somewhere the user can’t see, arrow-key behaviour breaks once the tab list overflows, or a custom component library makes every team re-solve the same focus problems by hand.

Table of Contents

The Hidden Focus Problem in Modern Interfaces

Most tab navigation tutorials assume a clean viewport. Real products rarely have one. You tab onto a control, the browser technically focuses it, and the focused element sits under a sticky bar or behind a floating widget. From the browser’s perspective, nothing is wrong. From the user’s perspective, focus has vanished.

A stressed woman with a messy bun pointing at a laptop screen filled with multiple open windows.

That failure shows up more often than many teams expect. A UK accessibility audit notes that modern UI chrome such as sticky headers, chat widgets, cookie banners, carousels, and back-to-top controls can obscure the focused element, and the same audit reports missing skip-navigation links on 61.3% of pages tested (UK accessibility audit report). Keyboard users still get forced through repeated navigation, then hit focus states that are reachable but not clearly visible.

Tabbable is not the same as usable

A control can be in the tab order and still be a bad experience. That distinction matters.

When I review tab navigation in production apps, I look for three separate guarantees:

  • Reachability: Can the user get to every interactive control with Tab and reverse with Shift+Tab?
  • Visibility: Is the focused element visible, not clipped, covered, or scrolled halfway out of frame?
  • Predictability: Does focus move in a way the user can anticipate from the visual layout?

UK public-sector guidance treats keyboard movement as a baseline requirement. Users should be able to use Tab to move through links and controls, Shift+Tab to reverse, and the sequence should follow the visual order from left to right and top to bottom (Home Office accessibility standard).

Practical rule: If a keyboard user has to hunt for the focus ring, your tab navigation is broken even when the DOM says it isn’t.

What fixes this in component systems

The reliable fix isn’t “add more tabindex values”. That usually makes things worse. Positive tabindex creates a second navigation system that fights the document order.

What works is boring and robust:

  1. Keep DOM order aligned with visual order.
  2. Let native focus move naturally where possible.
  3. Use roving tabindex only inside composite widgets such as tabs.
  4. On focus, ensure the active control scrolls into view within its nearest scroll container.
  5. Reserve space and z-index rules so sticky UI doesn’t sit on top of focus targets.

If your team is standardising accessible components, it helps to use headless primitives that already model these rules instead of rewriting them in every feature branch. The broader patterns behind that approach line up with modern accessibility best practices for production UI systems.

Anatomy of an Accessible Tab Component

Tabs are a composite widget. Treat them like a row of ordinary buttons and you’ll usually end up with a noisy tab sequence, inconsistent announcements, and too many edge cases around selection versus focus.

A diagram illustrating the accessibility structure of a tabbed interface using ARIA roles and keyboard interactions.

The semantic contract

An accessible tab set needs a small but strict contract between three parts:

Element Role Purpose
Tab container tablist Groups the available tabs
Each trigger tab Represents one selectable tab
Each content area tabpanel Holds the content associated with the active tab

The state and relationship attributes matter just as much as the roles:

  • aria-selected tells assistive tech which tab is active.
  • aria-controls links a tab to its panel.
  • aria-labelledby on the panel points back to the controlling tab.
  • tabindex determines which tab is in the keyboard sequence at a given moment.

A common mistake is making every tab trigger tabbable all the time. That turns a simple tab set into a long keyboard detour. Users should be able to enter the widget, move within it efficiently, and leave it without stepping through every sibling first.

The single-focus-target pattern

UK design guidance for tabbed interfaces recommends a smarter pattern. Focus lands on the tab bar once, then users move between tabs with arrow keys, activate with Space or Enter, and jump to the first or last tab with Home and End (Intelligence Community Design System tabs guidance).

That pattern matters for two reasons.

First, it reduces tab-stop bloat. A tab row with six items shouldn’t require six separate Tab presses just to move past it.

Second, it separates focus movement from selection state. In a good implementation, the keyboard user can explore the tab options with arrow keys, and your component can decide whether focus also activates the panel immediately or waits for Enter or Space.

Good tabs feel closer to a radio group with content regions than a row of unrelated buttons.

What naïve implementations get wrong

You’ll see the same problems repeatedly in custom tab components:

  • Every tab in the Tab order: Users get trapped in unnecessary repetition.
  • Hidden panels left focusable: Keyboard users tab into content that isn’t visible.
  • Selection without linkage: The active tab changes visually, but screen readers don’t get the right relationships.
  • Manual IDs everywhere: Teams duplicate ARIA wiring by hand and introduce mismatches during refactors.

The fix is to think in terms of a composite widget, not isolated controls. Once you do that, the DOM structure becomes simpler. One active tab is tabbable. Siblings are arrow-navigable. One panel is shown. The others are removed from interaction in a way that matches your rendering strategy.

Implementing Tabs with DOM Studio Vue Wrappers

If you’ve built tabs from scratch in Vue, you know where the code starts to sprawl. A simple visual component turns into ID generation, selected-state syncing, arrow-key handlers, panel registration, and edge cases around focus when tabs mount or unmount.

A headless primitive approach removes most of that glue code. The primitive owns behaviour and relationships. The Vue wrapper exposes them through props, slots, and model binding that fit naturally into app code.

Start with state, not markup tricks

The first decision is whether your tab set is controlled or uncontrolled.

For app-level state, a controlled pattern is usually cleaner:

<script setup>
import { ref } from 'vue'
import { TabsRoot, TabsList, TabsTab, TabsPanel } from '@domstudio/vue'

const activeTab = ref('billing')
</script>

<template>
  <TabsRoot v-model="activeTab" activation="manual">
    <TabsList class="flex gap-2 border-b">
      <TabsTab value="overview" v-slot="{ selected, focused }">
        <button
          class="px-4 py-2"
          :class="selected ? 'border-b-2 font-medium' : 'text-slate-600'"
          :data-focused="focused || undefined"
        >
          Overview
        </button>
      </TabsTab>

      <TabsTab value="billing" v-slot="{ selected }">
        <button
          class="px-4 py-2"
          :class="selected ? 'border-b-2 font-medium' : 'text-slate-600'"
        >
          Billing
        </button>
      </TabsTab>
    </TabsList>

    <TabsPanel value="overview">
      <section>Overview content</section>
    </TabsPanel>

    <TabsPanel value="billing">
      <section>Billing content</section>
    </TabsPanel>
  </TabsRoot>
</template>

The important part isn’t the styling. It’s that the wrapper can keep the ARIA relationships, roving tabindex, and keyboard behaviour in sync with your Vue state.

Why wrappers help in real teams

In practice, Vue teams don’t struggle with rendering tabs. They struggle with keeping behaviour consistent across dozens of variants.

A thin wrapper helps because it gives you:

  • Reactive selection state: v-model keeps the active tab aligned with route state, filters, or persisted preferences.
  • Scoped slot signals: You can style selected, focused, or disabled states without re-implementing interaction logic.
  • Stable panel linkage: The primitive handles trigger-to-panel relationships so refactors don’t break announcements.
  • Framework-friendly composition: You can place custom content inside the visual button while preserving the widget behaviour underneath.

One option in this category is DOM Studio tabs, which expose headless tab primitives with Vue wrappers. That setup is useful when you want standards-based behaviour without hand-authoring every ARIA and keyboard interaction.

A pattern that survives design changes

Teams often paint themselves into a corner by binding behaviour to a very specific HTML shape. Then design asks for icons, badges, counters, or horizontally scrollable triggers, and the whole thing gets rewritten.

A better structure keeps a narrow contract:

  1. Root manages value and activation mode
  2. List manages composite keyboard behaviour
  3. Tab trigger exposes state for styling
  4. Panel renders content for the current value

That contract survives redesigns because the behaviour layer doesn’t care whether the tab trigger contains text only or an icon, label, and status pill.

Implementation note: The less ARIA you write by hand in app code, the fewer regressions you create during UI refreshes.

For styling, keep the focus ring on the actual interactive element, not an outer wrapper that may get clipped. If you’re using Tailwind CSS 4 or a visual theming layer, define focus tokens once and apply them consistently to the tab trigger primitive. That gives your design system room to evolve without rewriting keyboard behaviour each time the visual spec changes.

Handling Responsive and Scrollable Tab Layouts

Tabbed interfaces often break the moment the tab list gets wider than the viewport. On desktop, the row fits. On mobile, half the labels disappear, someone adds overflow-x-auto, and the keyboard experience degrades.

A four-step diagram illustrating the process for handling responsive and scrollable tab layouts in UI design.

Scrolling must follow focus

The first rule is simple. If arrow-key movement changes which tab is focused, the newly focused tab must scroll fully into view inside the tab strip.

Without that, a keyboard user can move focus onto an off-screen tab and lose orientation immediately. The browser won’t always fix this for you, especially inside nested overflow containers.

A practical implementation usually does this on each focus change:

  • Find the active tab element
  • Find the nearest horizontal scroll container
  • Compare the tab’s bounds with the container’s visible bounds
  • Adjust scrollLeft enough to reveal the whole trigger
  • Preserve the natural arrow-key behaviour while scrolling

Roving tabindex in dense layouts

Scrollable tabs are exactly where roving tabindex earns its keep. One tab is tabbable. The rest are reachable with arrow keys. That keeps the document-level tab order short even when the component contains many options.

Here’s the trade-off:

Approach Works well Breaks down
Every tab tabbable Small static tab sets Long rows, mobile overflow, repetitive tabbing
Roving tabindex Composite widgets, scrollable tab bars Poorly implemented focus syncing
Positive tabindex ordering Almost never Maintenance, unpredictability, keyboard confusion

For dense component libraries, roving tabindex is usually the right default for tabs. It keeps keyboard interaction local to the widget instead of polluting the whole page order.

Responsive behaviour under zoom and layout shifts

A lot of teams test responsive behaviour at narrow widths but forget browser zoom. That’s where sticky headers get taller, labels wrap, and scroll buttons overlap the focus ring.

The safer patterns are:

  • Keep scroll buttons outside the trigger hit area: Don’t let overlay controls hide focused tabs.
  • Avoid clipping focus outlines: Use padding or outline offsets so the ring stays visible.
  • Recalculate on resize and content changes: Font loading, translated labels, and dynamic badges can change widths after initial render.
  • Degrade cleanly in virtualised or AI-edited layouts: If a system can’t guarantee stable left-right movement, simplify interaction rather than pretending the full pattern still works.

UK public-sector monitoring increasingly checks keyboard access in more complex flows, including carousels, modals, and authentication journeys, and GOV.UK documents a keyboard-access issue in a carousel that couldn’t be closed with standard keyboard shortcuts (GOV.UK ID Check app accessibility statement). The lesson carries over to tabs. Once a component starts scrolling, collapsing, or rendering conditionally, you need interaction rules that still make sense when the UI gets crowded.

Testing Keyboard Interactions and Focus Visibility

A quick manual Tab test catches obvious failures. It doesn’t catch enough of them. Tabs can look fine in a local build and still fail once you add zoom, sticky UI, browser extensions, or a screen reader into the mix.

A numbered infographic checklist explaining how to test keyboard interactions and focus visibility for web accessibility.

What manual testing should include

For government services in the UK, keyboard testing is a formal quality check. NHS guidance says testing should confirm you can tab to all links, buttons, and form controls, can’t tab to non-functional elements, don’t get trapped in hidden or scripted elements, and that tabbing follows the visual order from left to right and top to bottom (NHS accessibility testing guidance).

That’s a strong baseline for product teams too. For tabs, manual checks should include:

  • Entry and exit: Tab into the widget once, move within it, then Tab out without getting stuck.
  • Reverse movement: Shift+Tab should behave logically when leaving and re-entering the component.
  • Arrow-key behaviour: Left and Right Arrow should move as your component pattern defines.
  • Selection model: Verify whether focus automatically activates panels or waits for Enter or Space.
  • Hidden content: Inactive panels must not leak focusable descendants into the keyboard path.

What automation can and can’t prove

Automation is useful, but it has a ceiling.

Automated checks are good at flagging missing roles, broken relationships, duplicate IDs, and obvious visibility issues. They’re not good at judging whether a sticky toolbar hides the focus ring at a particular zoom level, or whether a scrollable tab strip keeps the active item visible while arrowing across it.

That means the most reliable workflow is mixed:

  1. Run automated accessibility checks in CI.
  2. Do manual keyboard testing in the browser.
  3. Repeat at multiple zoom levels.
  4. Repeat with any persistent UI chrome enabled.
  5. Spot-check with a screen reader before release.

A related pattern shows up in other composite widgets too. If your team also builds menus and popovers, the same focus-discipline issues appear in an accessible dropdown menu pattern.

Before release, it helps to watch a full keyboard testing walkthrough and compare your own checks against a real interaction sequence.

If a test plan only says “Tab through the component”, it’s too shallow for modern UI.

A release checklist that catches real regressions

Use a short gate before shipping:

  • Focus is always visible: No clipping, overlap, or hidden active states.
  • Logical order holds under responsive layouts: Especially after wrapping, overflow, or conditional rendering.
  • No keyboard traps: Users can always leave the tab set and any related overlays.
  • Home and End behave consistently: If your tab pattern supports them, they should always work.
  • Panel content matches selected state: Screen reader announcements and visible content should stay aligned.

Migrating to Headless Primitives and Next Steps

Legacy tab components usually carry a lot of hidden cost. The visual layer seems simple, but the implementation accumulates one-off fixes for focus bugs, custom key handlers, route syncing, disabled states, and responsive overflow behaviour.

That’s why migration pays off even when the existing component “mostly works”. You’re not just swapping markup. You’re removing duplicated interaction logic from every product surface that uses tabs.

A practical migration path

Refactoring goes more smoothly when you separate concerns first.

Start by identifying what your current component is responsible for:

Responsibility Keep in app code Move to primitive layer
Visual styling Yes No
Active value state Usually yes Sometimes
Keyboard interactions No Yes
ARIA relationships No Yes
Focus movement rules No Yes

Once that boundary is clear, migration becomes incremental instead of disruptive.

A sensible order is:

  • Wrap existing visuals first: Keep your current classes and slot content.
  • Replace keyboard logic next: Remove bespoke arrow-key handlers and tabindex bookkeeping.
  • Move panel linkage into the primitive layer: Many legacy bugs hide.
  • Test under real layout pressure: Sticky headers, small screens, zoom, and embedded app shells.

Why this approach holds up better

Headless primitives give teams a stable behaviour layer that doesn’t depend on one framework’s component internals. That matters if your design system spans multiple apps or if AI-generated UI needs to remain inspectable after the first pass.

The operational benefit is straightforward. Fewer hand-written accessibility rules in feature code means fewer regressions during redesigns, theme work, and content changes. It also gives design and engineering a cleaner contract. Designers can iterate on the trigger appearance, spacing, and motion, while the primitive preserves the keyboard model underneath.

There’s a bundle-size angle too. DOM Studio’s individual modules average under 2 kb gzipped, which makes it realistic to standardise primitives without dragging in a heavy UI runtime. Small, tree-shakeable behaviour units are easier to adopt across a large Vue codebase than a monolithic component stack.

For teams rebuilding custom tabs yet again, that trade is usually worth taking. You keep control of markup and styling, but you stop treating keyboard behaviour as bespoke app logic.


DOM Studio gives teams headless UI primitives, Vue wrappers, and embedded component specs that make tab navigation easier to ship without re-implementing focus management every time. If you want accessible tabs that hold up in responsive, AI-editable interfaces, visit DOM Studio.