Label Chip

Free

A tag in any colour a user picks, kept readable in both themes by CSS alone.

BugDesignCopyInfraChoreBlocked

Static tags, the way they sit on a card. Switch the site theme: every one stays readable. Pick any colour and add one.

npx shadcn@latest add @ui-shrushank/label-chip

The install writes a single file to components/ui/LabelChip.tsx, which exports LabelChip.

tsx
import { LabelChip } from "@/components/ui/LabelChip";
tsx
<LabelChip color="Color">Label</LabelChip>

A tag in a colour the user chose. Issue trackers, CRMs and note apps all let people pick label colours, and all of them then have to make a stranger's pick readable: a pale yellow vanishes as text on white, a near-black vanishes on a dark card, and a saturated red shouts as a solid fill. The Badge has four fixed tones; this is the one for a palette nobody designed.

Every colour is derived in CSS from the one hex passed in, so there is no contrast maths in JavaScript and nothing for the server and the browser to disagree about. The fill is the hue mixed into the surface with color-mix(in oklab, ...) (14% on white, 20% on the stone-900 card). The text is the same hue with its OKLCH lightness clamped by relative colour syntax: at most 0.45 on light and at least 0.82 on dark, which lands at roughly 6:1 or better in both themes whatever was picked, and chroma is capped so a neon pick does not vibrate. The dot keeps the raw colour with an inset hairline, so a white or near-black label still shows an edge. One component covers the three places a label appears: static on a card, a toggle in a filter bar (aria-pressed, a deeper fill and a hairline in the text colour when on) and removable on an open item. A browser without relative colour syntax drops only the text tint and inherits the surrounding text colour, which is still legible.

A label keeps its identity (the hue is recognisably the one picked) without ever costing legibility, and it looks the same family in light and dark rather than being re-picked per theme.

Every line below was read out of the file you are about to copy, so you can check each one against the source at the bottom of this page.

  • Carries aria-label, aria-pressed, so its state is exposed and not left to the visual treatment alone.
  • Draws a focus-visible ring that is separate from its hover treatment, so keyboard users get an affordance mouse users do not take away.

2 required, 6 optional.

colorrequired
string

Any CSS colour, usually a hex the user picked.

childrenrequired
ReactNode
size
"sm" | "md" · default "md"
dot
boolean · default true

Hide the leading dot, e.g. in a dense row where the fill already carries the hue.

pressed
boolean

Turns the chip into a toggle button (a filter).

onPressedChange
(pressed: boolean) => void
onRemove
() => void

Adds a remove button after the name.

className
string

One file, no runtime package. Copy it, or install it with the command at the top of this page.

LabelChip.tsx
"use client";import type { CSSProperties, ReactNode } from "react";import { Check, X } from "lucide-react";const cx = (...c: Array<string | false | null | undefined>) => c.filter(Boolean).join(" ");/** * Every colour below is derived in CSS from the one hex the user picked, so * there is no contrast maths in JS, nothing to disagree about between the * server and the browser, and a colour chosen at runtime is readable the * moment it lands. * * - The fill is the hue mixed into the surface (14% on white, 20% on the *   stone-900 card), so a saturated pick never becomes a solid block. * - The text is the same hue with its OKLCH lightness clamped: at most 0.45 *   on light (about 6:1 on the tint or better) and at least 0.82 on dark *   (about 5.5:1 or better). A pale yellow and a near-black both come out *   readable, which is the case a user-picked palette always ends up with. * - Browsers without relative colour syntax drop the text colour and inherit *   the surrounding text, which is still legible, just not tinted. */function chipVars(color: string): CSSProperties {  return {    "--chip": color,    "--chip-bg": `color-mix(in oklab, ${color} 14%, #ffffff)`,    "--chip-bg-on": `color-mix(in oklab, ${color} 26%, #ffffff)`,    "--chip-line": `color-mix(in oklab, ${color} 36%, #ffffff)`,    "--chip-fg": `oklch(from ${color} min(l, 0.45) min(c, 0.16) h)`,    "--chip-bg-dark": `color-mix(in oklab, ${color} 20%, #1c1917)`,    "--chip-bg-on-dark": `color-mix(in oklab, ${color} 34%, #1c1917)`,    "--chip-line-dark": `color-mix(in oklab, ${color} 42%, #1c1917)`,    "--chip-fg-dark": `oklch(from ${color} max(l, 0.82) min(c, 0.12) h)`,  } as CSSProperties;}const SIZE = {  sm: "h-5 gap-1 px-2 text-[11px]",  md: "h-6 gap-1.5 px-2.5 text-xs",} as const;const SURFACE =  "border bg-[var(--chip-bg)] border-[color:var(--chip-line)] text-[color:var(--chip-fg)] dark:bg-[var(--chip-bg-dark)] dark:border-[color:var(--chip-line-dark)] dark:text-[color:var(--chip-fg-dark)]";const FOCUS =  "focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-emerald-600 dark:focus-visible:outline-emerald-400";export interface LabelChipProps {  /** Any CSS colour, usually a hex the user picked. */  color: string;  children: ReactNode;  size?: "sm" | "md";  /** Hide the leading dot, e.g. in a dense row where the fill already carries the hue. */  dot?: boolean;  /** Turns the chip into a toggle button (a filter). */  pressed?: boolean;  onPressedChange?: (pressed: boolean) => void;  /** Adds a remove button after the name. */  onRemove?: () => void;  className?: string;}/** * A label in a colour the user chose, not one from a fixed palette. Static by * default; a toggle when given `onPressedChange`; removable with `onRemove`. */export function LabelChip({  color,  children,  size = "md",  dot = true,  pressed,  onPressedChange,  onRemove,  className,}: LabelChipProps) {  const style = chipVars(color);  const toggle = onPressedChange !== undefined;  const body = (    <>      {dot && (        <span          aria-hidden          className="h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--chip)] ring-1 ring-inset ring-black/15 dark:ring-white/25"        />      )}      <span className="truncate">{children}</span>      {toggle && pressed && <Check aria-hidden className="h-3 w-3 shrink-0" strokeWidth={2.5} />}    </>  );  const base = cx(    "inline-flex max-w-full shrink-0 items-center rounded-full font-medium leading-none",    SIZE[size],    SURFACE,    className  );  if (toggle) {    return (      <button        type="button"        aria-pressed={!!pressed}        onClick={() => onPressedChange(!pressed)}        style={style}        className={cx(          base,          "transition-[background-color,box-shadow] duration-150 active:scale-[0.98]",          pressed            ? "bg-[var(--chip-bg-on)] shadow-[inset_0_0_0_1px_var(--chip-fg)] dark:bg-[var(--chip-bg-on-dark)] dark:shadow-[inset_0_0_0_1px_var(--chip-fg-dark)]"            : "hover:bg-[var(--chip-bg-on)] dark:hover:bg-[var(--chip-bg-on-dark)]",          FOCUS        )}      >        {body}      </button>    );  }  return (    <span style={style} className={cx(base, onRemove && "pr-1")}>      {body}      {onRemove && (        <button          type="button"          onClick={onRemove}          aria-label={`Remove ${typeof children === "string" ? children : "label"}`}          className={cx(            "-my-1 ml-0.5 inline-flex h-4 w-4 items-center justify-center rounded-full opacity-70 transition-opacity duration-150 hover:opacity-100",            FOCUS          )}        >          <X aria-hidden className="h-3 w-3" strokeWidth={2.5} />        </button>      )}    </span>  );}