Quasar tabs come in three parts: q-tabs draws the tab bar, q-tab-panels shows the content, and q-route-tab connects a tab to Vue Router. The tab bar and the panels don't know about each other. They share a name through one v-model, and that is the part people miss when a refresh drops them back on the first tab. This post builds a tab bar with panels, then moves the active tab into the URL, makes the tabs vertical, and keeps panel state alive. Everything is checked against Quasar 2.35.2.
| Component | Job |
|---|---|
q-tabs | The tab bar. Holds the active tab name in its v-model. |
q-tab | One tab. Its name is what lands in the model. |
q-route-tab | A tab that is also a router-link. The current route decides if it's active. |
q-tab-panels | The content area. Renders the q-tab-panel whose name matches its model. |
q-tab-panel | One panel, with a required name. |
The Quasar docs make a point of this: q-tab-panels doesn't need a q-tabs at all. You can drive it from a button group, a select or a stepper. It only reads a name.
Give q-tabs and q-tab-panels the same ref, and give each tab and panel a matching name:
<script setup lang="ts">
import { ref } from 'vue'
const tab = ref('overview')
</script>
<template>
<q-card flat bordered>
<q-tabs
v-model="tab"
align="left"
no-caps
active-color="primary"
indicator-color="primary"
class="text-grey-7"
>
<q-tab name="overview" label="Overview" />
<q-tab name="orders" label="Orders" />
<q-tab name="activity" label="Activity" />
</q-tabs>
<q-separator />
<q-tab-panels v-model="tab" animated>
<q-tab-panel name="overview">Account summary</q-tab-panel>
<q-tab-panel name="orders">Order history</q-tab-panel>
<q-tab-panel name="activity">Recent activity</q-tab-panel>
</q-tab-panels>
</q-card>
</template>A few defaults to know:
align is 'center' by default. Most app UIs want 'left'.no-caps.breakpoint (600px by default), the tabs switch to justified alignment.animated slides panels in and out. swipeable adds swipe gestures, which can fight with touch content inside the panel.Since 2.25, q-tabs renders as a tablist, each tab as a tab with aria-selected, and each q-tab-panel as a focusable tabpanel. Keyboard support follows the WAI-ARIA tabs pattern: arrow keys move focus, Home and End jump to the ends, and Enter or Space activates the focused tab. The tab bar is a single Tab stop.
What Quasar can't do is link a tab to its panel, because the two components are independent. Add the ids yourself:
<q-tab name="orders" id="orders-tab" aria-controls="orders-panel" label="Orders" />
<q-tab-panel name="orders" id="orders-panel" aria-labelledby="orders-tab">
Order history
</q-tab-panel>A ref('overview') resets on every reload, and the browser's back button ignores it. If a tab is something a user would bookmark or share, put it in the URL. There are two ways to do that with q-route-tab.
When each tab is a page of its own, give each one a child route and let <router-view> replace the panels:
// router/routes.ts
{
path: '/account',
component: () => import('pages/AccountPage.vue'),
children: [
{ path: '', redirect: '/account/overview' },
{ path: 'overview', component: () => import('pages/account/OverviewTab.vue') },
{ path: 'orders', component: () => import('pages/account/OrdersTab.vue') },
{ path: 'activity', component: () => import('pages/account/ActivityTab.vue') },
],
}<!-- AccountPage.vue -->
<template>
<q-tabs align="left" no-caps active-color="primary" indicator-color="primary">
<q-route-tab to="/account/overview" label="Overview" />
<q-route-tab to="/account/orders" label="Orders" />
<q-route-tab to="/account/activity" label="Activity" />
</q-tabs>
<q-separator />
<router-view />
</template>There's no v-model here, on purpose. The Quasar docs warn against a v-model on q-tabs when you use q-route-tab: the route is the source of truth, and changing the model doesn't change the route.
In Nuxt, the same idea uses nested pages (pages/account.vue with <NuxtPage /> and pages/account/orders.vue), and the tabs point at the same paths.
When the tabs are views of one page, a query parameter is lighter: /account?tab=orders. Point each q-route-tab at a query, then derive the panel name from the route:
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const tabs = [
{ name: 'overview', label: 'Overview' },
{ name: 'orders', label: 'Orders' },
{ name: 'activity', label: 'Activity' },
]
const route = useRoute()
const activeTab = computed(() => {
const requested = route.query.tab
return tabs.some(tab => tab.name === requested) ? String(requested) : 'overview'
})
function linkFor(name: string) {
return name === 'overview' ? { query: {} } : { query: { tab: name } }
}
</script>
<template>
<q-tabs align="left" no-caps active-color="primary" indicator-color="primary">
<q-route-tab
v-for="tab in tabs"
:key="tab.name"
:name="tab.name"
:label="tab.label"
:to="linkFor(tab.name)"
replace
/>
</q-tabs>
<q-separator />
<q-tab-panels :model-value="activeTab" animated>
<q-tab-panel name="overview">Account summary</q-tab-panel>
<q-tab-panel name="orders">Order history</q-tab-panel>
<q-tab-panel name="activity">Recent activity</q-tab-panel>
</q-tab-panels>
</template>Why it's built this way:
:model-value, not v-model. The route is the only writer. An unknown or missing ?tab= falls back to the first tab, so an old link never shows an empty panel./account stays the clean URL for the default view.exact. In 2.35.2, an exact route tab only matches when the current URL has the same number of query parameters. Any extra parameter (a ?page=2 from a table, a utm_source from a link) would leave no tab active. Without exact, Quasar picks the tab whose query is the closest match, so ?tab=orders&page=2 still lights up Orders, and a URL with no tab falls back to Overview.replace. Switching tabs replaces the history entry, so the back button leaves the page instead of stepping through every tab. Drop it if you want each tab in the history.One trade-off: { query: { tab: name } } replaces the whole query. If the page keeps other state in the query, spread it in: { query: { ...route.query, tab: name } }.
useRoute is auto-imported in Nuxt, so the import line goes away there. Everything else is the same.
Add vertical to q-tabs to stack the tabs, and put the panels beside them. The indicator moves to the side of the tab; switch-indicator puts it on the other side.
<div class="row no-wrap">
<q-tabs v-model="tab" vertical no-caps active-color="primary" class="text-grey-7" style="min-width: 160px">
<q-tab name="profile" icon="person" label="Profile" />
<q-tab name="security" icon="lock" label="Security" />
<q-tab name="billing" icon="credit_card" label="Billing" />
</q-tabs>
<q-separator vertical />
<q-tab-panels v-model="tab" animated vertical transition-prev="jump-up" transition-next="jump-up" class="col">
<q-tab-panel name="profile">Profile settings</q-tab-panel>
<q-tab-panel name="security">Security settings</q-tab-panel>
<q-tab-panel name="billing">Billing settings</q-tab-panel>
</q-tab-panels>
</div>vertical on q-tab-panels only changes the axis of the transitions and swipes. With vertical tabs, the arrow keys become Up and Down. On a phone, vertical tabs take too much width; switching to a horizontal bar below a breakpoint (:vertical="$q.screen.gt.sm") is a common fix.
By default, q-tab-panels renders only the active panel. Switch tabs and the old panel is unmounted, so a half-filled form or a scrolled list is gone when the user comes back. keep-alive wraps the panels in Vue's <KeepAlive>:
<q-tab-panels v-model="tab" keep-alive keep-alive-include="orders,activity">
<q-tab-panel name="overview">...</q-tab-panel>
<q-tab-panel name="orders">...</q-tab-panel>
<q-tab-panel name="activity">...</q-tab-panel>
</q-tab-panels>keep-alive-include, keep-alive-exclude and keep-alive-max map to the <KeepAlive> props of the same name. In 2.35.2, when you pass include or exclude, Quasar names each panel's wrapper after the panel's name, so the list holds panel names. Here Overview remounts on every visit (fresh data), and the other two keep their state. A kept-alive panel also keeps its watchers and timers running, so use onActivated and onDeactivated to pause polling.
| Prop | Effect |
|---|---|
active-color, indicator-color | Text color of the active tab and color of its underline. indicator-color="transparent" hides the underline. |
active-bg-color | Background of the active tab, for pill-style tabs. |
narrow-indicator | Underline only as wide as the label. |
inline-label | Icon and label side by side instead of stacked. |
dense | Less height. |
outside-arrows, mobile-arrows | Scroll arrows when the tabs overflow; reserve space for them, or show them on touch screens too. |
shrink | Needed when the tabs sit inside a q-toolbar. |
A tab can also carry a notification: alert adds a dot (pass a color name to change it), and alert-icon shows an icon instead.
Why does my tab reset to the first one on reload? Its name lives in a ref. Use q-route-tab with a route or a query parameter, as above.
Can I use v-model with q-route-tab? You can, but the route decides which tab is active, and setting the model doesn't navigate. Drive the panels from the route instead.
How do I hide the underline? Set indicator-color="transparent" and style the active tab with active-bg-color or active-class.
My tabs inside a toolbar overflow. Why? q-tabs grows to fill the space by default. Add shrink.
The Tabs Block has 7 tab bars built on q-tabs and q-tab: underline tabs, tabs with icons, pill tabs with an active background, a dense version, a colored bar, and full-width justified tabs. Two of them are free. Copy one, then add the panels or routed tabs from this post.