Color Palette

CIGitHub licenseMaintenancenpm versionnpm downloadsGitHub Repo stars

Click a trigger, pick a swatch from a floating panel, get the value back


Vanilla TypeScript, one runtime dependency for the positioning. It works standalone, with no CSS framework at all, and ships Bootstrap 4 and Bootstrap 5 themes for applications that have one.

An LLM-optimized documentation is included for your AI assistants.

Any contribution or feedback is highly welcomed, please feel free to create a pull request or submit a new issue.

Live demo

One page per theme, each built by the package itself, so what you see is the stylesheet you would install:

  • Standalone — no CSS framework on the page at all
  • Bootstrap 5 — the panel reading Bootstrap 5's popover variables
  • Bootstrap 4 — the same, against Bootstrap 4

All three carry the same four examples, each printed next to the markup and script that produce it.

Picking a swatch from the grid, and the value, label and dark flag coming back

Highlights

  • Standalone, or native-looking. A theme is a set of Sass values, not a set of rules. Recompile a Bootstrap theme against your variables and the picker follows.
  • Accessible on purpose. A real grid of buttons, ARIA roles, a roving tabindex, full arrow / Home / End navigation, focus handed back to the trigger on close.
  • Declarative or programmatic. data-color-palette plus data-cp-* attributes, or instances built in code. Same component either way.
  • Escaping is structural. The panel is built as DOM nodes and text is set with textContent. There is no escaping option to forget.
  • Typed end to end. Strict TypeScript, typed options, typed event payloads.

Installation

Evergreen browsers, ES2020, no polyfills, screens from 360px up. Building from source needs Node 20.19+.

npm install @bgaze/color-palette

Or from a CDN, with no build step at all:

<link rel="stylesheet" href="https://unpkg.com/@bgaze/color-palette/dist/css/color-palette.css" />
<script src="https://unpkg.com/@bgaze/color-palette"></script>

The bundle may sit in the <head>: it waits for DOMContentLoaded.

Entry points

