Chip
betav0.3.0Compact status/tag/filter token. No Radix primitive (Chip.rfc.md header — plain <span>/<button>): renders a decorative <span> by default, or a <button> the moment an onClick is provided. An optional trailing delete control is composed from the library's own IconButton, always as a DOM sibling of the label body — never nested inside it (Chip.rfc.md §4.3/§5.1, RULE-04/-05) — so a clickable+deletable chip ('active filter', usage family 3) produces two independent tab stops instead of a button-in-button. Color is resolved from one of three axes, in precedence order: `severity` (feedback tokens: neutral/info/low/medium/high/critical), `entity` (the 9 --color-entities-* tokens; takes precedence over `severity`), and `color` — a free hex coming from the DATA rather than from the design system (an OpenCTI label or marking definition carries one, chosen by a user and stored in the database), which outranks both. A data colour is never painted raw: the library normalizes it into a BOUNDED WASH behind the label, while the label, the icon and any border keep their system tokens. TWO MECHANISMS, TWO USES, and picking the wrong one is the only way to get this wrong: a CLOSED product semantic (TLP, and anything else with a fixed set of values) goes through `severity`/`entity`, because a closed set is what a token axis is for; a colour chosen PER DATUM by a user (an OpenCTI label's hex — the ~47 sites) goes through `color`, because no closed set can cover it. The severity axis includes "ee", the Enterprise Edition marker: the one value with an opaque fill and a 12px medium label where every other value labels at 14px regular, which replaced a separate `tone` prop on 2026-08-12. Long labels are truncated at a Figma-measured max-width and exposed in full through the library's own Tooltip, which opens only while the label is ACTUALLY clipped (RULE-06 / NavbarItem.rfc.md §3.7) — on hover for every Chip, and additionally on keyboard focus when the body renders as a <button> (a read-only <span> body is never focusable). The native `title` this component used to carry was removed on 2026-08-23: it fired on every chip whether cut or not, was unreachable by keyboard, and duplicated the description the Tooltip already provides. The full label remains the accessible NAME either way, since truncation is a paint-time effect.
Variants
Severity (read-only)
Entities (read-only, precedence over severity)
EE severity (opaque fill, 12px medium label — the one value that does not label at 14px regular; clickable on some product screens, informative on others)
EE small (`size="sm"`, Figma EEChipsSize=sm, added 2026-08-30) — 16px tall, no inline padding, 21px wide floor. Same fill, same 12px medium label, same ring and same disabled border as the 24px chip above: only the box shrinks. The first row is informative, clickable (hover it, then Tab to it for the focus ring) and disabled — the four states the node draws, in three chips. The second row puts it where it is meant to go, against the text and controls it has to sit beside without pushing their line height around.
…and where `size="sm"` is refused: Figma draws the small chip for the EE marker only, and label-only. Each of these four renders at the ordinary 24px size and logs one dev-only warning naming the reason — never an invented small geometry.
With a leading icon
Clickable / deletable / both (usage families 1, 3, 4)
Disabled
Truncation (full label on hover/focus via Tooltip)
TLP markings — a CLOSED product semantic, so it goes through `severity`, never `color`. Iso to the reference samples (nodes 7277-57897 dark / 7277-57896 light): the five tones they draw, in their order, with no free hex, no leading icon and no delete control.
Data colours (`color`) — a free hex from the data, normalized into a bounded wash. Both modes are shown side by side because a wash is a composite: the same hex is a different surface over each page. Label, icon and border stay on system tokens; hover stacks the same wash a second time and still lands under the doctrine ceiling.
In a Combobox (multiple) — `getChipColor` hands each selected option its own hex. This block follows the PAGE theme toggle rather than sitting in the two panels above: a Combobox field is built from the `--bg-input-*` family, which theme.css declares on `:root` only, so no nested scope can theme it — and its options panel portals to the owner document anyway. Toggle the page to see it in the other mode.
- ransomware
- apt
- phishing
- exfiltration
- unclassified
Examples
<Chip label="Critical" severity="critical" /><Chip label="Locations" entity="location" startIcon={<Icon name="map-pin" size={16} />} /><Chip label="EE" severity="ee" /><Chip label="EE" severity="ee" size="sm" /><Chip label="EE" severity="ee" onClick={() => navigate("/licensing")} /><Chip label="Malware" entity="arsenal" onClick={() => setActiveFilter("malware")} onDelete={() => removeFilter("malware")} /><Chip label="acme.example" onDelete={() => removeValue("acme.example")} /><Chip ref={chipRef} label="Archived" severity="neutral" disabled /><Chip label={label.value} color={label.color} /><Chip label="TLP:RED" color="#ff2b2b" /><Chip label={label.value} color={label.color} onDelete={() => removeLabel(label.id)} />Props
| Prop | Type |
|---|---|
label | string — Visible text (Figma `label`) |
severity | "neutral" | "info" | "low" | "medium" | "high" | "critical" | "ee" — Feedback-token color, default "neutral". Overridden by `entity` when both are set (precedence `entity ?? severity`). "ee" is the Enterprise Edition marker: the one value with an OPAQUE fill and a 14px medium label — it was a separate `tone` prop until 2026-08-12 (Chip.rfc.md §9) |
size | "md" | "sm" — Box geometry (Figma `EEChipsSize`), default "md" (24px). "sm" is the 16px Enterprise Edition marker for use inside or beside a button, a nav item or a panel row: 16px tall, no inline padding, 21px wide floor. Figma draws it for `severity="ee"` ONLY and label-only, so it is honoured exactly there and ignored — rendering at "md", with a dev-only console.warn naming every unmet condition — on any other tone, with a `startIcon`, or with an `onDelete`. Tone, label typography, focus ring and disabled border are identical at both sizes |
entity | "analyses" | "cases" | "events" | "observations" | "all-threats" | "arsenal" | "techniques" | "victimology" | "location" — Entity-token color, takes precedence over `severity` |
color | string — DATA colour, for a colour chosen PER DATUM by a user; a CLOSED product semantic (TLP included) goes through `severity`/`entity` instead. A free hex (`#rgb`/`#rrggbb`, `#` optional) coming from the data rather than from the design system, e.g. an OpenCTI label's or marking definition's user-chosen colour. Outranks `severity` and `entity`. Normalized by the library into a bounded wash behind the label (21% at rest, 37.59% under the stacked hover layer, under the 38%/49% doctrine ceilings); the label, the icon and any border stay on system tokens, so the data colour never becomes ink or an edge. An invalid hex — including one carrying its own alpha, which would multiply into the wash — is ignored: the chip renders exactly as it would without `color`, and a dev-only console.warn names the value |
startIcon | ReactNode — Optional decorative leading icon (16×16), tinted with the resolved color key |
onClick | (event) => void — Presence renders the chip as a <button> |
onDelete | () => void — Presence renders a trailing delete IconButton as a DOM sibling, never nested |
deleteLabel | string — Accessible label for the delete IconButton, defaults to "Remove ${label}" |
deleteTabIndex | number — tabIndex for the delete IconButton, omitted by default (keeps its own Tab stop). Pass -1 inside a composite widget that owns focus management (a chip row in a Combobox field): removed from the Tab order, still programmatically focusable, still fires onDelete |
disabled | boolean — Non-interactive, muted tokens |
className | string — Additional classes for the outermost rendered element |
Accessibility
Renders a <span> (read-only) or <button> (onClick present); a trailing delete control, when `onDelete` is set, is always a true DOM sibling of the label body (never nested — no button-in-button, §5.1), giving a clickable+deletable chip two independent Tab stops. forwardRef is exposed on the actual rendered element (the span, or the interactive body's button when onClick is set). Focus ring uses the standard ring-focus/ring-offset-focus pattern — no deviation. Truncated labels are wrapped in the library's own TooltipProvider/Tooltip (own instance per chip, same standalone trade-off as NavbarItem), CONTROLLED so it opens only while the label is really clipped — measured on the label span at open time, RULE-06 / NavbarItem.rfc.md §3.7. Keyboard focus exposes it only when the body renders as a <button> (onClick set); a read-only <span> body is never focusable. The native `title` was removed 2026-08-23 and nothing about screen-reader exposure regressed: the complete label is the element's own text content, so it has always been the accessible name — `title` was a second, weaker copy of it that also fired on chips whose label was not cut at all. CONTRAST — REST: all 16 label/fill pairs pass 4.5:1 in both modes, computed range 10.76:1 (light label-victimology) to 15.80:1 (dark label-critical) — `--text-default-primary` flips to dark ink in light mode, so even the lightest entity hues (victimology/cases/arsenal) clear the threshold comfortably. CONTRAST — EE (the one opaque fill, measured by `pnpm check:contrast` on this commit, not hand-computed): label-ee #18191b on #00f0bc = 11.89:1 dark ✅ / #f2f2f3 on #005744 = 7.67:1 light ✅; label-ee-hover #18191b on #bdffed = 15.66:1 dark ✅ / #f2f2f3 on #005744 = 7.67:1 light ✅; icon-ee 11.89:1 dark ✅ / 7.67:1 light ✅ (threshold 3). Threshold is 4.5 and not 3 for the label pairs: EE labels at 12px/500 since 2026-08-17 (14px/500 before), and WCAG large text starts at 14px BOLD — so neither size ever qualified, the threshold was already at its maximum, and reducing the size could not raise it. No colour moved either, so the figures above are unchanged by that pass. The single fg token works on both fills because it inverts per mode — dark ink on the bright dark-mode fill, light ink on the darker light-mode one. TRANSITIONAL (@sandy, 2026-08-12): against the committed theme.css both EE fills resolve to the same light-mode value (#005744), so the EE hover is visually identical to its resting state in LIGHT mode only. Dark mode differs normally. Sandy's Figma already carries a distinct light value under the same token name, so this differentiates itself with no code change when the coordinated token export lands, and check:contrast re-runs then. Asserted rather than merely noted, in check:contrast and Chip.ee.test.tsx (retired guard scripts/lib/chip-ee-severity.test.ts, 2026-09-26), so it goes red the day it stops being true. CONTRAST — HOVER MECHANISM: hover/focus-visible stack the SAME fill token as a second identical translucent layer (`before:bg-<same class>`) on top of the already-composited rest fill — never a hardcoded higher-alpha value. This is real 2-layer Porter-Duff compositing: stacking the same color at rest alpha α twice gives a combined effective alpha of 1-(1-α)², NOT the naive 2×α an earlier RFC draft assumed (feedback fills: 30% rest → ≈51% effective at hover, not 60%; entity fills: 20% rest → ≈36% effective, not 40%). EE is outside this mechanism entirely: its rest fill is opaque, so there is no alpha to stack and its hover is a genuinely different token, making label-ee-hover a real independent measurement rather than a proxy. Independently computed with the same theme.ts primitives check:contrast itself uses (loadTheme/resolveToken/ compositeOver/contrastRatio), chained twice to model the real two-layer stack — the `label-<tone>-hover` pairs above (EE excepted) reuse the rest pair's single-token/single- composite numbers because the current contrastPairs schema has no way to declare "this token stacked twice" as a distinct machine-checked value; this is a conservative, not an invented, proxy: moving the fill further from a light/dark- neutral page background usually REDUCES label contrast slightly (opposite of an earlier internal assumption that hover only ever gains margin), so the true stacked ratio is typically lower than the declared rest-pair number, and every one of the true stacked ratios was independently checked and still clears 4.5:1: worst cases dark label-techniques 6.64:1, dark label-arsenal 7.73:1, light label-victimology 7.72:1 (vs. their rest-state 11.01:1 / 11.96:1 / 10.76:1) — all 3 are 20% α entity fills, untouched by PR#62. This enumeration counted 16 stacked pairs while EE was a translucent `tone`; since 2026-08-12 EE is opaque with its own hover token, so it is measured directly above and is deliberately no longer part of this stacked-mechanism paragraph (its old 6.81:1 figure is dropped rather than recounted). label-neutral is the only one of the 16 built on a feedback fill (30% α) that PR#62 changed, and was re-verified independently post-rebase despite its rest value dropping materially (dark 14.39→13.40:1, light 15.72→11.26:1): true stacked ratio is 10.35:1 dark / 8.67:1 light, still well clear of both 4.5:1 and the entity worst cases above. No hover regression risk despite the reduced margin. CONTRAST — DECORATIVE ICON: the leading icon is `aria-hidden` (§5.3) — the label carries 100% of the meaning, so WCAG 1.4.11 does not require the icon/fill pairs above to pass. They are nonetheless declared (Rule 5 has no decorative exemption for solid colors, unlike Rule 4's gradient exemption) and 1 genuinely fails 3:1 in LIGHT mode against the current Figma-bound tokens: icon-medium 1.44:1 (dark mode 5.16:1, passes). COUNT CORRECTED 2026-08-30 from 2 to 1, measured, not assumed: icon-high was the second at 2.51:1 light and PR #188 (Depth rename + darker light hover layers) independently fixed it, the same way PR #62 had fixed icon-low. Verified in reports/contrast-report.md at this branch's base commit as well as on it, so the correction is #188's effect and not this change's — the size axis moves no pair at all (see SIZE AXIS below). icon-low was a 3rd failure at 2.45:1 light as originally measured; PR#62's remediation (--color-feedback-success-primary green-600 → green-700) independently fixed it as a side effect — it now passes at 4.46:1 light and has been removed from scripts/wcag-allowlist.json accordingly. Several others pass with a thin margin in light mode (icon-events 3.06:1, icon-cases 3.10:1, icon-observations 3.14:1, icon-location 3.16:1, icon-all-threats/-arsenal 3.20:1, icon-techniques 3.21:1, icon-analyses 3.26:1) — worth a look in a future token pass, not a blocker today. EE's icon is the most comfortable of the set — it takes the label's own token on an opaque fill, so it measures exactly what label-ee does (11.89:1 dark / 7.67:1 light) and has no stacked-hover variant to compute. wcagStatus is 'fail' solely because of the decorative-icon failure above (every text-carrying/normative pair passes); allow-listed in scripts/wcag-allowlist.json (approved by Sandy, lead design, expires 2026-08-31) pending the same broader color-token remediation already tracked for Switch/Input/Button. `--icon-neutral` (Figma's binding for the neutral-severity icon) does not exist in theme.css — a confirmed design gap, logged to process/AI-BACKLOG.md, not invented: neutral ships with the `--icon-subtle` alias (`--text-default-secondary`) as an explicit interim fallback (Chip.rfc.md §3.4) until the token is added. `entity` takes precedence over `severity` when both are set (two flat props, not a discriminated union — Chip.rfc.md §4.1). REVERSAL, 2026-08-12: EE used to be a third axis (`tone="tonic"`) that outranked both, on the 2026-08-02 reasoning that a brand accent is not a gravity level. @sandy reversed it — a property with a single legal value is not a property — so EE is now the severity value Figma always modelled it as (Severity=EE, node 2752-19169), it follows the ordinary precedence, and the `tone` prop is removed (breaking; see the changeset). Chip.rfc.md §9 carries the full trail. Disabled uses the same --bg-input-disabled/--text-default-disabled pair as Input/SearchField/Select; EE additionally carries --border-elevation-subtle, making it deliberately the only bordered disabled chip in the set (@sandy, 2026-08-12, in the direction of her Figma). A disabled-gated border needs no contrastPair — WCAG 1.4.3 Note 1 exempts inactive controls, and Rule 5 covers bg-*/text-*/ring-* rather than border-*. STACKING: the label and startIcon are `relative` so they paint ABOVE the ::before hover layer, which is absolutely positioned. Without it that layer covers the text — latent for every family, invisible while all fills were translucent, and total once EE introduced an opaque one (@sandy checkpoint, 2026-08-12; measured in docs/e2e/chip-ee-hover-label.spec.ts, which jsdom cannot do). FOCUS RING — ONE DEVIATION FROM THE REPO-STANDARD PATTERN, and it is node-driven: `ring-offset-1`, not the standard `ring-offset-2`. Node 2752-19169's focus symbols express the ring as `0 0 0 1px var(--bg-elevation-default)` then `0 0 0 3px var(--color-filigran-brand-primary)`; later shadows paint behind earlier ones, so that is a 1px offset and a 2px band of brand. Ring width (2px) and both colours already matched the standard — only the offset moved, from 2px to 1px, on 2026-08-25. WCAG unaffected: the indicator keeps its 2px thickness and the same pair the `focus-ring` entry measures at 3:1. The spec had recorded 2px here, taken from the implementation rather than from the node, which is why check:figma-fidelity stayed green on the divergence for as long as it did (Chip.figma.json State=Focus carries the correction and the reason). STATE COVERAGE: the node declares Default, hover, focus and disabled — 7 severities x 4 states, all four implemented and all four locked by Chip.figma.json. State=active existed until 2026-08-25 (a radial press ripple over the rest fill) and Sandy removed it from the nodes as unnecessary, so a pressed chip keeping its hover appearance is now exactly what the node declares. No ripple token was invented for it and none is owed. SCOPE, in two lines: a CLOSED product semantic goes through `severity`/`entity` — TLP included, and the reference samples (nodes 7277-57897 dark / 7277-57896 light) are authoritative for it: they draw TLP on system tones and contain no free hex at all. A colour chosen PER DATUM by a user goes through `color`. DATA COLOURS (`color`): a colour arriving from the DATA has no token, so it cannot be declared in contrastPairs and Rule 5 is blind to it by construction — the `accessibility.dataColorWash` declaration above closes exactly that gap and `pnpm check:contrast` Rule 6 verifies it. Doctrine (Sandy, arbitrated on Card): bounded wash only — never a border, never raw ink. Label and startIcon take --text-default-primary; the disabled border and the focus ring are unchanged. BOUND: 21% at rest, stacked a second time by `before:bg-inherit` on hover to 1-(1-0.21)^2 = 37.59%, under the 38% dark / 49% light ceilings. The light ceiling is deliberately unused: at 49% a pure-black data colour composites to #7b7b7c and --text-default-primary measures 4.18:1, below AA (mutation-proven). MEASURED at 37.59%, computed over the whole hex space rather than sampled (Rule 6 sweeps the eight sRGB-cube corners in both modes and checks the precondition that makes them a bound): DARK #f2f2f3 on #64686f (data colour #ffffff) = 5.00:1 ✅; LIGHT #18191b on #979798 (data colour #000000) = 6.03:1 ✅, both against 4.5:1. The decorative startIcon shares the label token, so it measures the same figures against 3:1. Pure white and pure black ARE those two worst corners, so they are what the bound is built on rather than exceptions to it. An invalid hex — including `#rgba`/`#rrggbbaa`, rejected because a data-carried alpha would multiply into the wash — is ignored and warns in dev; an ABSENT value (null/undefined/empty) is not invalid and never warns. A near-white data colour in light mode (or near-black in dark) renders as a deliberately discreet wash with no border — accepted as behaviour by Sandy on 2026-08-25, and kept visible in the docs demo's near-greys row rather than left to be rediscovered. Full reasoning: Chip.rfc.md §9.4. SIZE AXIS (`size="sm"`, Figma EEChipsSize, node 2752-19169 read 2026-08-30): NO contrast pair moves with it, and that is a measurement rather than an assumption. The four sm symbols bind the same fill, label, ring and border tokens as their Default counterparts, so `label-ee` (11.89:1 dark / 7.67:1 light), `focus-ring` and the disabled pair are the same pairs at the same values; and the label was already 12px/500 at both sizes since 2026-08-17, which is normal text under WCAG 1.4.3 (large text starts at 14px BOLD), so it was already held to the maximum 4.5:1 threshold — a smaller BOX cannot raise a threshold already at its ceiling, and it changes nothing about what the label composites against. What DOES change is target size: a clickable sm chip is 21x16 CSS px, under the 24x24 minimum of WCAG 2.2 SC 2.5.8. This repo's stated bar is WCAG 2.1 AA + Section 508, neither of which has a target-size criterion at AA (2.1's SC 2.5.5 is AAA), so the variant conforms to the bar it is held to — recorded here, and in Chip.rfc.md §10.4, so that a future move to 2.2 finds it already counted rather than having to rediscover it. Products placing the sm chip as a decorative marker (its designed use: beside a button or nav label) are unaffected either way, since SC 2.5.8 scopes to pointer targets.