Use it for
Use BbColorInput when colour is an editable form value with a label, validation, and a readable hex representation.
Use something else when
BbColorPalette, when colour is assigned from a trigger you ownBbSelect, when users must choose from a closed palette
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.
Edit a label colour
Bind a seeded string | null model. The field emits only a complete lowercase #RRGGBB value, or null when cleared.
<template>
<div class="flex max-w-sm flex-col gap-3">
<!-- Repaints on every drag frame — one style binding, no watcher. -->
<span
class="self-start rounded-full px-2.5 py-0.5 text-xs font-medium"
:style="{
backgroundColor: color ?? 'var(--bb-muted)',
color: textOn(color),
}"
>
{{ name || 'label preview' }}
</span>
<BbTextInput
id="label-name"
v-model="name"
label="Label name"
name="label-name"
placeholder="needs-triage"
/>
<BbColorInput
id="label-color"
v-model="color"
label="Label color"
name="label-color"
swatches
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbColorInput, BbTextInput } from 'bitboss-ui';
const name = ref<string | null>('regression');
const color = ref<string | null>('#b91c1c');
/*
* The model is always a complete `#RRGGBB`, so this parses without a guard
* for a half-typed value — there is no such state to guard against.
*/
const textOn = (hex: string | null) => {
if (hex === null) return 'var(--bb-text)';
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>
Partial text never reaches the model, and a partial hex left in the field reverts to the last valid colour on blur, so the field, the model and a native form post always agree. The field keeps the case you type until you leave it, then shows the lowercase value it emitted.
Convert rgb() or hsl() at your data boundary, and pass name when a native form post must include the value.
Picker options and limits
Add picker features together when the task needs them; they do not define separate field variants.
<template>
<div class="flex max-w-sm flex-col gap-4">
<!-- `swatches` on its own: the built-in grid, six hues by five shades. -->
<BbColorInput
id="swatches-brand"
v-model="brand"
label="Brand color"
name="brand-color"
swatches
/>
<!-- Custom presets: each inner array is a COLUMN, one hue per column. -->
<BbColorInput
id="swatches-label"
v-model="labelColor"
label="Label color"
name="label-color"
:swatches="SWATCH_COLUMNS"
/>
<!-- alpha widens the model to #RRGGBBAA; the eyedropper shows where supported. -->
<BbColorInput
id="swatches-overlay"
v-model="overlay"
alpha
eye-dropper
hint="Stored as #RRGGBBAA."
label="Hero overlay"
name="hero-overlay"
persistent-hint
swatches
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbColorInput } from 'bitboss-ui';
const SWATCH_COLUMNS: string[][] = [
['#7f1d1d', '#b91c1c', '#ef4444'],
['#78350f', '#b45309', '#f59e0b'],
['#14532d', '#15803d', '#22c55e'],
['#1e3a8a', '#1d4ed8', '#3b82f6'],
['#4c1d95', '#6d28d9', '#8b5cf6'],
];
const brand = ref<string | null>('#4f46e5');
const labelColor = ref<string | null>('#b91c1c');
const overlay = ref<string | null>('#1e3a8a99');
</script>
swatchestakestrueorstring[][]; each inner array is a column.alphachanges both the picker and model to#RRGGBBAA. Without it, an eight-digit input cannot round-trip.eye-dropperis progressive enhancement. Its button is absent in Safari and Firefox, with nothing logged (test'EyeDropper' in windowif your UI must say so), and sampled colours are opaque.
Coming from v2picker → eye-dropper
picker became eye-dropper. The old attribute falls through to $attrs and has no effect; a development build warns about the rename, and npx bitboss-ui check --fix rewrites it.
Validation and locked values
Validate presence and business rules, not hex syntax: the mask already owns the format. Save the theme with no brand colour to see the rule.
<template>
<BbForm
class="flex max-w-md flex-col gap-4 rounded-(--bb-radius) border border-(--bb-border) p-5"
@invalid="status = 'Pick a brand color to save the theme.'"
@submit="save"
>
<h3 class="m-0 text-base font-semibold">Workspace theme</h3>
<!--
Presence is the only rule left to write: the mask already owns the hex
format, so a "not a color" rule could never fire.
-->
<BbColorInput
id="chrome-brand"
clearable
description="Used for primary buttons and links across the app."
label="Brand color"
name="brand"
rules="required"
swatches
/>
<!-- readonly: readable, focusable, copyable, and the picker never opens. -->
<BbColorInput
id="chrome-imported"
label="Imported from the old theme"
:model-value="importedColor"
name="imported"
readonly
/>
<!-- disabled: out of the tab order, picker blocked. -->
<BbColorInput
id="chrome-managed"
disabled
hint="Managed by your organization."
label="Accent color"
:model-value="managedColor"
name="accent"
persistent-hint
/>
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="m-0 text-sm opacity-70" role="status">{{ status }}</p>
<BbButton type="submit" variant="primary">
Save theme
</BbButton>
</div>
</BbForm>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { defineRule } from 'vee-validate';
import { required } from '@vee-validate/rules';
import { BbButton, BbColorInput, BbForm } from 'bitboss-ui';
import { delay } from '~/demo-data';
// In an app this lives once in a validators module, not in the component.
defineRule('required', (value: unknown) => required(value) || 'Required.');
const importedColor = '#0e8a16';
const managedColor = '#f59e0b';
const status = ref('');
const save = async (values: { brand?: string | null }) => {
status.value = 'Saving…';
await delay(null, 600);
status.value = `Saved ${values.brand}.`;
};
</script>
Inside a BbForm, rules="required" flags an empty field when you leave it or submit; outside a form, pass required for the asterisk and errors yourself. A colour that only drives a preview, and is not saved with the form, takes formless.
readonly keeps an imported colour focusable and copyable; disabled removes it from the tab order. Both block the picker. clearable emits null.
variant="none" drops every colour, focus ring included, for a field you style yourself, like the flat search field on BbTextInput.
Application-owned persistence
Picker drags emit on every frame. Repaint a preview directly from the model, but debounce remote persistence or save on blur or form submit.
Brand
Applied to buttons and links in your customer portal.
All changes saved.
<template>
<section
class="flex max-w-md flex-col gap-4 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-5"
>
<header class="flex flex-col gap-1">
<h3 class="m-0 text-base font-semibold">Brand</h3>
<p class="m-0 text-sm opacity-70">
Applied to buttons and links in your customer portal.
</p>
</header>
<!--
The preview repaints from the model on every drag frame. It shows the
customer portal, which is light whatever the docs theme, so it has a
fixed surface of its own.
-->
<div
class="flex items-center gap-4 rounded-[var(--bb-radius)] border border-(--bb-border) bg-white p-3 text-sm text-zinc-900"
>
<span
class="rounded-[var(--bb-radius)] px-3 py-1.5 font-medium"
:style="{ backgroundColor: brand ?? '#e4e4e7', color: textOn(brand) }"
>
Book a demo
</span>
<span class="underline" :style="{ color: brand ?? undefined }">View pricing</span>
</div>
<BbColorInput
id="brand-color"
v-model="brand"
label="Brand color"
name="brand-color"
placeholder="#RRGGBB"
swatches
/>
<p class="m-0 text-sm opacity-70" role="status">{{ status }}</p>
</section>
</template>
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from 'vue';
import { BbColorInput } from 'bitboss-ui';
const brand = ref<string | null>('#4f46e5');
const status = ref('All changes saved.');
// Drags emit every frame: repaint at once, persist only once the value settles.
let saveTimer: ReturnType<typeof setTimeout> | undefined;
watch(brand, (value) => {
status.value = 'Saving…';
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
status.value = value ? `Saved ${value}.` : 'Brand color removed.';
}, 800);
});
onBeforeUnmount(() => clearTimeout(saveTimer));
const textOn = (hex: string | null) => {
if (hex === null) return '#111827';
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
return 0.299 * r! + 0.587 * g! + 0.114 * b! > 150 ? '#111827' : '#ffffff';
};
</script>
Do not parse the forwarded input event: it can contain partial text. The model is the validated channel.
Settings rows and the mobile sheet
In a settings column, compact rows with the same layout ratio line the swatches up (Variants, density and layout explains both). With adaptive (the default), only the picker becomes a bottom sheet below config.mobileMaxWidth; the text field does not change.
<template>
<!--
A settings column: label on the left, swatch and hex on the right, the same
ratio on every row. A per-breakpoint map stacks it below `md`, where the
picker also opens as a bottom sheet on a phone.
-->
<div class="flex max-w-md flex-col gap-2">
<BbColorInput
id="layout-primary"
v-model="primary"
compact
:layout="rowLayout"
label="Primary"
name="token-primary"
swatches
/>
<BbColorInput
id="layout-danger"
v-model="danger"
compact
:layout="rowLayout"
label="Danger"
name="token-danger"
swatches
/>
<BbColorInput
id="layout-warn"
v-model="warn"
compact
:layout="rowLayout"
label="Warning"
name="token-warn"
swatches
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbColorInput } from 'bitboss-ui';
const rowLayout = { default: 'vertical', md: 'xx xxxxx' } as const;
const primary = ref<string | null>('#4f46e5');
const danger = ref<string | null>('#dc2626');
const warn = ref<string | null>('#f59e0b');
</script>
Below the same breakpoint the rows switch to vertical: layout takes a per-breakpoint map, { default: 'vertical', md: 'xx xxxxx' }, with no screen-size code of yours. The surface is chosen when it opens. Use off-canvas-props for sheet controls and pt:sheet for sheet-only geometry.