Subpath What it is
@bgaze/color-palette the class, the types, and automatic initialization (ESM and CJS). Also exports CLASSES, the frozen class-name set, and TRIGGER_SELECTOR
@bgaze/color-palette/i18n/en, /i18n/fr a bundled palette, as a side-effect-free data module
@bgaze/color-palette/css/standalone the compiled standalone stylesheet
@bgaze/color-palette/css/bootstrap4, /css/bootstrap5 the compiled Bootstrap themes
@bgaze/color-palette/scss/* the Sass sources, to recompile a theme yourself — see Themes

Each compiled stylesheet also ships minified next to it (….min.css).

The entry you pick changes one thing: how a palette reaches the component. The ES module bundles none, so you import one and pass it in; the UMD bundle carries both on ColorPalette.palettes. Options, API, events and markup are identical.

Quick start

Three ways in, from the most to the least JavaScript. All produce the same component.

With a bundler

import { ColorPalette } from '@bgaze/color-palette'
import { en } from '@bgaze/color-palette/i18n/en'
import '@bgaze/color-palette/css/standalone'

// Before the module's own automatic initialization, which is deferred by one microtask.
ColorPalette.setDefaults({ colors: en })

const palette = new ColorPalette(document.querySelector('#trigger')!)
palette.value = '#4a86e8'

A default palette is the whole configuration most applications need: every trigger on the page starts from it.

From a script tag, with no bundler

<link rel="stylesheet" href="https://unpkg.com/@bgaze/color-palette/dist/css/color-palette.css" />
<script src="https://unpkg.com/@bgaze/color-palette"></script>
<script>
  ColorPalette.setDefaults({ colors: ColorPalette.palettes.en })
</script>

<button type="button" data-color-palette data-color="#4a86e8">Colour</button>

Nothing to import: the UMD bundle carries en and fr on ColorPalette.palettes.

Without a line of JavaScript

Once a default palette is set, a trigger is markup and nothing else:

<button
  type="button"
  data-color-palette
  data-color="#4a86e8"
  data-cp-title="Colours"
  data-cp-placement="right"
  data-cp-close-on-select="false"
>
  Colour
</button>

The selection lives in data-color on the trigger, so a form can submit it and a stylesheet can react to it with no script of your own.

Automatic initialization

Every element matching [data-color-palette] gets an instance, one microtask after the module is evaluated — which is what lets ColorPalette.setDefaults({ colors }) sit right after the import and still be seen.

  • No palette configured → one warning, nothing initialized. A declarative trigger must not take the page down. Explicit construction with an empty palette throws.
  • Turn it off with <html data-cp-autoinit="off">, then instantiate by hand.
  • Run it yourself over injected markup with ColorPalette.autoInit(root). It skips elements that already have an instance.

Options in markup

Attribute Option Notes
data-color (the value) the selection itself; wins over the value option
data-cp-placement placement auto | top | bottom | left | right
data-cp-offset offset a number
data-cp-title title any text, empty included
data-cp-label label the grid's accessible name
data-cp-close-on-select closeOnSelect false, 0, no, off mean no; anything else, including an empty value, means yes

An unreadable value (data-cp-offset="a bit") is ignored with a warning, never guessed.

colors, container and value have no attribute: a palette does not fit in one, a container is resolved by the caller, and the value already has data-color.

Precedence

Least specific first: shipped defaults → data-cp-* attributes → constructor options. Automatic initialization passes no options, so markup rules there. One exception runs the other way: data-color beats the value option, because it carries state rather than configuration.

setDefaults(partial) merges key by key and copies the palette in and out, so mutating the array you handed it does not reach the store. resetDefaults() restores the shipped baseline.

What the markup becomes

The panel is built as DOM nodes and appended to document.body — which is why the open state lives on the trigger, the only element that stays put.

<!-- what you write -->
<button
  type="button"
  data-color-palette
  data-color="#000000"
  data-cp-title="Colours"
>
  Colour
</button>
<!-- the trigger, while the panel is open -->
<button
  type="button"
  data-color-palette
  data-color="#000000"
  data-cp-title="Colours"
  class="cp--open"
  aria-haspopup="dialog"
  aria-expanded="true"
  aria-controls="cp-panel-1"
>
  Colour
</button>
<!-- the panel, at the end of <body> -->
<div id="cp-panel-1" class="cp__panel cp__panel--bottom"
     role="dialog" aria-labelledby="cp-panel-1-title">
  <h2 id="cp-panel-1-title" class="cp__header">Colours</h2>
  <div class="cp__grid" role="grid" aria-label="Color palette">
    <div class="cp__row" role="row">
      <button type="button"
              class="cp__swatch cp__swatch--dark cp__swatch--selected"
              data-color="#000000" style="background-color: #000000;"
              role="gridcell" title="Black" aria-label="Black"
              tabindex="0" aria-selected="true"></button>
      <button type="button" class="cp__swatch cp__swatch--light"
              data-color="#ffffff" style="background-color: #ffffff;"
              role="gridcell" title="White" aria-label="White"
              tabindex="-1" aria-selected="false"></button>
    </div>
  </div>
  <div class="cp__arrow" aria-hidden="true"></div>
</div>

Every class above is frozen public API, and every swatch carries its normalized colour in data-color — readable from CSS with no JavaScript. destroy() gives back only what the instance added.

Palettes

A palette is plain data: no class, no builder, no registration. It is the only option with no default.

Shape

type PaletteRow = Record<string, string> // hexadecimal colour -> label
type Palette = PaletteRow[]              // one entry per rendered row

One entry is one row, and display order is key insertion order.

const palette: Palette = [
  { '#000000': 'Black', '#ffffff': 'White' },
  { '#ff0000': 'Red', '#00ff00': 'Green', '#0000ff': 'Blue' },
]

Rows may have different lengths; navigation onto a shorter row is clamped to its last swatch.

Bundled palettes

en and fr, two side-effect-free data modules:

import { en } from '@bgaze/color-palette/i18n/en'
import { fr } from '@bgaze/color-palette/i18n/fr'

Both carry eight rows of ten colours — greys, saturated hues, then three lighter tints and three darker shades. Same colours, same order; only the labels differ.

From a script tag they are already on ColorPalette.palettes.en and .fr.

Accepted colour forms

Only #rgb and #rrggbb, any case, surrounding whitespace tolerated. Everything is canonicalized to lowercase #rrggbb.

Input Result
'#abc' '#aabbcc'
'#AABBCC', ' #aabbcc ' '#aabbcc'
null, undefined, '' null — no value
'tomato', 'rgb(0,0,0)', '#zzz', '#aabbccdd' refused

No alpha channel: the component handles opaque colours. Anything else is refused with a warning rather than stored as given, which would come back with label and dark both null.

A malformed palette key is dropped and reported, the rest kept. A row emptied by dropped keys disappears; your array is never mutated.

Dark or light

Every selection and every swatch answers one question: does white contrast more than black on this colour? The themes use it for the check mark; selection.dark exposes it for your own trigger.

The rule is the WCAG 2.2 relative luminance against 0.179129, the luminance where contrast with white equals contrast with black:

c  = channel / 255
c' = c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ^ 2.4
L  = 0.2126·r' + 0.7152·g' + 0.0722·b'

L < 0.179129  ⇒  dark

Contrast decides, not hue: #757575 is dark, #767676 is light, and #ff0000 and #ff00ff are light so they take a black check mark. None of the eighty bundled colours carries a check mark below 4.5:1.

Writing your own

Any number of rows, any number of colours, any labels.

ColorPalette.setDefaults({
  colors: [
    { '#1d3557': 'Marine', '#457b9d': 'Steel', '#a8dadc': 'Mist' },
    { '#f1faee': 'Bone', '#e63946': 'Signal' },
  ],
})

An empty label is fine: the swatch then announces its colour, so it is never unnamed. Labels reach the DOM through textContent only, so a label containing markup stays text.

Row width is what a screen has to carry. A row never wraps, so the widest row sets the panel's width. Ten fits the 360px screen the component is built for; wider needs a wider screen — see narrow viewports.

A palette handed to an instance stays live. Mutate it in place, call update(), and an open panel re-renders. The defaults store is the exception: it copies both ways.

Themes

The theme is the stylesheet you import. There is no theme option, no framework detection, and no framework class is ever emitted.

The three themes

Import Produces For
@bgaze/color-palette/css/standalone dist/css/color-palette.css any page, no CSS framework needed
@bgaze/color-palette/css/bootstrap4 dist/css/color-palette-bootstrap4.css a Bootstrap 4 application
@bgaze/color-palette/css/bootstrap5 dist/css/color-palette-bootstrap5.css a Bootstrap 5 application

The standalone sheet needs nothing on the page and is the default answer.

The Bootstrap sheets read Bootstrap's own popover, colour and typography variables, so the panel looks native. They only work on a page that loads Bootstrap.

The grid stays the component's own in every theme. Compare the three demos: the panel changes, never the palette.

Using a theme

Take the compiled sheet as it is — one import, or one <link> from a CDN:

import '@bgaze/color-palette/css/bootstrap5'

Or recompile it against your own variables. This is the point of the Bootstrap themes: they reference Bootstrap under its canonical name, which is what your tree already has, so your overrides propagate through the picker.

// your own Bootstrap overrides first
$primary: #6f42c1;
$popover-bg: #fdfdfd;

@import '@bgaze/color-palette/src/themes/bootstrap5';

A prebuilt sheet is a snapshot of Bootstrap's defaults. An application that customizes Bootstrap at all should take the second path.

Bootstrap 4 and 5 have no module entry point, so the themes consume them with @import — the only documented way. Your build will report the matching Sass deprecations; they are Bootstrap's.

Recompiling the standalone theme

Same thing with no framework. Set any $cp-* variable, then import the base:

$cp-swatch-size: 20px;
$cp-swatch-spacing: 4px;
$cp-panel-border-radius: 0;

@import '@bgaze/color-palette/src/themes/base';
.cp__panel {
  /* … */
  padding: 0.5rem;
  border: 1px solid rgba(0, 0, 0, 0.15);
  border-radius: 0;
}

