Use it for
Use BbBaseButton when your design is not a button variant: a card overlay,
list row, inline link or custom navigation item. Omit a destination for an
action; pass href or to for navigation.
Use something else when
BbButton, if you want something that looks like a button, including a quiet one: that isvariant="ghost"
Pass Through
Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.
BbButton and other components are built on this.
No chrome, your class
No styles included: padding, background, border and colour come from your own
classes. The one thing to add is a visible focus style: bb-base-button ships
the box-shadow transition, not the ring.
<template>
<div class="flex flex-wrap items-center gap-4 text-sm">
<!-- Untouched: the reset, the focus scaffold, and nothing else. -->
<BbBaseButton class="focusable" @click="saved = 'compact'">
Save view
</BbBaseButton>
<!-- The same component, dressed entirely by these utility classes. -->
<BbBaseButton
class="focusable rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] px-3 py-1.5 font-medium hover:bg-[color:var(--bb-hover)]"
@click="saved = 'branded'"
>
Save view
</BbBaseButton>
<p class="text-[color:var(--bb-text-muted)]" role="status">
{{ saved ? `Saved from the ${saved} button.` : '' }}
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbBaseButton } from 'bitboss-ui';
const saved = ref('');
</script>
<style scoped>
/*
* The primitive ships the ring *scaffold* — the transition and the box-shadow
* property — but never a visible focus style. Writing one is not optional.
*/
.focusable:focus-visible {
box-shadow: 0 0 0 var(--bb-ring-size) var(--bb-ring);
outline: none;
}
</style>
The label is the default slot. text is a string-only fallback for loops that
spread a config object.
What it renders
The props pick the tag, in this order:
- A router link:
tois set, or an Inertia app gets a same-originhrefwith notarget. Which component (RouterLink,NuxtLink, Inertia'sLink) depends on the framework. - An anchor: any other
href: external URLs, anything withtarget,downloadorexternal, and a disabledtolink. - A native
<button>: no destination.
<button> — no destination, so an action
Jump to navigation <a href> — middle-click and copy-address work
bitboss-ui on npm <a target="_blank" rel="noopener noreferrer">
<template>
<div class="flex flex-col gap-3 text-sm">
<!-- No href and no to: a native <button type="button">. -->
<p class="flex items-center gap-2">
<BbBaseButton class="link" @click="pressed = true">
Run the import
</BbBaseButton>
<span class="text-xs text-[color:var(--bb-text-muted)]">
<button> — {{ pressed ? 'pressed' : 'no destination, so an action' }}
</span>
</p>
<!-- An href with no framework routing available: a real <a>. -->
<p class="flex items-center gap-2">
<BbBaseButton class="link" href="#navigation">
Jump to navigation
</BbBaseButton>
<span class="text-xs text-[color:var(--bb-text-muted)]">
<a href> — middle-click and copy-address work
</span>
</p>
<!-- A target forces a plain anchor, and rel is hardened for you. -->
<p class="flex items-center gap-2">
<BbBaseButton
class="link"
href="https://www.npmjs.com/package/bitboss-ui"
target="_blank"
>
bitboss-ui on npm
</BbBaseButton>
<span class="text-xs text-[color:var(--bb-text-muted)]">
<a target="_blank" rel="noopener noreferrer">
</span>
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbBaseButton } from 'bitboss-ui';
const pressed = ref(false);
</script>
<style scoped>
.link {
color: var(--bb-primary);
font-weight: 500;
}
.link:focus-visible {
box-shadow: 0 0 0 var(--bb-ring-size) var(--bb-ring);
outline: none;
}
</style>
Any target other than _self gets rel="noopener noreferrer", named windows
included.
Destinations are props, not handlers
If a click goes somewhere, put that destination on href or to. An @click
that calls router.push cannot open in a new tab and is not a link to assistive
technology.
Which prop you use depends on the framework:
<!-- Vue Router or Nuxt: `to` keeps the navigation inside the app. -->
<BbBaseButton :to="{ name: 'orders.show', params: { id: order.id } }">
{{ order.reference }}
</BbBaseButton>
<!-- The same destination as an href (`/orders`) is a real document
navigation in that app: a full page load. -->
<BbBaseButton :href="ordersPath">All orders</BbBaseButton>
Under Inertia the rule flips: a same-origin href without a target becomes an
Inertia visit. Configure the visit with props, not listeners: request shape
(method, data, headers, only, except), history and scroll (replace,
preserve-scroll, preserve-state), lifecycle hooks (on-before, on-start,
on-progress, on-success, on-error, on-finish).
<!-- Inertia: the href (`/invoices`) is the visit; everything else rides
along as props. -->
<BbBaseButton
:data="{ customerId }"
:href="invoicesPath"
method="post"
:on-success="() => drawer.close()"
preserve-scroll
>
Create invoice
</BbBaseButton>
Three props force a plain anchor and skip routing: target, download
(including blob: and data: URLs) and external. replace swaps the history
entry instead of pushing one, under both Vue Router and Inertia. Without a
router, only href works.
The link that points here
When the link points at the current location the component adds a class and, on
an exact match, aria-current (page unless aria-current-value says
otherwise). It applies no styling: you write the highlight against the class you
named.
<BbBaseButton
class="nav-item"
exact-active-class="nav-item--current"
:to="{ name: 'settings.members' }"
>
Members
</BbBaseButton>
active-class matches a path prefix (a section link that stays lit on child
pages). exact-active-class only matches exactly. Without them you get the
defaults, router-link-active and router-link-exact-active, or whatever
linkActiveClass / linkExactActiveClass set on the plugin. A fragment-only
href (#section) is the page you are already on, so it never matches.
A whole region as one link
A simple row can be the link itself. For a richer card, keep the content in a
plain container and stretch one BbBaseButton overlay across it. The overlay
gets a short accessible name while badges, metadata and secondary controls stay
outside the link.
<template>
<div class="grid max-w-lg gap-2 sm:grid-cols-2">
<article
v-for="product in catalogue"
:key="product.id"
class="relative flex flex-col items-start gap-1 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-3 hover:bg-[color:var(--bb-surface-hover)] has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-[color:var(--bb-ring)]"
>
<span class="flex w-full items-center justify-between gap-2">
<span class="text-xs text-[color:var(--bb-text-muted)]">
{{ product.category }}
</span>
<BbBadge variant="secondary">{{ product.stock }} in stock</BbBadge>
</span>
<span class="text-sm font-medium">{{ product.name }}</span>
<span class="text-xs text-[color:var(--bb-text-muted)]">
{{ product.sku }} · updated {{ product.updatedAt }}
</span>
<BbBaseButton
class="absolute inset-0 rounded-[var(--bb-radius)] outline-none"
:href="`#${product.sku.toLowerCase()}`"
>
<span class="sr-only">Open {{ product.name }}</span>
</BbBaseButton>
</article>
</div>
</template>
<script setup lang="ts">
import { BbBadge, BbBaseButton } from 'bitboss-ui';
import { products } from '~/demo-data';
const catalogue = products.slice(0, 2);
</script>
Keep the overlay's label specific: “Open Acme workspace” is better than making a screen reader announce every value in the card. Put secondary controls above the overlay and keep them as separate interactive elements.
Links that flow in text
A record identity in a table cell, a reference in a sentence, a value in a property grid: links without a button box.
Refund issued against ORD-2026-0417 on 4 June, after the customer reported a damaged desk.
BbBaseButton + your own .plain-link — the link is part of the sentence, wraps with it, and inherits its colour.
<template>
<div class="flex max-w-md flex-col gap-4 text-sm">
<div class="flex flex-col gap-1">
<p>
Refund issued against
<BbBaseButton class="plain-link" :href="`#${order.reference}`">
{{ order.reference }}
</BbBaseButton>
on 4 June, after the customer reported a damaged desk.
</p>
<p class="text-xs text-[color:var(--bb-text-muted)]">
BbBaseButton + your own <code>.plain-link</code> — the link is part of
the sentence, wraps with it, and inherits its colour.
</p>
</div>
<div class="flex flex-col gap-1">
<p>
Refund issued against
<BbButton :href="`#${order.reference}`" variant="link">
{{ order.reference }}
</BbButton>
on 4 June, after the customer reported a damaged desk.
</p>
<p class="text-xs text-[color:var(--bb-text-muted)]">
BbButton <code>variant="link"</code> — still a button box: its own
height and padding push the line apart.
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { BbBaseButton, BbButton } from 'bitboss-ui';
import { orders } from '~/demo-data';
const order = orders[0]!;
</script>
<style scoped>
/*
* A project-owned class, not a library variant — the design language's
* recommended shape for inline identity links (record numbers, names, titles).
*/
.plain-link {
color: inherit;
text-decoration: underline;
text-decoration-color: color-mix(in oklab, var(--bb-text) 25%, transparent);
text-underline-offset: 2px;
transition: text-decoration-color var(--bb-transition-fast) var(--bb-ease);
}
.plain-link:hover,
.plain-link:focus-visible {
outline: none;
text-decoration-color: currentColor;
}
</style>
BbButton's variant="link" changes the colour but keeps the button's height
and padding, so it cannot flow with the text or truncate at cell width. On
BbBaseButton use a project class (for example .plain-link), defined once and
reused on identity cells.
Disabled adapts to the element
One prop, two behaviours: the platform has no disabled <a>.
Neither one is reachable by keyboard or pointer.
<template>
<div class="flex flex-wrap items-center gap-4 text-sm">
<!-- Native button: the real `disabled` attribute, so the browser blocks
the click and drops it from the tab order. -->
<BbBaseButton
class="pill"
disabled
@click="attempted = 'the button ran its handler'"
>
Export ledger
</BbBaseButton>
<!-- Link: the href is removed and the element stays in place with
role="link" and aria-disabled="true" — announced, not vanished. -->
<BbBaseButton class="pill" disabled href="#form">
Open the archived invoice
</BbBaseButton>
<p class="text-xs text-[color:var(--bb-text-muted)]" role="status">
{{ attempted || 'Neither one is reachable by keyboard or pointer.' }}
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbBaseButton } from 'bitboss-ui';
const attempted = ref('');
</script>
<style scoped>
.pill {
border: 1px solid var(--bb-border);
border-radius: var(--bb-radius);
padding: 0.375rem 0.75rem;
}
/*
* The component sets `bb-base-button--disabled` on both elements — the same
* hook whether it rendered a <button> or an <a>, which is what lets one rule
* style both.
*/
.pill.bb-base-button--disabled {
color: var(--bb-text-faint);
cursor: not-allowed;
}
</style>
On a button it sets the native disabled attribute. On a link it drops the
href and sets role="link", aria-disabled="true" and tabindex="-1":
unfollowable and unfocusable, still in the layout. Both get
bb-base-button--disabled, so one rule styles either. Prefer that to a dead
href that 404s.
Inside a form, and full width
type defaults to button, so nothing submits by accident. The one that should
submit says so.
<template>
<form
class="flex max-w-xs flex-col gap-3 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-3"
@submit.prevent="submitted = true"
>
<BbTextInput
id="workspace-name"
v-model="name"
compact
label="Workspace name"
name="workspace-name"
/>
<!-- type="submit" is the opt-in. Without it the default `button` type
means this control could never submit the form by accident. -->
<BbBaseButton block class="submit" type="submit">
Create workspace
</BbBaseButton>
<p class="text-xs text-[color:var(--bb-text-muted)]" role="status">
{{
submitted
? `Submitted through the form's own submit event.`
: 'Press Enter in the field, or use the button.'
}}
</p>
</form>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbBaseButton, BbTextInput } from 'bitboss-ui';
const name = ref('Northwind Billing');
const submitted = ref(false);
</script>
<style scoped>
.submit {
background: var(--bb-primary);
border-radius: var(--bb-radius);
color: var(--bb-primary-fg);
font-weight: 500;
padding: 0.375rem 0.75rem;
}
.submit:focus-visible {
box-shadow: 0 0 0 var(--bb-ring-size) var(--bb-ring);
outline: none;
}
</style>
block adds bb-base-button--block for a full-width control: stacked nav
items, list rows, a mobile call to action. tag renders the non-link case as a
div, span or label (for example a <label> wrapping a hidden file input,
or where a nested <button> would be invalid markup). You lose the native
type and disabled attributes and keyboard activation: use it only when you
have no alternative, then restore the missing focus and keyboard behaviour.