Composites
Color Swatch
A small square that shows a colour someone has stored — a product’s background colour in an order table, a client’s brand colour on a detail page. The colour is user data, not part of the interface: the swatch draws it, names it for screen readers and refuses anything that is not a plain colour value. It is not a way to paint your own UI.
- Import
import { ColorSwatch } from '@comitor/ui'— the main entry. No optional peer to install.- Runtime
- A client component, but every prop is a plain value, so a Server Component can render it directly. The colour check is a pure function that runs the same on the server and in the browser — no hydration mismatch.
- Origin
- Ported in
3.15.0from Dice UI (MIT, © 2024 Sadman Sakib), together with Color Picker. What changed on the way is listed under What the port guarantees; the release notes are in the changelog.
Anatomy
One element. It renders a <div> with role="img", an accessible name built from the value, and the colour as inline background — inline because there is no token for “the colour this client asked for”, and there should not be.
import { ColorSwatch } from '@comitor/ui'
<ColorSwatch
color="#1F6FEB" // a STORED value: hex, a named colour, or a colour function with plain arguments
size="default" // sm 24px · default 32px · lg 48px
withoutTransparency // optional: no checkerboard under a colour with alpha
/>
// → <div role="img" aria-label="Color swatch: #1F6FEB" data-slot="color-swatch" style="background-color: #1F6FEB" />Sizes
sm fits a table row, default a form or a detail list, lg a header next to the colour’s details. All three keep the same border, so a white swatch still shows on a white page.
Transparency
A colour with alpha — 4- or 8-digit hex, rgba()/hsla(), any function with a / alpha, or transparent — is laid over a grey-and-white checkerboard, the convention every image editor uses for “see-through”. The checkerboard is the same in light and dark on purpose: a board that followed the theme would make the same 50% colour look different in each, so the swatch would misreport the very data it shows. withoutTransparency turns it off.
No colour yet
Leave color out, or pass an empty or blank string, and the square shows a thin diagonal stroke in the destructive-ink role, named “No color selected”. Map a null from your data to undefined — the prop is a string.
What counts as a colour
The value is data someone typed, and it ends up in a style attribute — so the swatch accepts only a closed grammar and nothing else reaches the page: hex with 3, 4, 6 or 8 digits; a single word of letters (red, rebeccapurple, transparent); or one colour function — rgb, hsl, hwb, lab, lch, oklab, oklch, color and the a variants — whose arguments are only letters, numbers, spaces and % . , / + -. That includes relative colours such as rgb(from red r g b). Everything else draws a transparent square: var(), color-mix(), a function inside a function, and anything with a ;, a colon or a quote that could break out of the declaration.
Drawn
#e9e1d3rebeccapurpleoklch(0.62 0.19 255)rgb(from red r g b / 50%)Rejected — a transparent square
var(--primary)color-mix(in oklch, red, blue)rgb(calc(255) 0 0)The rejection of var(--primary) is the point, not a gap: a token is interface colour, and the swatch is not the place for it. The grammar decides what is safe, not what is valid — notacolor is a word of letters, passes, and the browser simply drops the declaration. Validate when the value is saved, and store hex: it is what Color Picker posts from a form.
In a table
The shape the component was ported for: a list of records, each with a stored colour. Print the value next to the swatch — two near-identical blues are one colour to the eye and two to the person matching a brief — and say so in words when nothing is stored.
| Product | Background colour |
|---|---|
| Linen tote bag | #E9E1D3 |
| Ceramic mug, matte | #1F3A5F |
| Running shoe, white | #FFFFFF |
| Glass bottle cut-out | #2E8B5780 |
| Leather wallet | Not specified |
The white row is why the border exists: it uses the input role, the one that marks a boundary, not the decorative border. The semi-transparent row shows the checkerboard. With the hex printed beside it, a screen reader hears the value twice — once in the swatch’s name, once in the text. That is harmless; if a column repeats it hundreds of times, pass aria-hidden to the swatch and let the text carry it.
The accessible name
Every swatch is an image with a name: “Color swatch: ” followed by the value exactly as stored. A screen reader spells out a hex code, which is accurate but not friendly — when you know more than the value does (a colour name from the brief, what the colour is for), pass your own aria-label. It replaces the default whole.
asChild and children
With asChild the swatch renders no element of its own: its role, name, classes and background land on the single element you pass — here a <span>, so the swatch can sit inside a sentence where a <div> is not allowed. It needs exactly one child element; with none, nothing renders. Without asChild, children are drawn inside the square — a mark on top of the colour.
Shoot on #E9E1D3, as the brief asks.
The child takes role="img", so do not merge a swatch onto a button or a link — it would stop being one for assistive technology. And a mark drawn on a colour the user chose has no guaranteed contrast — whatever colour you pick for it will vanish on some stored value, and no token can promise otherwise. Keep such marks decorative (aria-hidden) and say the same thing in text next to the swatch.
What the port guarantees
Behaviour you can rely on that the Dice UI original did not give you — each point compared against the upstream source.
- Nothing but a colour reaches the style attribute. Upstream filtered the value with
CSS.supports, which does not exist on the server — so in server rendering every string went intostyle, and React writesred;background-image:url(…)into the HTML as it is: a CSS injection that can load an image from any host. The closed grammar has no;, colon, quote or nested parenthesis, and it gives the same answer on server and client, which also ends the hydration mismatchCSS.supportscaused. - The “no colour” stroke actually draws. Upstream wrote
hsl(var(--destructive)), which assumes shadcn’s old three-channel tokens; with full colour tokens it becomes an invalid declaration and the stroke silently disappears. The port paints it with--destructive-ink— the role for a thin mark on the page, rather than a button fill. - The border is a boundary.
border-inputinstead of the bareborder(the decorative role, 1.16:1). The border is the only thing that separates a near-white colour from a white page, so it carries information: 3.83:1 in light, 4.94:1 in dark, and the high-contrast palette raises it further. - The name is translated. Both strings come from the
colorSwatchnamespace, so a Vietnamese provider names the swatch in Vietnamese. - asChild works. Upstream removed
childrenfrom the props, soasChildhad no child to merge into and the swatch rendered nothing. The port accepts children in both modes. - One checkerboard, shared. The swatch and Color Picker (its swatch and alpha slider) draw transparency from the same helper, so the same colour looks the same in both. Upstream kept two copies with different squares.
- Forced colours keep the data. The swatch sets
forced-color-adjust: none, so Windows High Contrast does not repaint the very colour it exists to show.
Language
Two strings, both heard and not seen. swatchLabel is a function of the value — “Color swatch: #1F6FEB”, “Mẫu màu: #1F6FEB” — and noColor names the empty square: “No color selected”, “Chưa chọn màu”. The per-instance override is aria-label, which beats the nearest ComitorLocaleProvider, which beats English.
An app that builds a whole message table of its own (a third language, typed as ComitorMessages) must add the colorSwatch namespace since 3.15.0, or typecheck fails. Where the provider goes: Get started → Set the language.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
color | string | — | The stored colour to show. Accepted: hex with 3, 4, 6 or 8 digits; a single word of letters (a named colour, transparent, currentcolor); or one colour function — rgb, rgba, hsl, hsla, hwb, lab, lch, oklab, oklch, color — whose arguments contain only letters, digits, spaces and % . , / + -. Anything else (var(), color-mix(), a nested function, more than 128 characters) draws a transparent square. Trimmed first; empty or missing draws the “no colour” diagonal. |
size | "sm" | "default" | "lg" | "default" | 24px, 32px or 48px square. |
withoutTransparency | boolean | false | Do not draw the checkerboard under a colour with alpha (8- or 4-digit hex, rgba/hsla, a function with a / alpha, transparent). The colour is then laid over whatever sits behind the swatch. |
disabled | boolean | false | Sets aria-disabled and data-disabled; the swatch fades to 50% and ignores the pointer. |
asChild | boolean | false | Merge the swatch onto its single child element instead of rendering a div. Needs exactly one child element — without one, nothing renders. The child takes role="img", so never use it on a button or a link. |
children | ReactNode | — | Without asChild: drawn inside the square (an icon, a mark). Presentational — the name comes from aria-label, not from the children. With asChild: the one element the swatch becomes. |
aria-label | string | — | Overrides the accessible name. Default: “Color swatch: <value>”, or “No color selected” when there is no value — from the colorSwatch namespace of the nearest ComitorLocaleProvider. |
...props | ComponentProps<"div"> | — | Go to the root div. className is merged; style is spread after the computed background, so a style key you pass wins over it. |
Known limitations
- A rejected value is still named. The name is built from the string you passed, not from what was drawn:
var(--primary)shows a transparent square announced as “Color swatch: var(--primary)”, andnotacolorlikewise. Validate before you store, and the two never disagree. - No tokens, no mixing.
var()andcolor-mix()are refused by design (see above). To show one of the package’s own colours, use the component that owns it — a Badge tone, a Status Pill. - The package’s contrast gate does not see it.
lint:a11yskips the inline style of an element whose class name has no literal string, which is how the swatch draws — so the data colour passes the gate without an exemption rather than with a measured one. The 3.15.0 release notes record this as a known, unpatched gap. It is one more reason the swatch must never carry interface meaning. - Display only. The swatch is not focusable and has no click behaviour. To let someone enter or change a colour, use Color Picker.
Accessibility
role="img"with anaria-labelthat includes the value, or “No color selected”. Passaria-labelto replace it.- Children of an image are presentational: anything drawn inside the swatch is not read. Put meaning in the name or in text beside it.
- Colour must not be the only carrier (WCAG 1.4.1): print the value, or a name for it, next to the swatch, as the table demo does.
- The border uses the
inputrole — 3.83:1 on the light background, 4.94:1 on dark — above the 3:1 WCAG 1.4.11 asks of a boundary that carries information. disabledsetsaria-disabledand fades the swatch to 50%; it was never focusable, so nothing changes for the keyboard.forced-color-adjust: nonekeeps the stored colour visible in forced-colour modes.- Never merge it onto a button or a link with
asChild—role="img"replaces their role.
Related
- Color Picker — entering the colour this component later shows.
- Table — the rows a swatch most often sits in.
- Key Value — a detail page’s label-and-value list, where a swatch can sit beside the hex.