.cp__swatch {
  /* … */
  width: 20px;
  height: 20px;
  margin: 4px;
  border-radius: 50%;
  transition: all 0.15s ease-in-out;
}

.cp__swatch:hover,
.cp__swatch:focus-visible,
.cp__swatch.cp__swatch--selected {
  transform: scale(1.5);
}

That form needs node_modules on the Sass load path, which bundlers configure. With Sass's package importer enabled, the published subpath is shorter:

@import 'pkg:@bgaze/color-palette/scss/base';

Either form reports Sass's own @import deprecation — that is the rule an overridable-defaults stylesheet is built on.

The class contract

Frozen public API. Style them, target them, expect them to stay exactly these.

Class On
cp--open the trigger, while its panel is open
cp__panel the floating panel (role="dialog")
cp__panel--top / --bottom / --left / --right the panel, re-pointed each time positioning settles
cp__arrow the pointer at the trigger, inside the panel (aria-hidden)
cp__header the title, rendered only when one is configured
cp__grid the grid (role="grid")
cp__row a row (role="row")
cp__swatch a swatch — a real <button> (role="gridcell")
cp__swatch--dark / --light which foreground contrasts more on that swatch's colour
cp__swatch--selected the current selection

Every swatch carries its normalized colour in data-color, and so does the trigger.

