Skip to content

Tabs ​

For top-level page tabs — the strip just below the page header that switches between sibling views of the same screen — use the compound tabs from @scaler-tech/aurora/tabs-compound: Tabs, TabsList, TabsTrigger, TabsContent. You place the strip, the panels, and anything in between explicitly, so you control the layout instead of fighting an auto-collecting wrapper.

Overview content.
vue
<template>
  <Tabs v-model="activeTab" default-value="overview">
    <TabsList>
      <TabsTrigger value="overview">Overview</TabsTrigger>
      <TabsTrigger value="metrics">Metrics</TabsTrigger>
    </TabsList>
    <TabsContent value="overview">…</TabsContent>
    <TabsContent value="metrics">…</TabsContent>
  </Tabs>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@scaler-tech/aurora/tabs-compound'

// Starts undefined so the URL / default-value can seed it — see Gotchas.
const activeTab = ref<string>()
</script>

Tabs is a flex flex-col gap-4 column: TabsList holds the TabsTriggers, and the TabsContent panels stack below in source order. Drive the selection with v-model. Selection styling is data-driven (reka sets data-state="active"), so a TabsTrigger needs no selected prop — compose icons, counts or indicators inside it yourself. To switch tabs programmatically, set the model (activeTab.value = 'metrics') — there's no imperative .select().

Variants ​

VariantUse
segment (default)Segmented control — triggers in a grey track, the active one a floating white chip that slides between tabs. The page-level default.
underlineMinimal underline tabs that hug their content — inside a card/panel/modal, or to change how a dataset is represented (chart vs table, YoY vs MoM).

Pinned strip, scrolling panel ​

Inside a fixed-shell page (page structure) you want the tab strip and any shared filters to stay put while only the active panel scrolls. Two classes do it:

  • Tabs class="flex-1 min-h-0" — lets the tab block fill the leftover height of PageBody.
  • TabsContent fill — makes the panel the scroller (flex-1 min-h-0 overflow-y-auto), so the strip and the filter row above it stay fixed.

Asset list

KPI:Energy use intensity
Overview — row 1. Strip + filters stay; only this panel scrolls.
Overview — row 2. Strip + filters stay; only this panel scrolls.
Overview — row 3. Strip + filters stay; only this panel scrolls.
Overview — row 4. Strip + filters stay; only this panel scrolls.
Overview — row 5. Strip + filters stay; only this panel scrolls.
Overview — row 6. Strip + filters stay; only this panel scrolls.
Overview — row 7. Strip + filters stay; only this panel scrolls.
Overview — row 8. Strip + filters stay; only this panel scrolls.
Overview — row 9. Strip + filters stay; only this panel scrolls.
Overview — row 10. Strip + filters stay; only this panel scrolls.
Overview — row 11. Strip + filters stay; only this panel scrolls.
Overview — row 12. Strip + filters stay; only this panel scrolls.
Overview — row 13. Strip + filters stay; only this panel scrolls.
Overview — row 14. Strip + filters stay; only this panel scrolls.
Overview — row 15. Strip + filters stay; only this panel scrolls.
Overview — row 16. Strip + filters stay; only this panel scrolls.
Overview — row 17. Strip + filters stay; only this panel scrolls.
Overview — row 18. Strip + filters stay; only this panel scrolls.
Overview — row 19. Strip + filters stay; only this panel scrolls.
Overview — row 20. Strip + filters stay; only this panel scrolls.
Overview — row 21. Strip + filters stay; only this panel scrolls.
Overview — row 22. Strip + filters stay; only this panel scrolls.
Overview — row 23. Strip + filters stay; only this panel scrolls.
Overview — row 24. Strip + filters stay; only this panel scrolls.
vue
<PageBody>
  <Tabs v-model="activeTab" class="flex-1 min-h-0">
    <TabsList>
      <TabsTrigger v-for="tab in tabs" :key="tab.name" :value="tab.name">{{ tab.title }}</TabsTrigger>
    </TabsList>
    <!-- shared filter row, between strip and panels -->
    <div class="flex flex-wrap items-center gap-2"> … filters … </div>
    <TabsContent v-for="tab in tabs" :key="tab.name" :value="tab.name" fill class="flex flex-col gap-2">
      … rows …
    </TabsContent>
  </Tabs>
</PageBody>

Drop fill and the panel grows with its content instead — then PageBody does the scrolling and the strip scrolls away with the page. Use fill whenever the strip should stay visible. Full pattern: PageContentExample.vue.

