Design System Reference
Living reference for pure HTML/CSS prototyping (WCAG 2.2 AA on light backgrounds). Last updated 2026.10.05.
Stored in BeameryHQ/design-artefacts, aligning to BeameryHQ/lib-design-system. We build prototypes directly in this repo, test new patterns here before proposing them to the lib-design-system.
For prototyping, create a dated YYYY-MM-simple-name/ folder, starting from starter.html (or forking from any of the other existing folders).
See the repo's AGENTS.md for conventions.
Key
Candidate in progress still being designed or tested – don't spec from it.
Candidate for prod built and proven in prototype, proposed for lib-design-system.
Table of contents
Colours
Components must consume semantic tokens (e.g. text.*, borders.*, backgrounds.*, controls.*), never raw primitive shades.
Primary band colours
Click a row to copy the hex.
Key colours
Click a row to copy the hex. These shades are most often referenced in semantic tokens and components.
Primitives – Full colour scales: neutral, indigo, red, yellow, green (50 → 950)
Click to copy hex values. Solid swatches are in-use theme colours.
neutral
Greyscale
indigo
Primary accent
red
Semantic error
yellow
Semantic warning
green
Semantic success
Typography
Single family (Inter / system stack). Hierarchy by size and weight. Default: 14px / 400 / neutral-900.
H1 – Page (24px / 800 / 36px)
H2 – Section (20px / 700 / 32px)
H3 – Section or Modal (16px / 700 / 24px)
H4 – Small Header (14px / 700 / 24px)
H5 – Group Title (12px / 700 / 20px / uppercase)
Body – default product copy at 14px.
Body Emphasised – 600 weight for inline emphasis.
Small – 12px for captions and metadata.
Secondary – neutral-700 for descriptions and hints.
Link – brand primary, darkens on hover.
Error text – validation messages.
Success text – confirmations.
Spacing, Sizing & Shape
4px grid. Gap classes .gap-1….gap-8; most common gap is 16px (--space-4).
Control height: 32px (lib-design-system sizes.x8) for buttons, inputs and selects; chips are 28px, or 24px with .chip-sm. Every interactive target clears the 24×24 minimum (SC 2.5.8).
Radii: 4px (sm – connector chips, small controls), 6px (md – buttons, inputs, selects: the workhorse), 8px (lg – cards), 9999px (pill – value chips, status pills, toggle tracks).
Logo & Icons
All assets ship as SVG sprite symbols. Click an icon to copy its SVG source.
Logo
Full wordmark, 118:32. The bexa mark is always brand-orange; the wording defaults to neutral-900, brand-navy on marketing surfaces.
Bexa
The logo mark without the wording, brand-orange. Scales freely.
Icon library (151 icons)
Most icons are stroke-only – outline icons using stroke="currentColor" – set color on the parent to tint. Partner marks and special cases appear at the end.
Symbols carry their own viewBox — 16-unit for most,
but workday is 10-unit and several Phosphor-derived icons
(info, warning, seal-check) are 256-unit. Write
<svg class="ds-icon"><use href="#ds-icon-…"/></svg> and size
it with CSS; hardcoding viewBox="0 0 16 16" on the wrapper silently breaks
the non-16 symbols. ds-icon-beamery is the 118×32 wordmark, not an
icon — use ds-icon-bexa for a square mark.
Buttons
Base class .btn + variant. One filled primary action per surface.
The secondary family carries a visible resting border (borders.form, rendered as an inset box-shadow).
Add a leading sprite with .btn-icon; use .btn-icon-only for icon-only controls (include aria-label).
Action menus attach a floating .list-panel to a secondary button — labelled or overflow (three dots).
Variants
Disabled = 0.5 opacity + cursor: not-allowed.
With icons
Icon-only
Small (28px)
.btn-sm matches chip height — for inline filter rows and compact toolbars.
Action menus
Overflow commands in a .list-panel (role="menu"). Wire with DS.initActionMenu.
Use a labelled btn-secondary trigger or an icon-only overflow button (ds-icon-more).
Icons only on high-signal items; secondary actions stay text-only.
States (secondary family)
| State | Fill | Border (inset) |
|---|---|---|
| Rest | controls.secondary (neutral-200) | borders.form (neutral-700) |
| Hover / Focus | controls.secondaryHover (neutral-400) | borders.formActive (neutral-900) |
| Active | controls.secondaryActive (neutral-500) | borders.formActive |
| Pressed (toggled) | – | borders.primary |
Inputs
Grey-fill control with a visible resting border. Placeholders never replace a visible <label>.
.typeahead-results as alias of .list-panel.
DS.initSelectPicker
data-select-any or the empty <option> text). Prefix inside trigger when a value is chosen (Sort by: Match score). No external label. Inline auto-width variant — .select-picker-labeled without .select-full.
.select-picker-chip or .qualifier-select inside .keyword-group.
States
Text inputs
| State | Fill | Border | Notes |
|---|---|---|---|
| Rest | controls.secondary (neutral-200) | borders.form (neutral-700) | Placeholder forms.placeholder (neutral-700; 750 on grey surfaces) |
| Hover | controls.secondaryHover (neutral-400) | borders.formActive (neutral-900) | |
| Focus | white | borders.primary (indigo-500) | + focus ring |
| Error | neutral-200 | borders.error (red-500) | Label text.error; aria-invalid + aria-describedby |
| Disabled | neutral-200 | borders.form | 0.5 opacity |
Select picker
btn-secondary trigger + list-panel listbox — same chrome as the Actions menu.
| State | Trigger | Panel | Notes |
|---|---|---|---|
| Rest | controls.secondary + caret | hidden | Selected value on trigger |
| Open | aria-expanded="true" | white surface + shadow | Click-outside / Escape closes |
| Option hover | – | backgrounds.secondary | |
| Selected option | – | aria-selected="true" | Medium weight + primary text |
| Focus | focus ring | item focus ring |
Select picker (labeled)
Prefix label inside the trigger — .select-picker-labeled + data-select-label. Selected: Sort by: value. Empty: Any … via data-select-any or empty <option> text. Full width with .select-full; inline auto-width without.
| State | Trigger | Panel | Notes |
|---|---|---|---|
| Selected | Label: + value + caret | hidden | Prefix bold; value regular weight |
| Empty / default | Any … + caret | hidden | Prefix hidden; full “Any X” phrase bold |
| Open | aria-expanded="true" | white surface + shadow | Same list-panel behaviour |
Select picker (chip)
28px pill trigger matching .chip — for chip-pair keyword rows (location radius, language level). Add .select-picker-chip or use .qualifier-select inside .keyword-group (excludes .threshold-value-select, filter-level .status-select).
| State | Trigger | Panel | Notes |
|---|---|---|---|
| Rest | white fill + borders.form + 12px caret | hidden | Same geometry as value chip |
| Hover | backgrounds.secondary + borders.formActive | – | |
| Open | aria-expanded="true" | white surface + shadow | Click-outside / Escape closes |
| Focus | focus ring | item focus ring |
Chips and toggles
Status pills, boolean connectors, interactive chips, single checkboxes, and on/off switches – shared affordances for filters, selections, and settings.
Pills
Compact status labels – 12px / 500, full pill radius. *-100 fill with matching dark text token.
Typically not interactive; used to communicate state (e.g. Active, Pending).
Boolean connectors
Boolean-logic connectors for the filter system. 11px / 700, radius 4px. All pairings ≥ 4.5:1 (AA).
Full state spec in Filter System/connector-chip-spec.md.
Chips
Default is 28px / 14px. Resting border borders.form; hover darkens the border, not just the fill.
.chip-sm is 24px / 12px for dense filter rows — the same modifier on value, radio, and checkbox chips.
Default – 28px
Small – 24px
Toggles
Off track is neutral-700 (≥3:1 vs page and thumb); on is brand primary. Three non-colour cues: thumb position, track colour, icon (SC 1.4.1). Use for immediate on/off settings with no separate save step. Default track is 44×24 (18px knob + 3px padding). When a label is used, it must declare the active state and stay the same when off — never swap to an inactive phrasing (that confuses which side is on).
Default – 24px track
Compact exploration – 16px track Candidate for prod
Smaller visuals for dense panels (e.g. filter rows). Hit target kept at ≥24×24 via padding on the label (.toggle--compact-hit), not the track – otherwise the thumb looks undersized inside a padded pill.
With description
Matches the lib-design-system description prop. Wrap in .toggle-field and put the .toggle-description outside the label, wired to the input with aria-describedby – it explains the setting without becoming part of its name.
Description
Checkbox
Binary choices that apply on Save / Apply / Submit, or as part of a batch action. Single option: 16×16 box, 4px radius, label at 14px / 400. Resting border borders.form; hover border borders.primary; checked fill controls.primaryActive with white check. Matches the lib-design-system Checkbox component.
Single option
When to use toggle vs checkbox
toggle = “turn this on now”; checkbox = “include this”.
Use a toggle when flipping the control should take effect immediately — a live on/off for a single setting. Examples: enable/disable an active filter without clearing it, show/hide a panel section in place.
Use a checkbox when the choice is part of a form, list, or batch action — especially if several options can be selected, or the change only applies after Save / Apply / Submit. Examples: row selection, “Select all”, optional columns, consent, sub-filters in a filter panel.
Multiple options
When several choices need configuring together, wrap chips around native checkbox or radio inputs – use .chip-check; selected styles apply when the nested input is checked. Every option stays visible as a pill. Add .chip-sm for the 24px / 12px size.
Default – 28px
Small – 24px
States — chips
| State | Fill | Border | Text |
|---|---|---|---|
| Rest | white | borders.form (neutral-700) | text.default; close icon text.secondary |
| Hover | backgrounds.secondary (neutral-200) | borders.formActive (neutral-900) | close icon darkens to text.default |
| Active | backgrounds.secondaryOnGray (neutral-300) | borders.formActive | |
| Selected | backgrounds.accent (indigo-100); hover accentHover (indigo-200) | borders.primaryActive (indigo-900) | text.primaryActive |
| Remove preview (prototype pattern) | white | text.error (red-500) | text.error – hovering a remove-only chip previews the destructive outcome |
| Focus | Focus ring (--focus-ring) on the chip; remove buttons get their own ring | ||
States — checkbox
| State | Fill | Border | Check |
|---|---|---|---|
| Rest | white | borders.form (neutral-700) | hidden |
| Hover | white | borders.primary (indigo-500) | hidden |
| Checked | controls.primaryActive (indigo-900) | borders.primaryActive | white check icon |
| Disabled | 50% opacity; cursor: not-allowed | ||
| Focus | Focus ring (--focus-ring) on the box | ||
States — boolean connectors
| Connector | Fill | Border | Text | Hover |
|---|---|---|---|---|
| OR · IS (neutral) | backgrounds.secondary | borders.default | text.secondary (4.6:1) | Whole fill darkens one neutral step; border borders.form |
| AND (amber) | backgrounds.warningSubtle (yellow-50) | borders.warningSubtle (yellow-150) | text.warningSubtle (yellow-750, 5.5:1) | Border only → warningSubtleHover (yellow-750) |
| IS NOT (red) | backgrounds.errorSubtle (red-50) | borders.errorSubtle (red-200) | text.error (red-500, 5.9:1) | Border only → borders.error |
| Locked | Identical to rest; cursor: not-allowed; not focusable (tabindex="-1"); no hover response | |||
| Focus | outline: 2px solid outlines.focus, offset 2px | |||
Data table
Author a real <table class="table">; DS.initDataTable() enhances it
from attributes on each <th>. Mirrors lib-design-system's DataTable.
Column attributes and row classes
Attribute on <th> |
Effect |
|---|---|
data-col="key" | Makes the column sortable. Without it the column is inert — sorting is opt-in, as in production. |
data-sort-type | text (default), number or date. Cells may carry data-sort-value to sort on something other than their visible text. |
data-default-dir | asc or desc — the direction a newly clicked column starts from. |
data-variant | text · numeric · link · emphasisedLink · actions · expansion · custom |
data-pinned="start" | Sticks the column to the left under horizontal scroll. Requires a .table-scroll wrapper; offsets are measured from real widths, so several columns can pin. |
data-pinned="end" | Sticks the column to the right — keeps a row action reachable when the table is wider than the viewport. Gets an opaque fill and a left-edge shadow while there is more to scroll. |
.table--align-top starts every cell at the top of its row – use it once a column wraps.
.cell-marker puts a marker on the same line as a cell's primary text (one flex line, so
the text ellipsises rather than dropping below). Row classes are authored, not derived:
.table__subrow and .table__subrow-label.
Sorting
Opt-in per column; the active column shows a single arrow.
Spec
aria-sort carries the state. Unsorted columns show bidirectional arrows in
text.secondary; the active column a single arrow in controls.primary.
Sortable headers take tabindex="0" and respond to Enter and Space. Headers are
12px / 400, text.secondaryOnGray on backgrounds.secondary (5.9:1).
Expandable rows
The parent row carries the latest record; its subrows carry the history.
| Expand history | Record | Reason | Attempts | Last attempt |
|---|---|---|---|---|
| Priya RamanContact · C3-37228 | Duplicate website/social links | 3 | 11 Aug, 08:45 | |
| Attempt 2 of 3 | duplicate-url | 10 Aug, 14:53 | ||
| Attempt 1 of 3 | duplicate-url | 7 Aug, 13:20 | ||
| Ife AdeyemiContact · C3-41006 | Email address is not valid | 1 | 1 Sep, 11:37 |
Spec
A parent row travels with the .table__subrow rows that follow it, so sorting moves
the whole group. The chevron is injected only where subrows exist, matching production. Columns
that belong to the group rather than the attempt stay blank on subrows.
Read and unread rows Candidate in progress
Not user tested. A dot marks unread; acknowledging greys the row; flag reinstates unread.
| Record | Reason | Actions |
|---|---|---|
| Not yet acknowledged. Ife Adeyemi | Email address is not valid | |
| Acknowledged. Hannah Okonjo | Email can't be changed once set in Workday |
Spec
Three redundant signals, all present or all absent: a .table__unread dot in
controls.primary inside .cell-marker; row text in text.default,
dropping to text.secondary (pills to 65%) via .is-acknowledged; and a
.table__ack button revealed on hover or focus – check acknowledges, flag reinstates.
Strikethrough was trialled and dropped: it obscures text the user may still need.
The reveal uses opacity, so the button stays in the tab order; touch devices pin it
visible. The marker carries .sr-only text in both states (SC 1.4.1). Reserve for
non-critical progress tracking – a control hidden until hover is one most users never find.
Pinned columns
Pin the identifier to the left and the row's ⋮ actions to the right; both hold under horizontal scroll.
| id | timestamp | entity | status | code | direction | type | beameryId | atsId | traceId | Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| 2026-08-11T08:45:47.572Z | candidate | rejected | duplicate-url | push | workday |
|
||||
| 2026-08-10T14:53:35.103Z | candidate | rejected | phone-number-or-email-address-required | push | workday | — |
|
Spec
Wrap the table in .table-scroll and set data-pinned="start" or
"end" on the <th>. Offsets are measured from rendered widths;
edge shadows appear only while there is more to scroll. Long identifiers go in
.cell-copy: one line, ellipsised, click to copy.
The actions column is an icon-only btn-subtle with ds-icon-more and an
aria-label naming the row. .table-scroll clips positioned children, so
DS.initActionMenu floats a menu inside it with fixed positioning, opening upward
when there is no room below; scrolling closes it. Cell tooltips must open into the table.
.table-scroll has no border of its own – wrap it in a .card for chrome.
Add .table-scroll--fill to take the remaining height of a flex-column parent
(display: flex; flex-direction: column; overflow: hidden); that is also what makes
the sticky <thead> stick.
Empty and loading states
An empty state when no data; a skeleton stands in before first data.
Spec
.table__empty replaces the rows when a filter matches nothing – say what would widen
the result, not just that there is nothing. .table__skeleton stands in for the whole
table before first data (production's LoadingTable); its shimmer is suppressed under
prefers-reduced-motion.
Infinite scroll Candidate for prod
Built for applicant review: batches of 25 load as the list nears its end, with a spinner while they arrive and no pagination footer.
Showing 25 of 75 – scroll the list
Spec
Keep an empty .infinite-sentinel as the last child of the scrolling list and call
DS.initInfiniteScroll({ root, sentinel, hasMore, loadMore }). When the sentinel comes
within 160px of view and hasMore() is true, a .load-more spinner
(role="status", announced as “Loading more”) goes in above it while
loadMore() runs; return a Promise if it is async. Call .reset() on the
returned handle after a new sort or filter replaces the list.
Pagination Candidate in progress
Doesn't match production's current design yet. A sibling of the table, not part of it.
Alerts
Alert text uses text.warning (yellow-800) / text.error / text.success on the matching 100-tier fill. Error alerts carry role="alert"; others role="status".
Please fix the highlighted fields before submitting.
Account created successfully.
Your trial expires in 3 days.
Tooltip
Markup and behaviour
Brief, plain-text hint. Wrap a focusable trigger in .tooltip with a
.tooltip__bubble child. It reveals on hover and keyboard focus after a
short delay, and only the hovered or focused one shows. Set data-position
to top (default), right, bottom or left
— this version does not auto-flip. data-delay="instant" skips the delay.
<span class="tooltip"> <button aria-describedby="tip-save">Save</button> <span class="tooltip__bubble" id="tip-save" role="tooltip" data-position="top">Saves your changes</span> </span>
When the tip adds information, point aria-describedby at a
role="tooltip" bubble. When it only repeats an aria-label
(icon-only controls), mark the bubble aria-hidden="true".
.info-tip is the 24px icon beside a label: same wrapper, with an
aria-label naming what it explains.
Dialog
A native <dialog class="dialog"> opened with showModal(), so Escape,
focus trapping and the backdrop come from the browser. scripts.js closes it on a
backdrop click with returnValue 'cancel'. Put the actions in a
<form method="dialog">: each button's value becomes the
returnValue, read on close. Cancel takes autofocus, and the
highest-priority action sits bottom-right. Return focus to the trigger on close. Named
.dialog because many prototypes already define a local .modal.
Accessibility (WCAG 2.2 AA)
- Form-control boundaries are visible at rest.
borders.form(neutral-700) gives 5.2:1 on white and 4.6:1 on the neutral-200 fill – comfortably above the 3:1 non-text minimum (SC 1.4.11). Hover state darkens toborders.formActive(neutral-900). - Placeholders:
forms.placeholder(neutral-700, 4.6:1 on neutral-200) andplaceholderOnGray(neutral-750, 5.9:1). Placeholders never substitute for a visible<label for>. - Focus ring (
0 0 0 2px white, 0 0 0 4px indigo-500) on every interactive element; connector chips use a 2px offset outline. Never remove:focus-visiblestyles. - Hover is supplementary – every interactive element has a resting affordance (border, fill, or underline). Hover/colour change is never the only signal.
- Text contrast:
text.default16.7:1,text.secondary5.2:1 on white; connector chips 4.6–5.9:1; white oncontrols.primary7.0:1. - Targets: 32px controls, 28px chips, 24px
.chip-sm– all clear the 24×24 minimum (SC 2.5.8). - Error inputs carry
aria-invalid="true"+aria-describedby; required fieldsaria-required="true"plus the visual*. - Icon-only buttons carry
aria-label. Locked connectors are unfocusable (tabindex="-1") withcursor: not-allowed. - Disabled controls are 0.5 opacity (exempt from contrast requirements) but keep their border so the field shape remains discernible.
TODO
Tracked here so the reference and the live system stay honest about what exists versus what's planned. Grouped by type; not in priority order.
Components to build
- Toasts / notifications — formalise the existing copy-confirmation toast into a documented component.
- Tabs —
role="tablist", arrow-key navigation. - Breadcrumbs.
- Progress indicators.
Foundations & tokens
- Motion tokens — standard durations and easings (currently hardcoded
0.15s). - Elevation / shadow tokens — documented scale (shadows exist but are undocumented).
- Breakpoint scale + responsive guidance (only ad-hoc media queries today).
- i18n considerations.
Governance & docs
- Decision guidance — which button variant when; an anti-patterns gallery; microcopy / voice.
- Accessibility testing checklist (axe + manual screen-reader) and per-component keyboard patterns.