Use it for
Use BbColorPalette to assign a color from a trigger you own, such as a label dot or calendar category.
Use something else when
BbColorInput— users need a form field and editable hex textBbPopover— the overlay contains arbitrary content
Pass Through
Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.
Assign and save label colors
Spread the activator slot's props onto the clickable element and give each instance a specific label.
- accessibility
- design system
- documentation
<template>
<div class="flex max-w-sm 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">
{{ saving ? 'Saving…' : 'All changes saved' }}
</span>
</div>
<ul class="flex flex-col gap-1">
<li
v-for="label in labels"
:key="label.name"
class="flex items-center gap-2"
>
<!-- Repaints on every drag frame: one style binding, no watcher. -->
<span
class="chip"
:style="{ backgroundColor: label.color, color: textOn(label.color) }"
>
{{ label.name }}
</span>
<BbColorPalette
v-model="label.color"
:label="`Color for the ${label.name} label`"
placement="bottom-end"
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>
</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 labels = ref(
initialTags.map((name, index) => ({
name,
color: PRESETS[index % PRESETS.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;
return luminance > 0.6 ? 'var(--bb-text)' : 'var(--bb-panel)';
};
</script>
<style scoped>
.chip {
margin-right: auto;
padding: 0.125rem 0.625rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 500;
}
.dot {
height: 1rem;
width: 1rem;
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>
The model starts as string | null, but v3 emits only concrete colors. Dragging emits on every frame: repaint locally, debounce persistence, and provide your own action to clear back to null.
Color constraints
Treat swatches, alpha, and the eyedropper as constraints on one picker, not visual variants.
<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' }"
/>
</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 {
height: 100%;
width: 100%;
}
.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 native API does; keep the gradient or swatches as the universal path.
Coming from v2picker → eye-dropper
picker became eye-dropper; the old attribute silently does nothing. 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.
<template>
<div class="flex flex-col gap-3">
<!-- readonly: still focusable and in the reading order, never opens. -->
<BbColorPalette v-model="archived" label="Archived project color" readonly>
<template #activator="{ props, value }">
<span class="row">
<button
v-bind="props"
class="dot"
:style="{ backgroundColor: value ?? 'transparent' }"
type="button"
/>
<span class="text-sm">Archived project — read only</span>
</span>
</template>
</BbColorPalette>
<!-- disabled: inert, and the slot flag lets you grey your own markup. -->
<BbColorPalette v-model="locked" disabled label="Locked theme color">
<template #activator="{ props, value, disabled }">
<span class="row" :class="{ 'row--disabled': disabled }">
<button
v-bind="props"
class="dot"
:style="{ backgroundColor: value ?? 'transparent' }"
type="button"
/>
<span class="text-sm">Locked by the workspace theme</span>
</span>
</template>
</BbColorPalette>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbColorPalette } from 'bitboss-ui';
const archived = ref<string | null>('#0e8a16');
const locked = ref<string | null>('#6d28d9');
</script>
<style scoped>
.row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.row--disabled {
opacity: 0.5;
}
.dot {
height: 1rem;
width: 1rem;
flex: none;
border: 1px solid var(--bb-border);
border-radius: 999px;
outline: none;
}
.dot:focus-visible {
box-shadow:
0 0 0 2px var(--bb-panel),
0 0 0 4px var(--bb-ring);
}
</style>
readonly keeps the trigger focusable without opening the palette. disabled removes interaction. In v3, a leftover @update:open listener never fires and does not warn.
Placement and mobile behavior
Anchor the popover to a larger row when the click target is only a small color 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; picker drag surfaces do not move the sheet. Positioning props are ignored there, and the chosen surface is latched for the open cycle.
Coming from v2flip → disable-flip
:flip="false" became disable-flip with inverted polarity. show-arrow and arrow-padding were removed.