Where filters live ​

  • A shared filter row that applies to every tab goes between TabsList and TabsContent — rendered once, pinned with the strip.
  • A per-tab toolbar that only makes sense for one view lives inside that tab's TabsContent, at the top of the panel. Because only the active panel mounts, it appears and disappears with the tab.
  • On a flush panel (full-width table), that per-tab row is a PageToolbar pinned above the panel's PageBody bleed — the table scrolls under it. See the flush-page shape in page structure.

URL persistence ​

The active tab mirrors to a URL query param so deep links and refreshes land on the right tab — on by default.

vue
<!-- /assets?view=asset-groups deep-links straight to that tab -->
<Tabs v-model="activeTab" param-key="view" default-value="overview">
  …
</Tabs>

<script setup lang="ts">
const activeTab = ref<string>() // undefined → URL or default-value seeds it
</script>

On mount, if the model is still undefined, Tabs seeds the selection from the URL param (decoded) or, failing that, from default-value, then keeps the model and URL in sync both ways (including back/forward). Give each tab group on a page a distinct param-key.

Two opt-outs, and the difference matters:

  • disable-router-update skips the write only — the param is still read on mount and on back/forward. Use it when the page drives tab navigation itself (it seeds v-model and pushes its own routes).
  • disable-url-sync takes the group out of the URL in both directions. Use it for tabs inside an overlay (drawer, dialog, modal): the overlay renders over a page that has its own ?tab=, and the param is taken at its word — a value naming none of the overlay's triggers would leave the strip with nothing selected.

Tabs — Props ​

PropTypeDefaultDescription
v-modelstring | undefinedundefinedThe active tab value. Start it undefined so the URL / default-value can seed it.
variant'segment' | 'underline''segment'Visual style; published to TabsList/TabsTrigger.
defaultValuestringrequiredTab shown on cold load when neither the model nor the URL selects one. Required — omitting it is a vue-tsc error, so a page can never render a blank panel. Pass the first tab's value (deep links / the model still take precedence).
paramKeystring'tab'URL query param the active tab is mirrored to.
disableRouterUpdatebooleanfalseSkip writing to the URL (still reads the param, on mount and on back/forward).
disableUrlSyncbooleanfalseKeep this tab group out of the URL entirely — the param is neither read nor written. For tabs inside an overlay.
orientation'horizontal' | 'vertical''horizontal'reka orientation.
activationMode'automatic' | 'manual''automatic'Whether arrow-key focus also activates.

TabsContent — Props ​

PropTypeDefaultDescription
valuestringrequiredMatches the trigger value.
fillbooleanfalseFill the remaining height of a bounded parent and scroll internally, instead of growing with content.

TabsList and TabsTrigger forward their reka props; TabsTrigger takes the tab's value and renders its label/content via the default slot.

Legacy Tabs / Tab (deprecated) ​

The older auto-collecting API — import { Tabs, Tab } from '@scaler-tech/aurora/tabs' with <Tab name title> children — is deprecated. It can't pin the strip or drop shared content between the strip and the panels, and new pages should not use it. Migrate to the compound API above: replace each <Tab name="x" title="X"> with a <TabsTrigger value="x">X</TabsTrigger> in TabsList plus a matching <TabsContent value="x">, and switch default-tab → default-value.

Gotchas ​

  • The model ref must start undefined for refresh-restore to work — the critical one. Tabs only seeds the selection from the URL (or default-value) when selected.value === undefined on mount. If you write const activeTab = ref('overview'), the model is already set, the URL seed never fires, and deep-linking / refreshing to a different tab silently snaps back to 'overview'. Use ref<string>() and let default-value carry the fallback.
  • default-value is required, so "forgot the fallback" is a compile error, not a blank page. With the model starting undefined and no URL param, the selection resolves to default-value — which always exists. There is no first-tab auto-select; the required prop is what guarantees a panel renders on a cold load.
  • Only the active panel mounts. There's no forceMount, so an inactive TabsContent isn't in the DOM — matters if you hoist per-panel actions or measure panel content.
  • fill vs. grow is a deliberate choice. TabsContent fill makes the panel own the scroll (strip stays put); without fill the panel grows and the nearest scrolling ancestor (PageBody) scrolls everything, strip included.
  • [&[hidden]]:!hidden is why a flex class on the panel doesn't break it. reka hides inactive panels with the hidden attribute, but a display utility like flex from your class would override it and leave an empty panel taking height — Aurora forces hidden with !important, so class="flex flex-col gap-2" is safe.