Use it for
Use BbColorPalette to assign a colour from a trigger you own, such as a label dot or calendar category.
Use something else when
BbColorInput— the colour is a form field, with hex text people can typeBbPopover— the overlay contains arbitrary content
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.
Assign and save label colours
Spread the activator slot's props onto the clickable element and give each instance a specific label.
- Color for the accessibility labelaccessibility12 open issues
- Color for the design system labeldesign system5 open issues
- Color for the documentation labeldocumentation31 open issues
<template>
<div class="flex max-w-md flex-col gap-2">
<div class="flex items-baseline justify-between gap-3">
<span class="text-sm font-medium">Issue labels</span>
<span class="text-xs opacity-70" role="status">
{{ saving ? 'Saving…' : 'All changes saved' }}
</span>
</div>
<ul
class="m-0 flex list-none flex-col divide-y divide-[color:var(--bb-border)] rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-0"
>
<li
v-for="label in labels"
:key="label.name"
class="flex items-center gap-3 px-3 py-2"
>
<BbColorPalette
v-model="label.color"
:label="`Color for the ${label.name} label`"
placement="bottom-start"
swatches
@update:model-value="onQueueSave"
>
<template #activator="{ props, value, open }">
<button
v-bind="props"
class="dot"
:class="{ 'dot--open': open }"
:style="{ backgroundColor: value ?? 'transparent' }"
type="button"
/>
</template>
</BbColorPalette>
<!-- Repaints on every drag frame: one style binding, no watcher. -->
<span
class="chip"
:style="{ backgroundColor: label.color, color: textOn(label.color) }"
>
{{ label.name }}
</span>
<span class="ms-auto text-xs tabular-nums opacity-70">
{{ label.open }} open issues
</span>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue';
import { BbColorPalette } from 'bitboss-ui';
import { initialTags } from '~/demo-data';
const PRESETS = ['#d73a4a', '#2563eb', '#0e8a16'];
const OPEN_COUNTS = [12, 5, 31];
const labels = ref(
initialTags.map((name, index) => ({
name,
color: PRESETS[index % PRESETS.length]!,
open: OPEN_COUNTS[index % OPEN_COUNTS.length]!,
}))
);
const saving = ref(false);
let timer: ReturnType<typeof setTimeout> | undefined;
/*
* `update:modelValue` is drag-frequency, so the write is debounced: only the
* trailing edge of a drag would ever reach a backend.
*/
const onQueueSave = () => {
saving.value = true;
clearTimeout(timer);
timer = setTimeout(() => (saving.value = false), 600);
};
onBeforeUnmount(() => clearTimeout(timer));
const textOn = (hex: string) => {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
// Fixed ink, not theme tokens: the chip colour is the same in light and dark.
return luminance > 0.6 ? '#111827' : '#ffffff';
};
</script>
<style scoped>
.chip {
padding: 0.125rem 0.625rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 500;
}
.dot {
height: 1.25rem;
width: 1.25rem;
flex: none;
border: 1px solid var(--bb-border);
border-radius: 999px;
cursor: pointer;
outline: none;
}
.dot--open,
.dot:focus-visible {
box-shadow:
0 0 0 2px var(--bb-panel),
0 0 0 4px var(--bb-ring);
}
</style>
The model starts as string | null, but the palette emits only concrete colours. Dragging emits on every frame: repaint locally, debounce persistence, and provide your own action to clear back to null. The palette never joins a BbForm, so save the value yourself.
For a dot in a table cell, v-bb-color="{ modelValue, 'onUpdate:modelValue': save, label }" wires the same palette with no wrapper component; the plugin registers the directive (see Installation). For an element you already hold a ref to, pass it to activator.
Presets, transparency and the eyedropper
Offer your brand colours as swatches, turn on alpha when the value can carry opacity, and add eye-dropper to sample a colour from the screen.
<template>
<div class="flex max-w-sm flex-col gap-3">
<!-- Checkerboard behind the fill, so the alpha channel is visible. -->
<div class="preview">
<div
class="preview__fill"
:style="{ backgroundColor: overlay ?? 'transparent' }"
>
Summer collection
</div>
</div>
<!--
Three independent enrichments. The eyedropper button renders only
where the browser has the native EyeDropper API — on Firefox and
Safari the swatches and the gradient are the whole picker.
-->
<BbColorPalette
v-model="overlay"
alpha
eye-dropper
label="Overlay tint"
:swatches="SWATCH_COLUMNS"
>
<template #activator="{ props, value }">
<button v-bind="props" class="trigger" type="button">
<span
class="trigger__dot"
:style="{ backgroundColor: value ?? 'transparent' }"
/>
<span class="trigger__value">{{ value ?? 'Pick a tint' }}</span>
</button>
</template>
</BbColorPalette>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbColorPalette } from 'bitboss-ui';
// Each inner array is a COLUMN of shades: ramps are designed vertically.
const SWATCH_COLUMNS: string[][] = [
['#fef3c7', '#f59e0b', '#b45309'],
['#fee2e2', '#ef4444', '#b91c1c'],
['#dbeafe', '#3b82f6', '#1d4ed8'],
];
// With `alpha` the model carries an 8-digit #RRGGBBAA value.
const overlay = ref<string | null>('#1d4ed8cc');
</script>
<style scoped>
.preview {
height: 4rem;
border: 1px solid var(--bb-border);
border-radius: var(--bb-radius);
overflow: hidden;
background-image:
linear-gradient(45deg, var(--bb-muted) 25%, transparent 25%),
linear-gradient(-45deg, var(--bb-muted) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--bb-muted) 75%),
linear-gradient(-45deg, transparent 75%, var(--bb-muted) 75%);
background-position:
0 0,
0 6px,
6px -6px,
-6px 0;
background-size: 12px 12px;
}
.preview__fill {
display: grid;
height: 100%;
width: 100%;
place-items: center;
color: #ffffff;
font-weight: 600;
}
.trigger {
display: inline-flex;
width: 100%;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
border: 1px solid var(--bb-border);
border-radius: var(--bb-radius);
font-size: 0.875rem;
outline: none;
}
.trigger:focus-visible {
box-shadow: 0 0 0 2px var(--bb-ring);
}
.trigger__dot {
height: 1rem;
width: 1rem;
flex: none;
border: 1px solid var(--bb-border);
border-radius: 999px;
}
.trigger__value {
font-variant-numeric: tabular-nums;
}
</style>
Each custom swatch array is a column. Enable alpha wherever eight-digit values can arrive. The eyedropper button exists only where the browser has the native API, and a sampled colour is always opaque; keep the gradient or swatches as the universal path.
Coming from v2picker → eye-dropper
picker became eye-dropper; the old attribute has no effect, a development build warns about the rename, and npx bitboss-ui check --fix rewrites it. The activator slot value was renamed from color to value.
Trigger and open state
The palette owns its open state. Observe open from the activator slot; there is no open prop or v-model:open.
- Marketing siteUpdated 3 days agoColor for Marketing site
- Legacy billingArchived in MarchColor for Legacy billing
- Workspace themeManaged by an adminColor for Workspace theme
<template>
<ul
class="m-0 flex max-w-md list-none flex-col divide-y divide-[color:var(--bb-border)] rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-0"
>
<li v-for="project in projects" :key="project.id" class="row">
<span class="flex min-w-0 flex-col">
<span class="text-sm font-medium">{{ project.name }}</span>
<span class="text-xs text-[color:var(--bb-text-muted)]">
{{ project.note }}
</span>
</span>
<!-- The visible hex is inside the trigger, so it is part of the
trigger's accessible name after `label`. `props` already sets
`disabled` on the button; the slot's `disabled` is for styling. -->
<BbColorPalette
v-model="project.color"
:disabled="project.state === 'disabled'"
:label="`Color for ${project.name}`"
placement="bottom-end"
:readonly="project.state === 'readonly'"
swatches
>
<template #activator="{ props, value, open, disabled }">
<button
v-bind="props"
:aria-disabled="project.state === 'readonly' || undefined"
class="trigger"
:class="{ 'trigger--disabled': disabled }"
type="button"
>
<span
class="dot"
:style="{ backgroundColor: value ?? 'transparent' }"
/>
<span class="flex-1 text-left tabular-nums">{{ value }}</span>
<BbIcon
v-if="project.state === 'editable'"
class="caret"
:class="{ 'caret--open': open }"
icon="lucide:chevron-down"
size="xs"
/>
<BbIcon v-else class="opacity-60" icon="lucide:lock" size="xs" />
</button>
</template>
</BbColorPalette>
</li>
</ul>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbColorPalette, BbIcon } from 'bitboss-ui';
type Access = 'editable' | 'readonly' | 'disabled';
const projects = ref<
{
id: number;
name: string;
note: string;
state: Access;
color: string | null;
}[]
>([
{
id: 1,
name: 'Marketing site',
note: 'Updated 3 days ago',
state: 'editable',
color: '#2563eb',
},
{
id: 2,
name: 'Legacy billing',
note: 'Archived in March',
state: 'readonly',
color: '#0e8a16',
},
{
id: 3,
name: 'Workspace theme',
note: 'Managed by an admin',
state: 'disabled',
color: '#6d28d9',
},
]);
</script>
<style scoped>
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
}
.trigger {
display: inline-flex;
flex: none;
min-width: 7.5rem;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
border: 1px solid var(--bb-border);
border-radius: var(--bb-radius);
font-size: 0.8125rem;
outline: none;
}
.trigger:focus-visible {
box-shadow: 0 0 0 2px var(--bb-ring);
}
.trigger--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.dot {
height: 0.875rem;
width: 0.875rem;
border: 1px solid var(--bb-border);
border-radius: 999px;
}
.caret {
transition: transform 150ms ease;
}
.caret--open {
transform: rotate(180deg);
}
</style>
readonly keeps the trigger focusable without opening the palette. disabled stops it opening, and the activator props carry disabled and aria-disabled onto your button, so you only style the inert state, from the slot's disabled. Screen readers name the trigger by label followed by the text inside it, so a hex code you show stays in the name. Escape closes the palette before any dialog around it.
Coming from v2update:open removed
The event is gone, so a leftover @update:open listener never fires. Listen to show and hide, or read open from the activator slot.
Placement and mobile behaviour
Anchor the popover to a larger row when the click target is only a small colour dot.
<template>
<div class="max-w-sm">
<BbColorPalette
v-model="brand"
label="Brand accent color"
placement="bottom-start"
swatches
>
<!--
`anchorProps` goes on the row: the popover lines up with the whole
row. `props` goes on the dot: only the dot is clickable.
-->
<template #activator="{ props, anchorProps, value, open }">
<span v-bind="anchorProps" class="row">
<span class="row__text">
<span class="text-sm font-medium">Brand accent</span>
<span class="row__value">{{ value ?? 'No color' }}</span>
</span>
<button
v-bind="props"
class="dot"
:class="{ 'dot--open': open }"
:style="{ backgroundColor: value ?? 'transparent' }"
type="button"
/>
</span>
</template>
</BbColorPalette>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbColorPalette } from 'bitboss-ui';
const brand = ref<string | null>('#4f46e5');
</script>
<style scoped>
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--bb-border);
border-radius: var(--bb-radius);
}
.row__text {
display: flex;
min-width: 0;
flex-direction: column;
}
.row__value {
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
color: var(--bb-text-muted);
}
.dot {
height: 1.25rem;
width: 1.25rem;
flex: none;
border: 1px solid var(--bb-border);
border-radius: 999px;
outline: none;
}
.dot--open,
.dot:focus-visible {
box-shadow:
0 0 0 2px var(--bb-panel),
0 0 0 4px var(--bb-ring);
}
</style>
placement is a preference and flips on overflow unless disable-flip is set. On mobile, adaptive opens a draggable bottom sheet that ignores the positioning props. Dragging the colour square or a slider never drags the sheet.
Coming from v2flip → disable-flip
:flip="false" became disable-flip with inverted polarity. show-arrow and arrow-padding were removed.