Faces
variant picks the paint. Solid carries the primary action, outline and ghost sit beside it, soft is the quiet tint — and hero is the one that drifts, reserved for the single most important call on a page.
A button in five faces, four tones and three sizes — or a square around one glyph — drawn on the platform's own <button>, or on its <a>, when the control navigates.
@pacit/components/buttonbutton[pctButton], a[pctButton]Semantics come from the native <button> — no role and no keyboard handling of ours
import { Component } from '@angular/core';
import { PctButton } from '@pacit/components/button';
/** Five faces, one directive — `variant` picks the paint, the element stays a `<button>`. */
@Component({
selector: 'demo-button',
imports: [PctButton],
styles:
':host { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }',
template: `
<button pctButton variant="hero">Get started</button>
<button pctButton>Solid</button>
<button pctButton variant="outline">Outline</button>
<button pctButton variant="soft">Soft</button>
<button pctButton variant="ghost">Ghost</button>
<button pctButton loading>Saving…</button>
`,
})
export class ButtonDemo {}button.demo.ts — the same file runs here and is highlighted under Code
One import per component, and the shortest possible use — the package itself is installed once, on the start page.
import { PctButton } from '@pacit/components/button';| Exported | What it is |
|---|---|
PctButtoncomponentbutton[pctButton], a[pctButton] | Button. An attribute selector on a native <button> — or on a native <a>, when the control navigates — so semantics, keyboard handling and focus work natively (req-a11y-built-in). The paint is the same on both; what differs is the element, and the element is the consumer's (0071). |
PctButtonVarianttype'solid' | 'outline' | 'ghost' | 'soft' | 'hero' | How much of the page's colour a button takes: solid, outline, ghost, soft, or hero — the one loud face, with the gradient that drifts. |
PctButtonSizetypePctSize | The size of a button: the library's own scale. |
<button pctButton>Save</button> <a pctButton href="/start">Get started</a>
<button pctButton iconOnly variant="ghost" aria-label="Delete the draft">
<pct-icon icon="trash" />
</button>Each example is the component itself, not a screenshot. Open the code under any of them.
variant picks the paint. Solid carries the primary action, outline and ghost sit beside it, soft is the quiet tint — and hero is the one that drifts, reserved for the single most important call on a page.
tone picks which of the skin's families the face paints from — the same four names the toast and the progress bar wear. Move the choice and watch the four faces follow: there is no primary tone, because a button without one already is the brand, and None is what every button here is by default.
The fifth face is missing from the row on purpose. hero is the brand gradient and takes no tone at all; ask it for one and it says so in dev mode rather than quietly ignoring you.
The colour is never the message. "Delete account" says danger in words, and that is what a reader hears — a red button labelled "OK" tells nobody anything they could not see.
The tone is for the one press that cannot be taken back — and it works because of what stands beside it. The way out is the quiet face with no tone at all: a row where both buttons shout has no shape, and a reader who cannot see the red is left with the two labels, which is why the labels have to be the ones carrying the difference.
soft is the same tone at a lower volume, for the destructive action that is not the point of the screen — removing one row of a table rather than closing the account.
The dialog's own footer: one press that ends it, one that does not.
The same tones where the action is not the point of the screen.
A control that goes somewhere is an <a>, whatever it looks like: the middle click, the address bar's preview and a crawler's road through the site all come with the element and with nothing else. pctButton paints it and leaves the role alone — only the underline goes, because the face answers a pointer with its own background. A disabled link is the one thing HTML has no mechanism for, so the component says aria-disabled and refuses the press itself.
Three heights — 28, 36 and 44 px — shared with every field, so a button and an input on the same row line up to the pixel. The default comes from providePctConfig, not from the template.
Two states, kept apart on purpose. loading says "working": the spinner takes the face's own colour and the element reports aria-busy. disabled says "cannot be clicked". A queued click is a legitimate pattern, so loading alone never blocks — press Save to watch both at once.
iconOnly makes the face a square as tall as its size, with the glyph on the icon's own step for it — every face, tone and state is the same button's. There is nothing on it to read, so the name is written on the button: a tooltip with pctTooltipAs="name" shows it and speaks it, aria-label only speaks it. Press refresh to watch the spinner take the glyph's place.
A row of actions is the consumer's own flex box — pct-stack stacks, it does not row. The primary action goes last, the destructive one is kept at arm's length by the gap that grows between them.
The submit button is a native type="submit": the signal form's validity drives disabled, and the row heights match because the field and the button read the same size token. Clear the name to watch the button follow.
Read from the source at build time — the types, the defaults and the lines of documentation are the library's own.
unreleased marks 1 of them: on main, and not in @pacit/components@0.2.0 — the next release names the version.
Button. An attribute selector on a native <button> — or on a native <a>, when the control navigates — so semantics, keyboard handling and focus work natively (req-a11y-built-in). The paint is the same on both; what differs is the element, and the element is the consumer's (0071).
| Name | Type | What it does |
|---|---|---|
variantdefault 'solid' | PctButtonVariant | Picks the face. All five paint the same element; hero adds the drifting gradient and freezes it under reduced motion. |
tonesince 0.2.0default null | PctTone | null | Which of the skin's four families the face paints from — danger, warning, success, info — or null, the default, which is the brand. The names are shared with every other component that wears a tone rather than invented here ({@link PctTone}, 0076), and there is no primary member because primary is what a button with no tone already is. |
sizedefault config.defaultSize | PctButtonSize | Height 28 / 36 / 44 px — the axis every field shares, so rows line up. From providePctConfig by default (req-api-config). |
iconOnlyunreleaseddefault false | boolean | The face for one glyph and no words: a square as tall as the size, the glyph drawn on the icon's own step for that size, and 24 px under both axes whatever a skin does to the height. Geometry only — every face, tone and state above is the same button. |
disableddefault false | boolean | Blocks the click and greys the face; the grey is written to survive forced colors. On a link it is aria-disabled and a refused navigation — the platform has no disabled link. |
loadingdefault false | boolean | Shows the spinner in the face's own colour and sets aria-busy. Does not block the click — pair it with disabled when it should. |
(click), (focus), the form's submit — are the platform's, and two-way models above carry the rest. The hooks a stylesheet or a test can hold. Stable by contract — the parts inventory gate fails the build when one disappears.
| Attribute | Bound to | Note |
|---|---|---|
class | pct-button | |
data-pct-variant | variant() | |
data-pct-tone | appliedTone() | Absent when there is no tone, and absent on the hero face whatever was asked for: an attribute the stylesheet must not answer is better not written than written and ignored. |
data-pct-size | size() | |
data-pct-icon-only | iconOnly() ? "" : null | |
data-pct-loading | loading() ? "" : null | |
data-pct-disabled | isDisabled() ? "" : null | The face's own hook, written on both elements, because only one of them has a disabled attribute to key on and the stylesheet should not have to know which. |
disabled | isButton && isDisabled() ? "" : null | The platform's own refusal, where the platform has one: on a <button> the attribute blocks the click, drops the element out of the tab order and says "disabled" in the accessibility tree without anything being written for it. |
aria-disabled | !isButton && isDisabled() ? "true" : null | And where it has none. A link cannot be disabled — aria-disabled says the state, onClick refuses the navigation, and the element stays focusable on purpose: a control a reader cannot reach cannot tell them why it does nothing. |
aria-busy | loading() ? "true" : null | Only while the button actually works. aria-busy="false" is the default value, so writing it out adds nothing and stands in the accessibility tree of every button on the page. |
Two ways in, both stable. No class names to depend on.
In a test, PctButtonHarness from @pacit/components/testing reads a part by the name below and a state by its attribute — the harness gate holds the names to the package.
| Part | Selector | What it is |
|---|---|---|
label | [data-pct-part="label"] | the span around the projected content |
spinner | [data-pct-part="spinner"] | the loading ring, present only while loading |
Every value the component reads, with the light and the dark default. Override a token at any scope and it cascades like any custom property.
| Token | Styles |
|---|---|
--pct-button-bg{pct.primary}#2563eb · #60a5fa | Background of the solid face. |
--pct-button-bg-active{pct.primary-active}#1e40af · #bfdbfe | Solid face background while pressed. |
--pct-button-bg-disabled{pct.surface-disabled}#f1f5f9 · #1e293b | Background of any face while disabled. |
--pct-button-bg-hover{pct.primary-hover}#1d4ed8 · #93c5fd | Solid face background on hover. |
--pct-button-border{pct.primary}#2563eb · #60a5fa | Edge of the solid and outline faces. |
--pct-button-border-disabled{pct.border}#e2e8f0 · #334155 | Edge while disabled. |
--pct-button-fg{pct.on-primary}#ffffff · #0f172a | Label and spinner colour on the solid face. |
--pct-button-fg-disabled{pct.text-disabled}#64748b | Label colour while disabled — measured on purpose, though SC 1.4.3 exempts it. |
--pct-button-font-size{pct.font.size.md}14px | Label size at size md. |
--pct-button-font-size-lg{pct.font.size.lg}16px | Label size at size lg. |
--pct-button-font-size-sm{pct.font.size.sm}13px | Label size at size sm. |
--pct-button-font-weight{pct.font.weight.medium}500 | Label weight, every face. |
--pct-button-height{pct.control.height.md}36px | Height at size md — the control axis every field shares, so rows line up. |
--pct-button-height-lg{pct.control.height.lg}44px | Height at size lg. |
--pct-button-height-sm{pct.control.height.sm}28px | Height at size sm. |
--pct-button-icon-size20px | Glyph size on the icon-only face at size md. |
--pct-button-icon-size-lg24px | Glyph size on the icon-only face at size lg. |
--pct-button-icon-size-sm16px | Glyph size on the icon-only face at size sm. |
--pct-button-padding-x{pct.space.5}16px | Horizontal padding at size md. |
--pct-button-padding-x-lg{pct.space.5}16px | Horizontal padding at size lg. |
--pct-button-padding-x-sm{pct.space.4}12px | Horizontal padding at size sm. |
--pct-button-radius{pct.radius.md}8px | Corner radius of every face. |
--pct-button-target-min{pct.target.min}24px | Smallest height any face draws, and the smallest width of the square. |
A brand override in a few declarations. Scope it to a theme attribute, a page or one container — the component never notices.
[data-theme='brand'] {
--pct-button-bg: #0f766e;
--pct-button-bg-hover: #115e59;
--pct-button-fg: #ffffff;
--pct-button-radius: 999px;
} Pattern: a native <button> — or a native <a> when the control navigates; no role of its own on either, and no keyboard handling of its own (req-api-platform)
None of its own. Enter and Space come from the native <button>; a link answers Enter alone, because that is what a link does. That is req-api-platform at work, not an oversight — and it is also why the two elements differ here rather than being made to agree.
Every line is a test in three engines or a gate on every commit. The gaps are listed the same way, each with the requirement that will close it.
apps/sandbox-e2e/src/button.spec.ts — for the link alone, and only what is not the browser's: a real Tab reaches a disabled link, the ring it lights is the button's, and Enter on it navigates nowhereapps/sandbox-e2e/src/a11y.spec.ts (the /button view, the Links card among them)apps/sandbox-e2e/src/visual.spec.tslibs/components/button/src/button.spec.ts (the attribute, the hero refusal, the disabled state keeping it) and apps/sandbox-e2e/src/button.spec.ts (the paint: four tones × four faces, the grey that outranks a tone, the quiet face's hover ground, the soft face read at rest and under the pointer, and the press read on the SOLID face, where hover and active are different steps of the ramp — on the soft face they are one tint, so an assertion there would be satisfied by what hover already painted; it is the suite's only reading of :active on a button) and apps/sandbox-e2e/src/forced-colors.spec.ts (a toned face flattens to exactly what the untoned one flattens to, which is the whole reason a tone may not be the only channel) — the colours themselves are measured by libs/tokens/build.mjs against contrast.policy.json on every build, 32 pairs over two themesforced-colors: activeapps/sandbox-e2e/src/forced-colors.spec.ts and tools/check-styles.mjs (point 7) — the grey of the disabled state repeats the base rule's selector, or @media would not outrank it; loading keeps the variant's colour here too (lesson-70); the hero drops its gradient (an image survives the forcing, so it is dropped by hand) and the boundary-less faces get a ButtonText edge back — both read computed in three engines. A link is forced to LinkText on Canvas instead, so its edge is LinkText and its disabled state stays GrayText (0071)libs/components/button/src/button.scss — one pass of half the motion axis (four seconds) and the gradient stands still. It was 8s and endless; what changed is that it now ENDS, inside the five seconds the criterion allows motion to run with no control beside it, and the cost is that it reads twice as brisk while it runs. The pass is measured on [pctHero], whose fill face is the same sweep (apps/sandbox-e2e/src/hero.spec.ts); the button has no paused input of its own — the settle is what answers the criterion, and the extra stop lives on the component whose whole job is the faceprefers-reduced-motionapps/sandbox-e2e/src/preferences.spec.ts — the spinner slows down (still says "working"), the hero drift freezes outright (--pct-motion-drift-duration → 0s: decoration informs of nothing) — the two ends of the same axis (0008)apps/sandbox-e2e/src/target-min.spec.ts — both faces stand on --pct-button-target-min, and the spec reads each at 24 px with every height zeroed: the labelled button's height (its sizes clear it by themselves — 28 px at the smallest, 26 under compact density — and the floor is what a skin that shrinks them runs into; its width is its words and padding, because a width floor was measured letting it shrink past its label in a narrow row, which button.spec.ts now refuses) and the icon-only square on both axes, since it is only as wide as its height. apps/sandbox-e2e/src/button.spec.ts reads the squares, the glyph's step and the spinner in its place at every size, square in a row that squeezes or stretches it, in three engines (0085)apps/sandbox-e2e/src/size.spec.ts — measuring equality with the field row and the absolute valueapps/sandbox-e2e/src/density.spec.ts — at every size the button follows the field row into the compact scope (26 / 32 / 38 px), read beside it (0074)tools/check-styles.mjs (point 5, req-token-logical) over the stylesheet — three physical declarations, each a justified pct-exception: two border-right-color for the spinner's gap (a ring has no writing direction) and one background-position for the hero's drift — plus the button-variants-rtl baseline in apps/sandbox-e2e/src/visual.spec.tsapps/sandbox-e2e/src/hydration.spec.ts through visit()libs/components/parts.snapshot.md, tools/check-parts.mjs (target check-parts)contrast.policy.jsonlibs/tokens/src/contrast.policy.json, libs/tokens/build.mjsPCT_TEXTStools/check-texts.mjs — the component writes no strings of its own, and adding one fires the gatelibs/components/size.snapshot.md — the ./button row (req-project-tree-shaking)docs/acr/at/ — three readers, whole sandbox. Every variant announces as the same thing and nothing more: Outline · button. (Orca), Outline, button (NVDA), Outline button (VoiceOver), across solid, outline, ghost, soft and hero. That is the reading this card wanted — the variant is a look and a reader must not be able to hear it — and no reader did/components/button on the published site — prerendered, the demo's own source is the code tabRead at build time from the tracked snapshots, not typed here.
Five faces on two native elements. Each keeps its own semantics, its own keyboard handling and its own place in a form or in a crawler's index — the directive only paints, and every face is measured on both themes before it ships. A link wearing the face is still a link (0071): the role, the middle click and the address bar's preview are all still there, and only the underline goes.
tone="danger" paints; it writes no ARIA, and a reader hears "Delete account, button" whatever the colour. That is the reading the screen-reader row below records for the variant axis, and it holds for this one (0082) — which means a toned button whose LABEL does not carry the meaning says nothing to anyone who cannot see the colour, and no attribute here can repair that.hero takes no tone — the attribute is not written for it and dev mode says so once.info and the default look the same in this skin — deliberately: info and primary are the same blue, and only the role is separate.iconOnly makes a square around one glyph and nothing on it can be read, so the name is written on the button — aria-label, or pctTooltipAs="name", which shows it as well. An attribute on an element is not something a type can require: dev mode reports a square with no name at all, once, after the first render — a heuristic that errs towards silence — and the rest is the axe audit in the consumer's own suite (0085).aria-pressed on the native element is the platform's and is announced, but no face paints it — and an icon-only button is the most common toggle there is. A pressed state belongs to every button and is its own decision (0085).--pct-button-padding-x around a one-character label draws a target narrower than 24 px, and no gate says so — that is what iconOnly is for (0085).pct-icon in a labelled button stays 1em of the label (13 / 14 / 16 px); the square draws its glyph at 16 / 20 / 24. A toolbar that mixes the two shows two scales.loading does not block the click — disabled does. Deliberately separated: "working" and "cannot be clicked" are two different states, and a loading button without disabled is a legitimate pattern (when a click queues something, say). On a link the pair behaves the same way: loading alone announces aria-busy and lets the navigation through.disabled on a[pctButton] is aria-disabled, the greyed face and a refused press, and the element deliberately keeps its place in the tab order (0071). A consumer who needs it gone from the walk removes the href, which is the platform's own way of saying so.<a pctButton> with no href is reported, not repaired — it paints like a button and has no role, no tab stop and no keyboard. A dev-mode sentence names it once after the first render; nothing in the type system or in CSS can see it.