The variables

One base stylesheet carries every rule and reads nothing but $cp-* — a literal colour or size in it would be a defect, and three tests hold that line. Forty-one variables, all !default.

Panel

Variable Default
$cp-panel-bg #fff
$cp-panel-color #212529
$cp-panel-border-width 1px
$cp-panel-border-color rgba(0, 0, 0, 0.15)
$cp-panel-border-radius 0.375rem
$cp-panel-padding 0.5rem
$cp-panel-shadow 0 0.5rem 1rem rgba(0, 0, 0, 0.15)
$cp-panel-z-index 1070
$cp-panel-font-family inherit
$cp-panel-font-size 0.875rem

Arrow

Variable Default
$cp-arrow-size 0.75rem
$cp-arrow-offset derivedsize / 2
$cp-arrow-bg $cp-panel-bg
$cp-arrow-border-width $cp-panel-border-width
$cp-arrow-border-color $cp-panel-border-color

A square rotated onto its corner, so it protrudes by half its diagonal: 0.75rem sticks out 8.5px, which the default offset of 8 leaves room for. A smaller offset tucks the arrow under the trigger.

The Bootstrap themes map these onto $popover-arrow-height, $popover-arrow-color and $popover-arrow-outer-color, so the arrow matches the framework's own popover.

Header

Variable Default
$cp-header-color inherit
$cp-header-font-size 1rem
$cp-header-font-weight 500
$cp-header-padding-bottom 0.375rem
$cp-header-margin-bottom 0.375rem
$cp-header-border-width 1px
$cp-header-border-color rgba(0, 0, 0, 0.1)

Swatches

Variable Default
$cp-swatch-size 24px
$cp-swatch-spacing 5px
$cp-swatch-size-active 30px
$cp-swatch-scale-active derivedsize-active / size
$cp-swatch-radius 50%
$cp-swatch-border-width 1px
$cp-swatch-border-color rgba(0, 0, 0, 0.2)
$cp-swatch-transition all 0.15s ease-in-out

A swatch grows on hover, on keyboard focus and when selected — by a transform, so its cell never changes size and nothing around it moves. Override $cp-swatch-scale-active only to break that link with $cp-swatch-size-active. Motion is dropped under prefers-reduced-motion: reduce.

Check mark and focus

Variable Default
$cp-check-content '✓'
$cp-check-font-size 16px
$cp-check-color-on-dark #fff
$cp-check-color-on-light #000
$cp-focus-ring-width 0.25rem
$cp-focus-ring-color rgba(13, 110, 253, 0.25)
$cp-focus-ring-offset 0

The two contrast modifiers place the check mark, so it stays legible with no runtime computation in CSS — see dark or light. The focus ring shows on :focus-visible only.

Narrow viewports

Variable Default
$cp-narrow-breakpoint 400px
$cp-narrow-max-width derivedbreakpoint - 0.02px
$cp-panel-padding-narrow 0.25rem
$cp-swatch-spacing-narrow 4px

360px is the narrowest screen supported, and at the usual spacing a ten-column palette does not fit: 358px on the shipped theme, 374px under Bootstrap 5, against 344px available. The engine can slide a panel but never shrink it.

Below $cp-narrow-breakpoint the space around the swatches gives way — panel padding and the gap between swatches — bringing all three themes to 330px. The grid itself is untouched.

