Use it for
Use BbSlider when position within a known interval carries meaning.
Use something else when
BbNumberInput— exact entry is the taskBbRating— the scale is a few discrete judgements
Also see: Validated forms.
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.
Set an exact spending limit
When the amount has consequences, bind the slider and number field to the same ref.
<template>
<div class="flex max-w-sm flex-col gap-3">
<!-- One ref, two controls: the slider for the feel, the field for the figure. -->
<BbSlider
id="spend-limit-slider"
v-model="limit"
compact
description="API calls pause once the cap is reached."
label="Monthly spend limit"
:max="500"
:min="0"
name="spend-limit"
:step="25"
/>
<BbNumberInput
id="spend-limit-exact"
v-model="limit"
compact
currency="EUR"
label="Exact amount"
:max="500"
:max-precision="0"
:min="0"
name="spend-limit-exact"
:step="25"
>
<template #suffix>/ month</template>
</BbNumberInput>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbNumberInput, BbSlider } from 'bitboss-ui';
// No sync code: both controls write to this ref, and the slider clamps and
// snaps whatever the field emits.
const limit = ref<number | null>(200);
</script>
min, max, and step are hard invariants. Invalid bounds or a non-positive step throw at mount. A value from an API must be converted to a number before binding.
Filter a range
Set range and bind a two-element array; the component keeps it sorted.
<template>
<section
aria-labelledby="filter-price-heading"
class="flex max-w-xs flex-col gap-3 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-4"
>
<div class="flex items-baseline justify-between gap-2">
<span id="filter-price-heading" class="text-sm font-semibold">
Price
</span>
<span class="text-sm text-[color:var(--bb-text-muted)] tabular-nums">
€{{ price[0] }} to €{{ price[1] }}
</span>
</div>
<!-- The visible heading already names the slider, so the label is kept
for assistive technology only. -->
<BbSlider
id="filter-price"
v-model="price"
hide-label
label="Price range"
:max="MAX"
:min="MIN"
name="price"
range
:step="50"
/>
<div
aria-hidden="true"
class="flex justify-between text-xs text-[color:var(--bb-text-muted)] tabular-nums"
>
<span>€{{ MIN }}</span>
<span>€{{ MAX }}</span>
</div>
<div
class="flex items-center justify-between gap-2 border-t border-[color:var(--bb-border)] pt-3"
>
<span class="text-sm" role="status">
{{ matching.length }} of {{ products.length }} products
</span>
<BbButton size="sm" variant="ghost" @click="price = [MIN, MAX]">
Reset
</BbButton>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbButton, BbSlider } from 'bitboss-ui';
import { products } from '~/demo-data';
const MIN = 0;
const MAX = 1000;
// `range` needs an array. A two-element tuple says so in the type as well, and
// keeps `price[0]` a number rather than `number | undefined`.
const price = ref<[number, number]>([100, 500]);
const matching = computed(() =>
products.filter(
(product) =>
product.price >= price.value[0] && product.price <= price.value[1]
)
);
</script>
The model's shape follows the mode: a number for one thumb, a two-element array with range. Binding a number to a range slider throws.
Readout, ticks, and keyboard
The component has no built-in numeric readout. Show the model beside the track or through the thumb slot.
<template>
<div class="max-w-sm">
<BbSlider
id="ticks-priority"
v-model="priority"
label="Priority"
:max="5"
:min="1"
name="priority"
:step="1"
ticks
>
<!-- `ticks` positions one slot per step; the mark itself is yours. -->
<template #tick="{ value, active }">
<span
class="text-xs tabular-nums"
:class="active ? 'font-medium' : 'opacity-50'"
>{{ value }}</span
>
</template>
</BbSlider>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSlider } from 'bitboss-ui';
const priority = ref<number | null>(3);
</script>
ticks positions the tick slot and draws no marks by itself. Raise max-precision when step is finer than 0.01.
<template>
<section
aria-labelledby="steppers-volume-heading"
class="flex max-w-sm flex-col gap-2 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-4"
>
<div class="flex items-baseline justify-between gap-3">
<div class="flex flex-col">
<span id="steppers-volume-heading" class="text-sm font-semibold">
Playback volume
</span>
<span class="text-xs text-[color:var(--bb-text-muted)]">
Meeting room speaker
</span>
</div>
<span class="text-sm font-medium tabular-nums">{{ volume }}%</span>
</div>
<!-- The heading names the slider on screen; the label stays for assistive tech. -->
<BbSlider
id="steppers-volume"
v-model="volume"
hide-label
label="Playback volume"
:max="100"
:min="0"
name="volume"
pt:box="mx-2"
:step="10"
>
<!--
Icon-only buttons, so each one carries its own accessible name.
`disabled` is on when that side would do nothing: at a bound, or
while the slider is disabled or readonly.
-->
<template #prepend="{ decrease, disabled }">
<BbButton
aria-label="Quieter"
:disabled="disabled"
icon="lucide:volume-1"
size="sm"
variant="ghost"
@click="decrease"
/>
</template>
<template #append="{ increase, disabled }">
<BbButton
aria-label="Louder"
:disabled="disabled"
icon="lucide:volume-2"
size="sm"
variant="ghost"
@click="increase"
/>
</template>
</BbSlider>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbSlider } from 'bitboss-ui';
// The buttons, the arrow keys and a drag all land on the same 10% grid.
const volume = ref<number | null>(60);
</script>
Arrow keys move one step, Page keys ten, and Home/End reach the bounds; every key and both stepper callbacks land on the step grid. When max is off that grid, the top is the last grid point (0–10 by 3 stops at 9). The stepper callbacks work only in single-value mode, and each slot's disabled says when its side would do nothing (at a bound, or on a disabled, readonly or range slider), so the buttons bind it instead of comparing to min and max. Save on inactive when persistence should wait for release.
Coming from v2thumb-translate → disable-thumb-translate
:thumb-translate="false" became disable-thumb-translate with inverted polarity.
Messages and locked values
Say why a value is risky, invalid or locked: warnings for a value you accept with a caveat, errors for one you refuse, description or hint for a lock.
<template>
<!-- Each label carries a readout; aria-hidden, because the thumb already
announces its value. -->
<div class="flex max-w-sm flex-col gap-5">
<!-- Warnings are the amber channel: allowed, but worth a second look. -->
<BbSlider
id="chrome-quality"
v-model="quality"
description="Higher quality means larger exports."
label="Export quality"
:max="100"
:min="10"
name="quality"
:step="10"
:warnings="qualityWarnings"
>
<template #label>
<span class="readout">
Export quality<span aria-hidden="true">{{ quality }}%</span>
</span>
</template>
</BbSlider>
<!-- A non-empty `errors` implies the state; `has-errors` is for when the
message lives somewhere else. -->
<BbSlider
id="chrome-replicas"
v-model="replicas"
:errors="replicaErrors"
label="Replicas"
:max="8"
:min="0"
name="replicas"
:step="1"
>
<template #label>
<span class="readout">
Replicas<span aria-hidden="true">
{{ replicas }} {{ replicas === 1 ? 'replica' : 'replicas' }}
</span>
</span>
</template>
</BbSlider>
<!-- readonly, not disabled: still focusable, so the value is reachable. -->
<BbSlider
id="chrome-quota"
hint="Set by your plan. Upgrade to raise it."
label="Included seats"
:max="100"
:min="0"
:model-value="25"
name="quota"
persistent-hint
readonly
>
<template #label>
<span class="readout">
Included seats<span aria-hidden="true">25 seats</span>
</span>
</template>
</BbSlider>
<!-- disabled: out of the tab order and out of the form. Say why. -->
<BbSlider
id="chrome-ceiling"
description="Turn on autoscaling to set a ceiling."
disabled
label="Autoscale ceiling"
:max="20"
:min="1"
:model-value="4"
name="ceiling"
>
<template #label>
<span class="readout">
Autoscale ceiling<span aria-hidden="true">4 nodes</span>
</span>
</template>
</BbSlider>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbSlider } from 'bitboss-ui';
const quality = ref<number | null>(90);
const replicas = ref<number | null>(1);
const qualityWarnings = computed(() =>
(quality.value ?? 0) > 80
? ['Above 80 the files grow faster than the detail.']
: []
);
const replicaErrors = computed(() =>
(replicas.value ?? 0) < 2 ? ['Production services need at least two.'] : []
);
</script>
<style scoped>
.readout {
display: flex;
justify-content: space-between;
width: 100%;
}
.readout > span {
color: var(--bb-text-muted);
font-variant-numeric: tabular-nums;
font-weight: 400;
}
</style>
Prefer readonly for a locked value that people still need to inspect; disabled removes the thumb from the tab order. label-mode takes outside or inside only (a floating default set for the whole app falls back to outside here, without a warning).
Submit it with a form
In a BbForm the slider joins the form by its name and needs no v-model. Use rules for what min and max cannot say.
<template>
<BbForm
:model="booking"
class="flex max-w-sm flex-col gap-4 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-4"
@submit="onSubmit"
>
<!-- No v-model on the slider: the form holds the value under `name`.
The end labels overhang the rail by half their width: inset the box. -->
<BbSlider
id="delivery-window"
:description="`Thursday, ${hour(booking.window[0])} to ${hour(booking.window[1])}`"
disable-thumb-translate
label="Delivery window"
:max="20"
:min="8"
name="window"
pt:box="mx-3"
pt:ticks="mt-1"
range
:rules="atLeastTwoHours"
ticks
>
<template #tick="{ value, active }">
<span
v-if="value % 4 === 0"
class="text-xs tabular-nums"
:class="active ? 'font-medium' : 'text-[color:var(--bb-text-muted)]'"
>{{ hour(value) }}</span
>
</template>
</BbSlider>
<div class="flex items-center justify-between gap-3">
<span class="text-sm" role="status">
{{ status }}
</span>
<BbButton
class="shrink-0"
type="submit"
variant="primary"
>
Book slot
</BbButton>
</div>
</BbForm>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from 'vue';
import { BbButton, BbForm, BbSlider } from 'bitboss-ui';
import { delay } from '~/demo-data';
const booking = reactive({ window: [10, 14] as [number, number] });
const hour = (value: number) => `${String(value).padStart(2, '0')}:00`;
// A rule earns its place when it says what `min` and `max` cannot.
const atLeastTwoHours = (value: unknown) => {
const [from = 0, to = 0] = (value as number[] | null) ?? [];
return to - from >= 2 || 'Leave the courier at least two hours.';
};
const status = ref('');
// A new window is not booked yet.
watch(
() => [...booking.window],
() => (status.value = '')
);
/** Runs only when every field passes. */
const onSubmit = async (values: Record<string, unknown>) => {
await delay(null, 500); // stands in for the POST of `values`
const [from = 0, to = 0] = values.window as number[];
status.value = `Booked Thursday, ${hour(from)} to ${hour(to)}.`;
};
</script>
Drag the two ends closer than two hours and submit to see the rule stop the booking. A classic <form> post gets one field per value: window=10&window=14 for a range (name it window[] for Laravel), nothing at all for a null value. A slider that sits in a form without being its data, such as a zoom level, takes formless to stay out of the form's data and validation.
Rail, bar, and ticks
The colours are two locals on pt:box: --accent paints the bar and the thumb's halo, --track-color the rail.
<template>
<!-- The end labels overhang the rail by half their width: leave room. -->
<div class="max-w-sm px-4">
<!-- Colours are locals on the box: `--accent` paints the bar (and the
thumb halo), `--track-color` the rail. `pt:tick:in-range` reaches
only the marks under the bar: they turn from muted to full text
colour, which stays legible in both themes. -->
<BbSlider
id="styling-budget"
v-model="budget"
disable-thumb-translate
label="Monthly ad budget"
:max="5000"
:min="0"
name="budget"
pt:box="[--accent:var(--bb-success)] [--r:9999px] [--track:6px]"
pt:tick="text-(--bb-text-muted)"
pt:tick:in-range="font-medium text-(--bb-text)"
pt:ticks="mt-1"
:step="1000"
ticks
>
<template #tick="{ value }">
<span
class="flex flex-col items-center gap-0.5 text-xs leading-none whitespace-nowrap tabular-nums"
>
<span class="h-1.5 w-px bg-current" />
{{ value ? `${value / 1000}k` : '0' }}
</span>
</template>
</BbSlider>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSlider } from 'bitboss-ui';
const budget = ref<number | null>(3000);
</script>
pt:track reaches the rail itself, pt:bar the coloured span, and pt:tick:in-range only the marks under that span. --track sets the rail's thickness, --thumb the handle's size, and --r the rounding of the rail and of the box.
In inside label mode the box wears the field look through the same words as the other fields: --bg, --bw, --border-color, --r, --elev, --ring-size with --ring-color, and --px/--py. Its focus ring turns danger or warning with the field's state, so recolour the resting border with pt:box="[--border-color:…]" rather than a border-* utility, which would hide the state.
Coming from v2color → --accent
The v2 color prop is gone: set --accent (and --track-color) on pt:box, as above.