ldc-color-picker
v0.6.0
Published
A dependency-free React color picker: 12-swatch palette, HSV surface with HEX/RGB/HSL inputs, and pluggable saved colors. Themes off Ionic when it's there, stands alone when it isn't.
Downloads
367
Maintainers
Readme
ldc-color-picker
A React color picker with no dependencies but React: a 12-swatch palette, an HSV surface with HEX/RGB/HSL inputs, and saved colors you can persist wherever you like.
It themes off Ionic when Ionic is there, and stands alone when it isn't.
Extracted from notant, where it shipped as
NotantColorPickerin 0.6.0.
Install
npm install ldc-color-pickerUse
import { useState } from 'react';
import { ColorPicker } from 'ldc-color-picker';
import 'ldc-color-picker/styles.css';
function PenTool() {
const [color, setColor] = useState('#e5484d');
return <ColorPicker value={color} onChange={setColor} label="Pen color" />;
}Styling, in four layers
The styles arrive with the component. Since 0.6.0 there is nothing to remember:
import { ColorPicker } from 'ldc-color-picker'; // that is allThe stylesheet is injected on first mount, as the first stylesheet in
<head> — which is the point. Every rule in it therefore loses a specificity
tie to anything your app writes later, so your CSS overrides it exactly as it
did when the import was manual.
ldc-color-picker/styles.css still exists and still works. Import it when you
want the CSS in your own pipeline, in a <link>, or ahead of first paint —
the injected copy loses to it, being earlier. And opt out of the built-in look
entirely with:
<html data-ldc-color-picker="no-styles">| layer | scope | beats |
| --- | --- | --- |
| props | one instance — written inline | everything |
| your CSS | your app | the stylesheet (single-class selectors, 0,1,0) |
| --ldc-* variables | one origin | read by the stylesheet |
| the stylesheet | the default look | — |
A prop you do not pass writes no declaration at all. That is the rule the whole arrangement rests on: an inline value is one your own CSS can never override again, so options stay silent until asked for. Reach for variables to theme an app, and props only where one call site genuinely differs.
Options
| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| value | string | — | Current color. Any hex form; compared after normalizing. |
| onChange | (value: string) => void | — | Called with a normalized #rrggbb. |
| swatches | readonly string[] | 12 built-ins | Replaces the palette. |
| label | string | 'Color' | Accessible name. Say what it colors. |
| disabled | boolean | false | Grays out the trigger. |
| allowCustom | boolean | true | false hides the HSV surface, leaving only swatches. |
| favorites | FavoritesStore \| false | localStorage | Where saved colors live. false drops the feature. |
| scope | string | unset | Namespace for the default store. Omit and every picker on the origin shares one list. |
| side | 'top' \| 'bottom' \| 'left' \| 'right' | 'top' | Preferred side. Flips when there is no room. |
| align | 'start' \| 'center' \| 'end' | 'center' | Alignment along the cross axis. |
| border | string \| BorderOptions | 1px hairline | The trigger's border. A shorthand, or { width, style, color } — the same type panel.border takes. Unlike the panel it has one already, so this overrides; { style: 'none' } removes it. |
| className | string | — | Extra class on the trigger. |
| panel | PanelOptions | unset | How the panel looks — see below. Every field optional; every one omitted leaves that rule with the stylesheet. |
| open | boolean | unset | Controls whether the panel is showing. Omit it and the picker decides, as before. Passing it also switches the panel from popover="auto" to popover="manual", so the browser stops dismissing it and you do — see below. |
| onOpenChange | (open: boolean) => void | — | The picker asking to be opened or closed. Fires in both modes. |
Styling the panel
Until now the panel's box was fixed in the stylesheet and its colors reachable
only through the --ldc-* chain — which is per-origin, so two pickers on one
page could not look different. panel is per-instance.
| Field | Type | Default | |
| --- | --- | --- | --- |
| width | number \| string | --ldc-color-panel-width, 236px | Beats the custom property; an inline declaration always does. |
| padding | 'sm' \| 'md' \| 'lg' \| number \| string | 10px | Presets are 6 / 10 / 16, so md is the default unchanged. |
| radius | 'sm' \| 'md' \| 'lg' \| number \| string | 12px | Presets are 6 / 12 / 20. |
| border | string \| PanelBorderOptions | none — border: 0 | A shorthand, or { width, style, color }. See below. The shadow is what separates the panel from the page; turn the shadow off and it has nothing. |
| background | string | --ldc-color-surface | |
| color | string | --ldc-color-ink | |
| shadow | 'sm' \| 'md' \| 'lg' \| string \| false | md | false writes box-shadow: none. |
| maxHeight | number \| string | min(360px, 78vh) | Expanding Custom nearly doubles the panel; the cap is why it does not run off a short window. |
| backdrop | string | transparent | Only paints while the panel is an auto popover — a controlled one is manual and gets no backdrop. |
| gap | 'sm' \| 'md' \| 'lg' \| number | 8 | Space between trigger and panel. Presets are 4 / 8 / 16, so md is the default unchanged. |
| viewportMargin | 'sm' \| 'md' \| 'lg' \| number | 8 | How close the panel may get to the viewport edge. Same presets. |
<ColorPicker
value={color}
onChange={setColor}
panel={{ radius: 'lg', shadow: false, border: '1px solid #e4e4e7', padding: 14 }}
/>The hex field's typography
hexField could always express its geometry from props — width, radius
— and never its typography. Matching an existing field meant writing CSS
against .ldc-color-hex internals, which is not an API.
| Field | Type | Default |
| --- | --- | --- |
| padding | 'sm' \| 'md' \| 'lg' \| number \| string | 7px 9px, which is md. A number is every side; a string is verbatim, so .5rem .75rem works. |
| fontSize | number \| string | 13px |
| fontFamily | string | inherit. monospace is the one worth knowing about — proportional digits make #ffffff and #000000 different widths, so a column of fields never lines up. |
<ColorPicker
value={color}
onChange={setColor}
hexField={{ editable: true, fontFamily: 'monospace', fontSize: 14, padding: '.5rem .75rem' }}
/>Padding reaches the readout through a custom property rather than an inline
style, so the copy button's reserved space survives it. An inline padding
shorthand would beat that rule and the value would run underneath the button.
The border, in parts
Both border props — the trigger's and panel.border — take a CSS shorthand,
or the three pieces separately, using one shared BorderOptions type:
| Field | Type | Default once any part is set |
| --- | --- | --- |
| width | 'sm' \| 'md' \| 'lg' \| number \| string | 1px. Presets are 1 / 2 / 4. |
| style | 'solid' \| 'dashed' \| 'dotted' \| 'double' \| 'none' | solid. double needs 3px+ to read as two lines. |
| color | string | the same hairline the swatches use, so it themes with them |
| position | 'inside' \| 'outside' | inside |
position is box-sizing, and there is no center. CSS has no
border-alignment property — CSS.supports('border-alignment', …) is false, as
is border-position. A border always sits in the same place in the box model;
what position changes is whether the declared size includes it. inside
(border-box) is what both halves do today: a 4px border eats inward and
neither element grows, so thickening one never moves your layout. outside
(content-box) makes the declared size the content size and the element grows
by the border on each side. A true center would need the border drawn as an
outline with a negative outline-offset, and outline is the focus ring on
the trigger and every swatch.
The two differ only in where they start. The panel has no border; the
trigger has a 1px hairline — thin on purpose, so a white swatch stays visible
without a ring competing with the colour it contains. { style: 'none' } is how
that hairline comes off, and the reason none is in the union at all.
<ColorPicker value={color} onChange={setColor} panel={{ border: { style: 'dashed' } }} />
// -> 1px dashed <hairline>The parts exist because in practice you change one of them — thicker, or dashed
— and a shorthand makes you restate the other two to do it. That is how a
border's colour quietly stops matching the theme. An empty object means the
same as no border at all: nothing is written, and the stylesheet's border: 0
stands.
Omitting a field is not the same as passing its default. Nothing is written
inline for a field you leave out, so the stylesheet keeps that rule and a host's
own CSS can still override it. Pass shadow: 'md' and you get the same shadow —
but now as an inline declaration that host CSS can no longer beat.
Holding the panel open
open is not a hint the browser can overrule. An uncontrolled panel is a native
popover="auto", and light dismiss is the platform's — the next click anywhere
outside closes it no matter what React thinks. So passing open switches the
panel to popover="manual", which hands dismissal to you:
// Stays up until you say otherwise. Escape, an outside click and picking a
// color all still ask, through onOpenChange; nothing happens unless you act.
<ColorPicker value={color} onChange={setColor} open onOpenChange={() => {}} />The ordinary controlled case is the same shape, honoured rather than ignored:
<ColorPicker value={color} onChange={setColor} open={open} onOpenChange={setOpen} />Theming
Every color resolves through a three-step chain:
var(--ldc-color-accent, var(--ion-color-primary, #0054e9))Set --ldc-color-accent to theme it deliberately. Set nothing and, inside an Ionic app, it picks up the host theme on its own. Set nothing anywhere and it still looks like something.
| Property | Falls back to | Then |
| --- | --- | --- |
| --ldc-color-accent | --ion-color-primary | #0054e9 |
| --ldc-color-surface | --ion-background-color | #fff |
| --ldc-color-ink | --ion-text-color | inherit |
| --ldc-color-saved | --ion-color-warning | #e0a800 |
| --ldc-color-hairline | — | rgba(0,0,0,.22) |
| --ldc-color-panel-width | — | 236px |
Saved colors
favorites takes anything with get and set:
<ColorPicker
value={color}
onChange={setColor}
favorites={{
get: () => fetch('/api/me/colors').then((r) => r.json()),
set: (colors) => fetch('/api/me/colors', { method: 'PUT', body: JSON.stringify(colors) }),
}}
/>The default is localStorage under one unscoped key, so two pickers on a page share a list — one person, one set of colors. Pass scope when that is wrong.
The panel is a native popover
It renders in the top layer, so no ancestor's overflow: hidden can clip it, and the browser handles light dismiss, Escape and focus. Placement is ours — computePlacement is exported and tested on its own.
Browsers without the popover API fall back to a fixed-position panel with our own dismiss handling.
License
PolyForm Noncommercial 1.0.0 — see LICENSE.md.