A wider palette needs a wider screen.

Upgrading from 1.0

One theme variable is gone, and it is the only breaking change. $cp-swatch-spacing-active no longer exists, replaced by $cp-swatch-scale-active; an override of the old name has no effect. Everything else is unchanged, with cp__arrow and the four narrow-viewport variables added.

Reference

Options

Option Type Default Notes
colors Palette [] required — see Palettes
value string | null null initial colour; data-color on the element wins
placement Placement 'auto' preferred side, flipped and shifted as the viewport requires
offset number 8 pixels between trigger and panel
container HTMLElement | string | null null element or selector; defaults to document.body. A selector matching nothing warns and falls back
title string | null null panel header; none is rendered when null
label string 'Color palette' accessible name of the grid
closeOnSelect boolean true whether picking closes the panel

All but colors, container and value can be set per element — see options in markup.

Statics

Member Returns Notes
getInstance(el) ColorPalette | null the instance already attached to an element
getOrCreateInstance(el, options?) ColorPalette at most one instance per element
setDefaults(partial) void merges into the defaults later instances start from
resetDefaults() void restores the shipped baseline
autoInit(root?) ColorPalette[] attaches to every unclaimed trigger under root (default document)
palettes Record<string, Palette> empty in the module builds; en and fr in the UMD bundle

Instance

Member Type Notes
element HTMLElement the trigger
selection Selection { value, label, dark }, read-only
value string | null get and set
show() / hide() / toggle() void positioning is async internally; these return immediately
update() void re-renders an open panel from current state
destroy() void removes the panel, every listener, the attributes it added and the registration

update() takes no arguments: reconfiguring an instance is destroy() plus a new one, so there is exactly one way to do it. Call it after mutating the palette in place, or after writing data-color onto the element.

destroy() leaves data-color alone — the selection is the page's data, not the component's.

The selection

interface Selection {
  value: string | null // normalized `#rrggbb`
  label: string | null // from the palette, or null when the colour is not in it
  dark: boolean | null // true when a white foreground contrasts more than a black one
}

A value is only ever stored normalized, so dark is a boolean whenever value is not nullselection.dark ? '#fff' : '#000' never silently breaks.

Writing value: an equal value emits nothing, an invalid one is refused with a warning, null clears the selection and removes data-color.

The panel

Positioning comes from @floating-ui/dom: offset for the gap, autoPlacement or flip depending on placement, shift to stay inside the viewport, then arrow so the pointer follows a shifted panel. autoUpdate runs only while the panel is open, so a closed component costs nothing on scroll or resize.

It closes on a click outside itself and the trigger, on Escape, and on picking when closeOnSelect is on. A click inside the panel that is not on a swatch does not close it. Outside-click dismissal arms one task after opening, so calling show() from your own click handler does not open and shut in the same gesture.

Warnings

The core is pure and reports what it refused; the class is the only layer that writes to the console, always prefixed [@bgaze/color-palette].

Situation What happens
palette entries that are not hexadecimal colours warned, those entries dropped, the rest kept
empty palette, explicit construction throws
empty palette, automatic initialization one warning, nothing initialized
unreadable data-cp-* value warned, that attribute ignored
container selector matching nothing warned, falls back to document.body
value set to something that is not a colour warned, selection unchanged, no event

Events

Bubbling CustomEvents on the trigger, with a typed detail. They are not cancelable: the state machine has already moved.

Event detail When
color-palette:ready { selection } one microtask after construction, so a listener attached right after it still sees it
color-palette:change { selection, previous } on an effective change only
color-palette:show {} before the panel enters the document
color-palette:shown {} once positioned and visible
color-palette:hide {} before it leaves
color-palette:hidden {} once gone
trigger.addEventListener('color-palette:change', (event) => {
  console.log(event.detail.selection.value, event.detail.previous.value)
})

The element event map is augmented, so event.detail is typed without a cast. And since the events bubble, one listener on a container handles every trigger inside it.

Accessibility

Not a trade-off: a change that regresses anything below is a bug.

Roles and names

