BuTabs
Bulma’s .tabs with selection and panels — the JavaScript and the tabpanels the CSS framework never documented.
Reference: Tabs on bulma.io↗︎ — classes and visual variants. This page is the JavaScript.
Usage
BuTabs is the selection context and renders no element of its own. Modifiers live on BuTabList. BuTab siblings go inside the list; BuTabPanel siblings of the list hold the content. v-model is the selected tab’s value; leave it empty and the first tab is selected.
Anatomy
<script setup lang="ts">
import { BuTab, BuTabList, BuTabPanel, BuTabs } from '@paper/bulma'
</script>
<template>
<BuTabs>
<BuTabList>
<BuTab />
</BuTabList>
<BuTabPanel />
</BuTabs>
</template>Composed on v0
BuTabs maps onto v0’s Tabs compound: Tabs.Root, Tabs.List, Tabs.Item and Tabs.Panel.
BuTabs is Tabs.Root with no element of its own. Bulma’s .tabs block is the list, and the panels are siblings of that block, so the selection context has to wrap both. Every .tabs modifier — centered, boxed, toggle, size — therefore lives on BuTabList, which is the div.tabs plus the ul (Tabs.List as="ul"). Putting those classes on the root would paint a wrapper Bulma does not document.
BuTab is Tabs.Item with as set to null, not the renderless prop. as feeds the item’s button polyfill, so renderless alone would leak type="button" and disabled onto the anchor. The item renders li.is-active > a itself, which is what the fixture demands: is-active on the li, no href on the a.
That renderless item registers no element. v0’s focusSelectedTab / focusAdjacent then become no-ops — Arrow keys still move selection, panels and aria-selected, but DOM focus stays on the previously selected anchor. That is a documented APG deviation, not an oversight; the v0-core follow-up is to let TabsItem accept an element via registration so a renderless consumer can supply the focus target.
BuTabPanel is Tabs.Panel as-is. The hidden attribute is fine here: Bulma defines no panel CSS, so there is no class for the wrapper to fight.
The markup you know
The Bulma tab is the markup published on bulma.io↗︎, captured verbatim in the conformance fixture. The Vue tab is the component that renders it. Upstream documents no tabpanels — those are component territory. The conformance suite diffs the list.
<div class="tabs">
<ul>
<li class="is-active"><a>Pictures</a></li>
<li><a>Music</a></li>
<li><a>Videos</a></li>
<li><a>Documents</a></li>
</ul>
</div><template>
<BuTabs>
<BuTabList>
<BuTab value="pictures">Pictures</BuTab>
<BuTab value="music">Music</BuTab>
<BuTab value="videos">Videos</BuTab>
<BuTab value="documents">Documents</BuTab>
</BuTabList>
</BuTabs>
</template>You write no is-active. The first tab is selected when v-model is empty; after that, the model owns the class.
Examples
Props
BuTabs is the selection context. Everything visible is a part.
| Prop | Type | Default | Description |
|---|---|---|---|
v-model | T | — | Selected tab value. Empty selects the first tab |
| Part | Renders | Props |
|---|---|---|
BuTabList | div.tabs + ul | centered, boxed, toggle, size ('small' | 'normal' | 'medium' | 'large') |
BuTab | li > a | value, disabled |
BuTabPanel | div[role=tabpanel] | value (required) |
BuTab and BuTabPanel both expose isSelected as a slot prop.
Accessibility
BuTabList’s ul is role="tablist". Each BuTab puts role="presentation" on the li so the list’s required children are the role="tab" anchors, not the list items — without that, axe flags aria-required-children / aria-required-parent. Selected state is aria-selected on the tab and hidden on the matching role="tabpanel", which is labelled through aria-labelledby.
| Key | Behavior |
|---|---|
| Click | Selects that tab |
| Arrow Left / Right | Moves selection, not focus |
| Home / End | Selects first / last |
Arrow keys update selection, panels and aria-selected, but the focus ring and the screen-reader cursor stay on the tab that was focused. BuTab is renderless, so v0 has no element to move focus to. This is an APG tabs deviation the axe sweep cannot catch. Click still focuses the tab you clicked; keyboard users who start on a tab and arrow away will hear the old tab while a new panel is showing.