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"
Also see: Inertia and Laravel.
BbButton and other components are built on this.
Passthrough
Hover or tap a part to outline it. The toggles turn on loading, errors and warnings where the component has them; only parts get a row.
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-end gap-8 text-sm">
<!-- Untouched: the reset, the focus scaffold, and nothing else. -->
<figure class="flex flex-col items-start gap-2">
<BbBaseButton class="focusable" @click="saved = 'unstyled'">
Save view
</BbBaseButton>
<figcaption class="font-mono text-xs text-[color:var(--bb-text-muted)]">
no classes
</figcaption>
</figure>
<!-- The same component, dressed entirely by these utility classes. -->
<figure class="flex flex-col items-start gap-2">
<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 = 'styled'"
>
Save view
</BbBaseButton>
<figcaption class="font-mono text-xs text-[color:var(--bb-text-muted)]">
your classes
</figcaption>
</figure>
<p class="basis-full 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>
To style it from a wrapper or a preset, use pt:root (it lands on whichever
element renders) and pt:root:disabled: see passthrough.
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. The framework decides whether that isRouterLink,NuxtLinkor Inertia'sLink. - An anchor: every other
href. That covers 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 flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-2">
<BbBaseButton class="link shrink-0 whitespace-nowrap" @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 flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-2">
<BbBaseButton class="link shrink-0 whitespace-nowrap" 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 flex-col items-start gap-1 sm:flex-row sm:items-center sm:gap-2">
<BbBaseButton
class="link shrink-0 whitespace-nowrap"
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-cancel, on-finish). on-error
fires on validation errors (the 422 answer) only.
<!-- 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>
The visit props need href: a to link is a router link and sends no
method (the ESLint rule bitboss-ui/require-partner-prop flags it).
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 Aeris Ergonomic Task Chair” 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<string | null>('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-size: 0.875rem;
font-weight: 500;
/* Centred label, at the 28px height of the compact field above it. */
align-items: center;
display: flex;
justify-content: center;
min-height: 1.75rem;
padding: 0 0.75rem;
}
.submit:hover {
background: var(--bb-primary-hover);
}
.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. It centres the label and stretches a
link as well; a row laid out with flex and text-left, as in a nav list, keeps
its own alignment. tag renders the non-link case as a
div, span or label, for 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, so use it only when you have no
alternative, then restore focus and keyboard behaviour yourself.