Element Role Name
trigger its own, plus aria-haspopup="dialog" and aria-expanded tracking the real state yours
panel dialog aria-labelledby its header when a title is configured, aria-label from the label option otherwise
arrow none — decorative, aria-hidden="true"
grid grid aria-label from the label option
row row
swatch gridcell on a real <button type="button">, with aria-selected the palette label, or the colour itself when the label is empty

While open, the trigger also carries aria-controls and the cp--open class; both go away on close. The panel is never modal: no aria-modal, no focus trap.

Keyboard

Key On the trigger In the grid
Enter / Space opens picks the focused swatch
Escape closes and returns focus to the trigger
/ previous / next swatch within the row, stopping at its ends
/ same column in the row above / below, stopping at the grid's edges
Home / End first / last swatch of the row
Ctrl+Home / Cmd+Home, and the same with End first / last swatch of the whole grid
Tab moves on normally leaves the grid — never intercepted

Arrows do not wrap: the ARIA grid pattern stops at an edge rather than teleporting. Moving onto a shorter row lands on its last swatch.

Focus

  • Opening focuses the selected swatch, or the first one when there is no selection.
  • A roving tabindex keeps exactly one swatch tabbable, so Tab enters and leaves the grid in one step instead of walking eighty colours. It follows the action: when the panel stays open after a pick, the swatch just chosen becomes the tabbable one.
  • Closing returns focus to the trigger whenever the panel held it — on Escape, on picking, on hide(). Focus is never stranded. When the panel did not hold focus, closing leaves it where it was.
  • The focus ring shows on :focus-visible only.

Triggers that are not buttons

A <button> needs nothing. Anything else — a <div>, a <span>, an <a> without href — gets what it lacks rather than being left unreachable:

  • tabindex="0" and role="button", each only if absent;
  • a keydown handler turning Enter and Space into a toggle, with the default suppressed so Space does not scroll and Enter does not submit a surrounding form.

destroy() gives back only what that instance added: an existing role or tabindex is left as the page set it.

AI coding assistants

The repository ships a second documentation written for an LLM: dense, exhaustive, pinned by the test suite rather than by prose, and moving in the same commit as the behaviour it describes.

It is not published to npm — the tarball carries the build and the Sass sources only — so an assistant reads it from GitHub:

Wire it in once. One line in the instructions file your tool reads at startup — CLAUDE.md, AGENTS.md, .cursor/rules/, .github/copilot-instructions.md:

When using @bgaze/color-palette, read
https://github.com/bgaze/color-palette/blob/main/docs/llm/index.md first.

The payoff is the mistake the guide opens on: which entry point is in use decides how a palette reaches the component. Guess wrong and the symptom is one warning and a page where nothing initialized.

It is reference material, terse where this page explains. If you are reading rather than prompting, stay here.

Support

Any contribution or feedback is highly welcomed. Please feel free to create a pull request or submit a new issue.

The source lives at github.com/bgaze/color-palette. Every branch and pull request goes through format, lint, typecheck, unit tests, the build and the end-to-end suite — Chromium, Firefox and WebKit — on Node 20, 22 and 24.

Open-sourced software licensed under the MIT license.

Other packages

Feel free to visit my other packages:

bgaze/bootstrap-form

Bootstrap 4 & 5 forms builder for Laravel 12+

This package simplifies Bootstrap 4 & 5 forms creation in Laravel applications 12+
It renders Bootstrap 5 markup by default and fully supports Bootstrap 4 for backward compatibility.
Model form binding and automatic error display are supported, as well as most Bootstrap form features: form layouts, custom fields, input groups, and more.

Github Documentation

bgaze/laravel-kvstore

Key-value store for Laravel 12 & 13

A database-backed key-value store for Laravel 12 & 13, sized for settings: read on most requests, written rarely.
The whole store is cached as one single entry, so a cold read costs one query for every key at once.
Each entry carries its own cast, so a value always reads back as what it was written as.

Github Documentation

SnapStack

100% local browser captures for your AI assistant

SnapStack is a browser extension that captures any tab in one click and stacks it locally, so your AI assistant can read the screenshots on demand over MCP.
Nothing is ever uploaded: captures go only to a small server on your own machine. No account, no telemetry.
Works with any MCP-capable client (Claude Code and others), on Chrome, Edge and Firefox.

Github Documentation