Tabs
Give related views a shared home. Tabs combines a persistent set of panels with theme-aware controls, a responsive overflow menu and a compact selector for small screens.
Tabs + TabPaneSelection
One shared refConnect the active pane to forms, buttons and the rest of your app.
State
Panels stay mountedSwitch views without discarding a draft or resetting an input.
Space
Controls adaptOverflow on narrow containers. A native selector on mobile.
Interactive playground
Style the outer container, the tab controls and the panel independently. Enable Team, select it, then disable it again: this example returns to Overview so the active pane remains available. Switch the site theme to see every treatment in both skins.
<RegorApp id="tabs-playground" src="./playground.ts"/> import {
type ComponentVariant,
type ComponentVariantMode,
defineBadgeComponents,
defineButtonComponents,
defineFlexComponents,
defineFormComponents,
defineFormSelectField,
defineGridComponents,
defineIconComponents,
definePanelComponents,
defineTabsComponents,
type FormSelectOption,
} from '@purestack/ts-components'
import type { SemanticTone } from '@purestack/ts-style'
import {
lucide_chevron_down,
tabler_code,
tabler_layout_grid,
tabler_users,
} from '@purestack/ts-svg-icons'
import {
type ComputedRef,
computed,
createApp,
defineComponent,
html,
type Ref,
ref,
} from 'regor'
export interface TabsPlayground {
selected: Ref<string>
tone: Ref<SemanticTone>
shell: Ref<ComponentVariant>
controls: Ref<ComponentVariant>
panel: Ref<ComponentVariant>
mode: Ref<ComponentVariantMode>
icons: Ref<boolean>
header: Ref<boolean>
teamDisabled: Ref<boolean>
tones: FormSelectOption[]
variants: FormSelectOption[]
modes: FormSelectOption[]
selectionOptions: ComputedRef<FormSelectOption[]>
configuration: ComputedRef<string>
refresh: () => void
reset: () => void
}
const tabsPlaygroundTemplate = html`<Flex direction="column">
<Flex justify="between" align="center" wrap="true">
<p class="text-eyebrow m-0">One component, three styling surfaces</p>
<Badge tone="accent" variant="surface">Live playground</Badge>
</Flex>
<Tabs id="tabs-playground-preview" group="tabs-playground-views" :selectedTab="selected"
:tone="tone" :variant="shell" :tabVariant="controls" :variantMode="mode" ariaLabel="Project workspace">
<template #header>
<Flex r-if="header" class="tabs__header" justify="between" align="center" wrap="true">
<div><p class="text-eyebrow m-0">PureStack Studio</p><h3 class="mt-1 mb-0">Project workspace</h3></div>
<Badge tone="success" variant="surface">Preview ready</Badge>
</Flex>
</template>
<TabPane id="tabs-lab-overview" label="Overview" :icon="icons ? 'tabler:layout-grid' : ''" :variant="panel" class="p-4">
<p class="text-eyebrow mt-0">Release overview</p><h3 class="mt-0">Everything your next release needs.</h3>
<p>Content, components and a shared theme, ready to publish together.</p>
<Grid columns="1" columnsSm="3">
<Panel variant="outline" bodyClass="p-3"><strong>12 pages</strong><p class="text-muted mb-0">Ready to publish</p></Panel>
<Panel variant="outline" bodyClass="p-3"><strong>2 themes</strong><p class="text-muted mb-0">Light and dark</p></Panel>
<Panel variant="outline" bodyClass="p-3"><strong>0 warnings</strong><p class="text-muted mb-0">Content checks</p></Panel>
</Grid>
</TabPane>
<TabPane id="tabs-lab-source" label="Build" :icon="icons ? 'tabler:code' : ''" :variant="panel" class="p-4">
<p class="text-eyebrow mt-0">Build pipeline</p><h3 class="mt-0">From typed source to a static site.</h3>
<ol class="mb-0"><li>Resolve content and shared configuration.</li><li>Render pages, components and theme styles.</li><li>Bundle browser apps and generate the search index.</li></ol>
</TabPane>
<TabPane id="tabs-lab-team" label="Team" :icon="icons ? 'tabler:users' : ''" :disabled="teamDisabled" :variant="panel" class="p-4">
<p class="text-eyebrow mt-0">Project access</p><h3 class="mt-0">A shared place to build.</h3>
<p class="mb-0">Designers review the preview. Developers own the components. Editors keep the content current.</p>
</TabPane>
</Tabs>
<div @change="refresh">
<Grid columns="1" columnsMd="3">
<FormSelectField id="tabs-tone" label="Tone" :model="tone" :options="tones"/>
<FormSelectField id="tabs-shell" label="Container variant" :model="shell" :options="variants"/>
<FormSelectField id="tabs-controls" label="Tab control variant" :model="controls" :options="variants"/>
<FormSelectField id="tabs-panel" label="Panel variant" :model="panel" :options="variants"/>
<FormSelectField id="tabs-mode" label="Container / panel mode" :model="mode" :options="modes"/>
<FormSelectField id="tabs-selection" label="Selected pane" :model="selected" :options="selectionOptions"/>
</Grid>
<Flex wrap="true" class="mt-3">
<FormCheck id="tabs-icons" label="Show icons" :checked="icons"/>
<FormCheck id="tabs-header" label="Show header slot" :checked="header"/>
<FormCheck id="tabs-disabled" label="Disable Team" :checked="teamDisabled"/>
</Flex>
</div>
<Flex justify="between" align="center" wrap="true">
<p class="text-muted m-0" role="status">Selected: <code id="tabs-selected">{{ selected }}</code></p>
<Btn variant="outline" size="sm" @click="reset">Reset playground</Btn>
</Flex>
<Panel variant="surfaceAlt" bodyClass="p-3 min-w-0">
<p class="text-eyebrow mt-0">Current settings</p>
<pre class="overflow-auto max-h-inspector m-0"><code id="tabs-configuration">{{ configuration }}</code></pre>
</Panel>
</Flex>`
const tabsPlayground = defineComponent<TabsPlayground>(tabsPlaygroundTemplate, {
context: () => {
const selected = ref('tabs-lab-overview')
const tone = ref<SemanticTone>('neutral')
const shell = ref<ComponentVariant>('surface')
const controls = ref<ComponentVariant>('underline')
const panel = ref<ComponentVariant>('none')
const mode = ref<ComponentVariantMode>('stateless')
const icons = ref(true)
const header = ref(true)
const teamDisabled = ref(true)
const refresh = () => {
if (teamDisabled() && selected() === 'tabs-lab-team')
selected('tabs-lab-overview')
requestAnimationFrame(() =>
window.tsSsgTabs?.refresh('#tabs-playground-preview'),
)
}
return {
selected,
tone,
shell,
controls,
panel,
mode,
icons,
header,
teamDisabled,
refresh,
tones: [
'neutral',
'accent',
'secondary',
'info',
'success',
'warning',
'danger',
'feature',
'custom',
'ghost',
].map((value) => ({ label: value, value })),
variants: [
'none',
'solid',
'surface',
'surfaceAlt',
'spotlight',
'glass',
'flat',
'flatAlt',
'flatSolid',
'outlineFill',
'outline',
'subtle',
'subtleBtn',
'link',
'sheen',
'underline',
'rail',
'bracket',
].map((value) => ({ label: value, value })),
modes: ['stateless', 'stateful'].map((value) => ({
label: value,
value,
})),
selectionOptions: computed<FormSelectOption[]>(() => [
{ label: 'Overview', value: 'tabs-lab-overview' },
{ label: 'Build', value: 'tabs-lab-source' },
{ label: 'Team', value: 'tabs-lab-team', disabled: teamDisabled() },
]),
configuration: computed(() =>
JSON.stringify(
{
Tabs: {
group: 'tabs-playground-views',
selectedTab: selected(),
tone: tone(),
variant: shell(),
tabVariant: controls(),
variantMode: mode(),
ariaLabel: 'Project workspace',
},
TabPane: { variant: panel(), disabled: teamDisabled() },
showIcons: icons(),
showHeader: header(),
},
null,
2,
),
),
reset: () => {
selected('tabs-lab-overview')
tone('neutral')
shell('surface')
controls('underline')
panel('none')
mode('stateless')
icons(true)
header(true)
teamDisabled(true)
refresh()
},
}
},
})
const icons: Record<string, string> = {
'lucide:chevron-down': lucide_chevron_down,
'tabler:layout-grid': tabler_layout_grid,
'tabler:code': tabler_code,
'tabler:users': tabler_users,
}
createApp(
{
components: {
TabsPlayground: tabsPlayground,
...defineTabsComponents(),
...defineFlexComponents(),
...defineGridComponents(),
...definePanelComponents(),
...defineBadgeComponents(),
...defineButtonComponents(),
...defineFormComponents(),
...defineFormSelectField(),
...defineIconComponents((name) => icons[name] ?? ''),
},
},
{ selector: 'app#tabs-playground', template: html`<TabsPlayground/>` },
)
window.tsSsgTabs?.refresh('#tabs-playground-preview') The selected-pane control and the tab row share the same ref. The settings inspector shows the values used by this example; the TypeScript source tab contains the complete implementation, registrations and scoped runtime refresh.
Start with two panes
Use Tabs around TabPane children. Set selectedTab to a pane's id, give the group a descriptive ariaLabel, and use document-unique pane ids. The standard SSG registration includes both components and automatically adds the tab runtime for MDX.
<Tabs group="project-views" selectedTab="project-overview" ariaLabel="Project views">
<TabPane id="project-overview" label="Overview" class="p-3">
<p class="m-0">Your project at a glance.</p>
</TabPane>
<TabPane id="project-settings" label="Settings" class="p-3">
<p class="m-0">Configure your project here.</p>
</TabPane>
</Tabs> The default container uses surface, controls use underline, and panels use none. class="p-3" on a TabPane pads its content body. Supply your own spacing for the content you place inside it.
A release workspace with persistent state
Edit the release name and channel, review the draft, then save it locally. The buttons move between panes through the shared selection ref. Return to Details and the draft is still there. Clear the name to see the review and save actions become unavailable.
<RegorApp id="release-workspace" src="./release-workspace.ts"/> import {
defineBadgeComponents,
defineButtonComponents,
defineFlexComponents,
defineFormInputField,
defineFormSelectField,
defineIconComponents,
definePanelComponents,
defineTabsComponents,
type FormSelectOption,
} from '@purestack/ts-components'
import { lucide_chevron_down } from '@purestack/ts-svg-icons'
import {
type ComputedRef,
computed,
createApp,
defineComponent,
html,
type Ref,
ref,
type SRef,
sref,
} from 'regor'
export interface ReleaseWorkspace {
selected: Ref<string>
name: Ref<string>
channel: Ref<string>
saved: SRef<string[]>
canSave: ComputedRef<boolean>
channels: FormSelectOption[]
edit: () => void
review: () => void
save: () => void
}
const releaseWorkspaceTemplate = html`<Flex direction="column">
<Tabs id="release-workspace-tabs" group="release-workspace" :selectedTab="selected" tone="neutral" variant="surface" ariaLabel="Release workspace">
<template #header><Flex class="tabs__header" justify="between" align="center" wrap="true">
<div><p class="text-eyebrow m-0">Local release draft</p><h3 class="mt-1 mb-0">Prepare your next release</h3></div>
<Badge tone="accent" variant="surface">{{ saved.length }} saved</Badge>
</Flex></template>
<TabPane id="release-details" label="Details" class="p-3">
<Flex direction="column">
<FormInputField id="release-name" label="Release name" :model="name" placeholder="Give the release a name"/>
<FormSelectField id="release-channel" label="Release channel" :model="channel" :options="channels"/>
<p class="text-muted m-0">Your draft stays mounted when you switch panes. Changes are stored in this demo until the page reloads.</p>
<Flex><Btn @click="review" :disabled="!canSave">Review draft</Btn></Flex>
</Flex>
</TabPane>
<TabPane id="release-review" label="Review" class="p-3">
<Panel variant="surfaceAlt" bodyClass="p-4">
<p class="text-eyebrow mt-0">Ready for review</p><h3 id="release-summary" class="mt-0">{{ name.trim() || 'Untitled release' }}</h3>
<p>Channel: <strong>{{ channel }}</strong></p>
<Flex wrap="true"><Btn @click="save" :disabled="!canSave">Save draft locally</Btn><Btn variant="outline" @click="edit">Back to details</Btn></Flex>
</Panel>
</TabPane>
<TabPane id="release-history" label="Saved drafts" class="p-3">
<p class="text-muted" r-if="saved.length === 0">No saved drafts yet. Name a release, review it, then save it here.</p>
<ul r-else id="release-history-list"><li r-for="entry in saved">{{ entry }}</li></ul>
<Btn variant="outline" @click="edit">Continue editing</Btn>
</TabPane>
</Tabs>
<p class="text-muted m-0" role="status">Shared selection: <code id="release-selected">{{ selected }}</code></p>
</Flex>`
const releaseWorkspace = defineComponent<ReleaseWorkspace>(
releaseWorkspaceTemplate,
{
context: () => {
const selected = ref('release-details')
const name = ref('Studio component library')
const channel = ref('Preview')
const saved = sref<string[]>([])
const canSave = computed(() => name().trim().length > 0)
const select = (id: string) => {
selected(id)
requestAnimationFrame(() =>
window.tsSsgTabs?.refresh('#release-workspace-tabs'),
)
}
return {
selected,
name,
channel,
saved,
canSave,
channels: ['Preview', 'Stable', 'Internal'].map((value) => ({
label: value,
value,
})),
edit: () => select('release-details'),
review: () => {
if (canSave()) select('release-review')
},
save: () => {
if (!canSave()) return
saved([`${name().trim()} · ${channel()}`, ...saved()])
select('release-history')
},
}
},
},
)
createApp(
{
components: {
ReleaseWorkspace: releaseWorkspace,
...defineTabsComponents(),
...defineFlexComponents(),
...defineBadgeComponents(),
...defineButtonComponents(),
...defineFormInputField(),
...defineFormSelectField(),
...definePanelComponents(),
...defineIconComponents((name) =>
name === 'lucide:chevron-down' ? lucide_chevron_down : '',
),
},
},
{ selector: 'app#release-workspace', template: html`<ReleaseWorkspace/>` },
)
window.tsSsgTabs?.refresh('#release-workspace-tabs') Saving in this example updates an in-memory list; nothing is published or sent to a server. A page reload starts a fresh workspace. Hiding a panel does not unmount its inputs or pause its application logic.
Selection and the browser runtime
In a Regor app, pass a ref<string> with :selectedTab="selected". Clicking a generated tab button or choosing a compact option updates the radio input and the shared ref.
When your code changes selection, labels, icons, disabled states or presentation props, refresh the affected Tabs after its DOM bindings have updated. The complete examples use requestAnimationFrame and a root selector:
const select = (id: string) => {
selected(id)
requestAnimationFrame(() => window.tsSsgTabs?.refresh('#release-workspace-tabs'))
} refresh() rebuilds the generated buttons and selector; it keeps the existing panel content. Scope it to the root you changed. Also refresh after inserting or removing panes. A ref assignment updates the underlying radio binding, but the current runtime does not watch every component prop automatically.
Choose an enabled pane explicitly when disabling or removing the selected pane. This keeps the model and visible view aligned. The playground demonstrates that policy in its refresh action.
Overflow you can test
Change the example's width without resizing the page. On desktop, the More tabs button collects controls that no longer fit. Choose a distant section: the visible portion of the row follows your selection. Billing is disabled in the row, overflow menu and compact selector.
<RegorApp id="tabs-overflow" src="./overflow.ts"/> import {
defineBadgeComponents,
defineFlexComponents,
defineFormSelectField,
defineIconComponents,
defineTabsComponents,
type FormSelectOption,
} from '@purestack/ts-components'
import { lucide_chevron_down } from '@purestack/ts-svg-icons'
import { createApp, defineComponent, html, type Ref, ref } from 'regor'
export interface TabsOverflow {
width: Ref<string>
selected: Ref<string>
widths: FormSelectOption[]
topics: {
id: string
label: string
eyebrow: string
title: string
description: string
disabled?: boolean
}[]
}
const tabsOverflowTemplate = html`<Flex direction="column">
<FormSelectField id="tabs-overflow-width" label="Available container width" :model="width" :options="widths"/>
<div class="max-w-full" :style="{ width }">
<Tabs id="tabs-overflow-preview" group="tabs-overflow-topics" :selectedTab="selected" tone="neutral" variant="surface" tabVariant="outline" ariaLabel="Project sections">
<TabPane r-for="topic in topics" :id="topic.id" :label="topic.label" :disabled="topic.disabled" class="p-3">
<p class="text-eyebrow mt-0">{{ topic.eyebrow }}</p><h3 class="mt-0">{{ topic.title }}</h3>
<p class="mb-0">{{ topic.description }}</p>
</TabPane>
</Tabs>
</div>
<Flex wrap="true" align="center"><Badge variant="surface">{{ width }} container</Badge><span class="text-muted">Selected: <code id="overflow-selected">{{ selected }}</code></span></Flex>
<p class="text-muted m-0">On a desktop viewport, narrow the container and use More tabs. At 640px viewport width or below, the native selector replaces the row. Billing remains disabled in either control.</p>
</Flex>`
const tabsOverflow = defineComponent<TabsOverflow>(tabsOverflowTemplate, {
context: () => ({
width: ref('360px'),
selected: ref('overflow-configuration'),
widths: ['280px', '360px', '520px', '100%'].map((value) => ({
label: value === '100%' ? 'Full available width' : value,
value,
})),
topics: [
{
id: 'overflow-configuration',
label: 'Configuration',
eyebrow: 'Project foundation',
title: 'One shared configuration.',
description:
'Define the site identity, content roots and output paths together.',
},
{
id: 'overflow-content',
label: 'Content library',
eyebrow: 'Editorial workflow',
title: 'Pages with a clear home.',
description:
'Organize guides into folders and keep each example beside its page.',
},
{
id: 'overflow-components',
label: 'Components',
eyebrow: 'Reusable interfaces',
title: 'Compose the page.',
description:
'Build panels, forms and navigation from typed components.',
},
{
id: 'overflow-themes',
label: 'Theme settings',
eyebrow: 'Visual system',
title: 'Two themes. One identity.',
description:
'Semantic tones keep controls and surfaces consistent in light and dark.',
},
{
id: 'overflow-build',
label: 'Build history',
eyebrow: 'Publishing',
title: 'A repeatable release.',
description:
'Render content, bundle the browser apps and index the finished pages.',
},
{
id: 'overflow-members',
label: 'Team members',
eyebrow: 'Collaboration',
title: 'Give every contributor a place.',
description: 'Keep ownership and project resources close to the work.',
},
{
id: 'overflow-billing',
label: 'Billing',
eyebrow: 'Unavailable',
title: 'Billing',
description: 'This pane is disabled in the demonstration.',
disabled: true,
},
],
}),
})
createApp(
{
components: {
TabsOverflow: tabsOverflow,
...defineTabsComponents(),
...defineFlexComponents(),
...defineBadgeComponents(),
...defineFormSelectField(),
...defineIconComponents((name) =>
name === 'lucide:chevron-down' ? lucide_chevron_down : '',
),
},
},
{ selector: 'app#tabs-overflow', template: html`<TabsOverflow/>` },
)
window.tsSsgTabs?.refresh('#tabs-overflow-preview') | Available space | Control behavior | How to try it |
|---|---|---|
| Wide viewport, enough container space | Horizontal buttons | Choose Full available width |
| Wide viewport, narrow container | Buttons plus More tabs | Choose 280px or 360px |
Viewport at or below the framework's sm breakpoint (640px) | Native select | Open the page on a phone or resize the browser |
Container width determines overflow. Viewport width determines compact mode. A 280px component inside a desktop page still uses the overflow menu. Selection and panel state survive either transition.
Header slots, overrides and nested groups
The header slot holds a title and status above the controls. Environments overrides its inherited tone, control style and panel surface, then contains a second Tabs instance with its own group and selection.
Select Production, switch the outer group to Source, then return to Environments. The inner selection remains Production, while the outer group changes independently.
<RegorApp id="tabs-composition" src="./composition.ts"/> import {
defineBadgeComponents,
defineFlexComponents,
defineIconComponents,
defineTabsComponents,
} from '@purestack/ts-components'
import { tabler_code, tabler_rocket } from '@purestack/ts-svg-icons'
import { createApp, defineComponent, html, type Ref, ref } from 'regor'
export interface TabsComposition {
resource: Ref<string>
environment: Ref<string>
}
const tabsCompositionTemplate = html`<Flex direction="column">
<Tabs id="resource-tabs" group="resource-tabs" :selectedTab="resource" tone="neutral" variant="outline" tabVariant="underline" ariaLabel="Project resources">
<template #header><Flex class="tabs__header" justify="between" align="center" wrap="true">
<h3 class="m-0">Project resources</h3><Badge tone="accent" variant="surface">Header slot</Badge>
</Flex></template>
<TabPane id="resource-code" label="Source" icon="tabler:code" class="p-3">
<h3 class="mt-0">A home for the implementation.</h3><p class="mb-0">Components, theme styles and content live together in the project.</p>
</TabPane>
<TabPane id="resource-deploy" label="Environments" icon="tabler:rocket" tone="info" variant="surfaceAlt" tabVariant="solid" class="p-3">
<p class="mt-0">This pane overrides its tone, surface and control treatment. Its inner tabs have their own selection.</p>
<Tabs id="environment-tabs" group="environment-tabs" :selectedTab="environment" tone="neutral" variant="surface" tabVariant="outline" ariaLabel="Deployment environment">
<TabPane id="environment-preview" label="Preview" class="p-3"><Badge tone="warning" variant="surface">Review in progress</Badge><p class="mb-0">Share the preview with your team before publishing.</p></TabPane>
<TabPane id="environment-production" label="Production" class="p-3"><Badge tone="success" variant="surface">Published</Badge><p class="mb-0">The approved release is available to everyone.</p></TabPane>
</Tabs>
</TabPane>
</Tabs>
<p class="text-muted m-0" role="status">Resource: <code id="resource-selected">{{ resource }}</code> · Environment: <code id="environment-selected">{{ environment }}</code></p>
</Flex>`
const tabsComposition = defineComponent<TabsComposition>(
tabsCompositionTemplate,
{
context: () => ({
resource: ref('resource-deploy'),
environment: ref('environment-preview'),
}),
},
)
const icons: Record<string, string> = {
'tabler:code': tabler_code,
'tabler:rocket': tabler_rocket,
}
createApp(
{
components: {
TabsComposition: tabsComposition,
...defineTabsComponents(),
...defineFlexComponents(),
...defineBadgeComponents(),
...defineIconComponents((name) => icons[name] ?? ''),
},
},
{ selector: 'app#tabs-composition', template: html`<TabsComposition/>` },
)
window.tsSsgTabs?.refresh('#resource-tabs, #environment-tabs') Three styling surfaces
| Surface | Setting | Default | Scope |
|---|---|---|---|
| Outer container | Tabs.variant | surface | Shell background, border and text treatment |
| Tab controls | Tabs.tabVariant | underline | Default treatment for the row and overflow controls |
| Individual control | TabPane.tabVariant | Parent value | Overrides one pane's control treatment |
| Panel content | TabPane.variant | none | Background and border around that pane's content |
Tabs.tone supplies the semantic tone for the group. A pane's tone overrides it for that panel and its control. Without an explicit tone, colors follow the surrounding theme context. variantMode applies to the container and panel surfaces; tab controls retain their interactive hover and active treatment.
All variant values are available in the playground: none, solid, surface, surfaceAlt, spotlight, glass, flat, flatAlt, flatSolid, outlineFill, outline, subtle, subtleBtn, link, sheen, underline, rail and bracket.
API reference
Structure · selection · presentation
Import Tabs and TabPane for their component types. RefOrValue accepts a literal or a ref; use colon-prefixed bindings for reactive props. group and id identify the structure and should remain stable after mounting.
Tabs properties
group
string - Default
generated
Radio group name shared by the child panes. Use a unique group for each independent or nested instance. Reusing a group causes native radio selection to cross between instances.
selectedTab
RefOrValue<string> - Default
not set
The selected pane's id, not its label or index. Pass a ref to share selection with your application. A literal establishes an initial selection backed by an internal ref.
Set an enabled pane explicitly for predictable initial state. Without a valid checked pane, the enhanced runtime chooses the first enabled pane; the CSS-only fallback displays the first pane.
ariaLabel
RefOrValue<string> - Default
Tabs
Accessible group name for the tab list and compact selector. Describe the views, such as Release workspace or Deployment environment.
tone
RefOrValue<SemanticTone> - Default
inherited
neutral, accent, secondary, info, success, warning, danger, feature, custom or ghost. Inherited by panes unless they supply their own tone. Colors come from the active skin.
variant · tabVariant
RefOrValue<ComponentVariant> - Default
surface · underline
variant styles the outer container. tabVariant supplies the default control treatment. Neither sets the individual panel's surface; use TabPane.variant for that.
variantMode
RefOrValue<ComponentVariantMode> - Default
stateless
stateless keeps container and panel surfaces steady; stateful includes their hover and active variant styles. Inherited by panes. Tab controls always receive interactive variant classes.
TabPane properties
id · group
string - Default
generated · parent group
id identifies the radio input and becomes the base for the label and panel ids. Keep it unique across the whole document, including nested tabs and sample source tabs.
group inherits from the nearest Tabs context. In ordinary compositions, set the group on Tabs and leave it off the panes.
label · icon
RefOrValue<string> - Default
pane id · no icon
Visible title and optional registered SVG icon. Generated buttons and overflow entries include the icon; the native compact selector uses text labels. Register the icons your example uses.
disabled
RefOrValue<boolean> - Default
false
Disables this pane's radio, enhanced button, overflow item and compact option. The content remains mounted. This is a navigation state, not an access-control boundary.
When changing it reactively, choose another enabled selection if needed, then refresh the affected root so its generated controls match.
tone · variant · tabVariant · variantMode
RefOrValue<SemanticTone | ComponentVariant | ComponentVariantMode> - Default
inherited; variant none
tone, tabVariant and variantMode inherit from Tabs. Explicit values override their corresponding parent setting. variant is independent and defaults to none for the panel.
Use the standard class attribute for the panel body, for example class="p-4". There is no dedicated panel-padding prop.
Slots and registration
Tabs default slot: the TabPane children.
Tabs header slot: content above the controls. Use <template #header>; give its wrapper class="tabs__header" so the enhanced runtime preserves its position above the generated row.
TabPane default slot: the panel content. Inactive panels remain in the DOM and are hidden by the tab styles.
Register defineTabsComponents() and defineIconComponents() in a standalone Regor app. Register any components used inside the panels too. The standard SSG registration already includes the tab components and runtime.
Runtime API
window.tsSsgTabs.refresh(target?)
(selector?: string) => void - Default
all .tabs roots
Builds or rebuilds the responsive controls for matching roots. Call it after mounting a Regor example, changing generated-control presentation, or adding and removing panes. Use a root selector such as #release-workspace-tabs to limit the work.
The runtime responds to container resizing automatically. refresh does not fetch content, mount applications or persist selection across page loads.
Keyboard and accessibility
- Provide meaningful labels and a descriptive
ariaLabelfor each group. - Generated controls are native buttons: use Tab to focus and Enter or Space to activate. Compact mode uses the native select's keyboard behavior.
- With JavaScript disabled, the radio inputs and visible labels provide the static fallback. Always choose a valid initial pane for that fallback.
- The current enhanced row does not implement an arrow-key/roving-focus tab pattern. Do not assume Left/Right, Home or End move between generated tab buttons.
- Switching a pane does not move focus into its content automatically. Keep the reading and focus order sensible inside each panel.
- Hidden panels are still present in the document. Keep ids unique and avoid placing sensitive content in a disabled pane as an authorization mechanism.
Common integration mistakes
| Symptom | Check |
|---|---|
| Selecting one group clears another | Give each Tabs instance a different group |
| The wrong panel opens or labels target another example | Make every TabPane id document-unique |
| The model changes but enhanced controls look stale | Refresh the affected root after the DOM update |
| A narrow desktop demo does not use a select | Compact mode follows viewport width, not container width |
| A header appears below the generated controls | Add tabs__header to the header-slot wrapper |
| A selected pane becomes disabled | Move selection to an enabled pane before refreshing |
Related components
- Panel: a persistent content surface without selection.
- ExpandablePanel: reveal sections without replacing the visible view.
- BtnGroup: group actions when there are no content panels to switch.