Shapes
shape="text" draws lines with a shorter last one, the way a paragraph ends; "block" holds a box — an image, a card — at whatever size the container gives it; "circle" holds a disc, sized on either axis, with the other following.
The shape of content that has not arrived, holding the room it is going to take.
@pacit/components/skeletonpct-skeletonNo APG pattern applies, and none is invented — the reasoning is under Accessibility ↓
import { Component, signal } from '@angular/core';
import { PctButton } from '@pacit/components/button';
import { PctSkeleton } from '@pacit/components/skeleton';
/** The shape of content that is not here yet — and aria-busy on the region that waits. */
@Component({
selector: 'demo-skeleton',
imports: [PctButton, PctSkeleton],
// A width and not only a ceiling: the stage centres its demo in a flex row, and a row hands a
// flex item the width of its content — which, for a component whose bars are 100% OF ITS
// CONTAINER, is nothing. Measured on the page before this line: a 0px skeleton, invisible,
// under a heading that promised one (`lesson-160`).
//
// And a height for the box the skeleton stands in. Three bars are exactly three line boxes,
// but the paragraph that replaces them wraps to two and brings a margin above and below —
// measured 100 to 112px on the host, and the stage recentred it: 6px under the button on
// every press (`lesson-249`). A floor of three lines and no margin keeps the box the
// skeleton's shape in both states, which is the component's own promise.
styles: `
:host {
display: grid;
gap: 12px;
inline-size: min(24rem, 100%);
}
div {
min-block-size: 3lh;
}
p {
margin: 0;
}
`,
template: `
<div [attr.aria-busy]="pending() ? 'true' : null">
@if (pending()) {
<pct-skeleton [lines]="3" />
} @else {
<p>The article arrived — three lines of it, exactly as promised.</p>
}
</div>
<button
pctButton
variant="outline"
size="sm"
(click)="pending.set(!pending())"
>
{{ pending() ? 'Deliver' : 'Reload' }}
</button>
`,
})
export class SkeletonDemo {
readonly pending = signal(true);
}skeleton.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 { PctSkeleton } from '@pacit/components/skeleton';| Exported | What it is |
|---|---|
PctSkeletonShapetype'text' | 'block' | 'circle' | Which drawing a skeleton is: the lines of a paragraph, one box, or one disc. |
PctSkeletoncomponentpct-skeleton | A skeleton: the shape of content that has not arrived, drawn where the content will be. |
<pct-skeleton [lines]="3" />Each example is the component itself, not a screenshot. Open the code under any of them.
shape="text" draws lines with a shorter last one, the way a paragraph ends; "block" holds a box — an image, a card — at whatever size the container gives it; "circle" holds a disc, sized on either axis, with the other following.
The three shapes arranged the way an application arranges them: a disc the avatar's own size, one short line where the name will be, three where the text will be — under ONE busy region. The wait is a fact about the card, not about each placeholder inside it.
Read from the source at build time — the types, the defaults and the lines of documentation are the library's own.
A skeleton: the shape of content that has not arrived, drawn where the content will be.
| Name | Type | What it does |
|---|---|---|
linesdefault 1 | number | How many lines of the surrounding text this stands in for — one by default. |
shapedefault 'text' | PctSkeletonShape | Lines of text, one box, or one disc — text by default. |
pauseddefault false | boolean | Stop the sheen where it stands — false by default, and the default is the promise. |
(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-skeleton | |
aria-hidden | true | The component draws and does not speak. Everything below it is a box with a colour, there is no text anywhere in the template and nothing in it can take focus, so hiding the subtree costs a reader nothing and saves them a description of content that does not exist (req-a11y-built-in, 0050). What a reader needs to hear is aria-busy on the region, and the region is not ours to write on. |
data-pct-shape | shape() | |
data-pct-paused | paused() ? "" : null |
Two ways in, both stable. No class names to depend on.
In a test, PctSkeletonHarness 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 |
|---|---|---|
fill | [data-pct-part="fill"] | the shimmer that travels it |
track | [data-pct-part="track"] | one line of the shape |
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-skeleton-fill-bg{pct.surface}#ffffff · #0f172a | Colour of the shimmer. |
--pct-skeleton-fill-blur6px | Blur radius of the shimmer. |
--pct-skeleton-fill-size100% | Width of the shimmer. |
--pct-skeleton-track-bg{pct.border}#e2e8f0 · #334155 | Background of the track. |
--pct-skeleton-track-height1cap | Height of the track. |
--pct-skeleton-track-radius{pct.radius.md}8px | Corner radius of the track. |
--pct-skeleton-track-width-last60% | Width of the track for the last line. |
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-skeleton-track-bg: #ccfbf1;
--pct-skeleton-fill-bg: #f0fdfa;
} Pattern: none — a skeleton is not a widget and announces nothing. It is aria-hidden="true" outright, the second component in the library to be after pct-icon; what a screen reader has to hear is aria-busy on the region whose content is late, which is the consumer's own element. Named in the class JSDoc.
| key | effect | test |
|---|
Empty by design, and for the strongest reason in the library so far: nothing here can be landed on at all. The subtree is hidden from the accessibility tree, so anything focusable in it would be axe's aria-hidden-focus — apps/sandbox-e2e/src/skeleton.spec.ts asks every element in the component to focus itself and counts zero, in three engines, and tools/check-aria.mjs point 8 refuses the general case at build time (req-api-platform).
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.
libs/components/skeleton/src/skeleton.tsapps/sandbox-e2e/src/skeleton.spec.ts — the empty map is the claim, so what is gated is that no element in the component takes focus in any of the three enginesapps/sandbox-e2e/src/a11y.spec.ts — /skeleton in SBX_ROUTES, so the WCAG 2.2 AA sweep takes it. aria-hidden-focus is the rule that would bite here, and the subtree holds nothing to land onapps/sandbox-e2e/src/visual.spec.ts — skeleton-shapes and skeleton-shapes-rtl: three lines with the short last one, a picture's box and a discforced-colors: active — no state carried by colour alonelibs/components/skeleton/src/skeleton.scss — the box keeps its extent through an outline: 1px solid GrayText over background: Canvas, and the sheen is GrayText (not Highlight: a page of waiting cards would be a wall of selections). The reading: apps/sandbox-e2e/src/forced-colors.spec.ts. The mechanism this refuses is a gradient shimmer, dropped outright in that modeprefers-reduced-motion — duration from a tokenapps/sandbox-e2e/src/preferences.spec.ts — the sheen reads 5250 ms — three and a half loops of the reduced token, the sheet's factor scaling the axis's answer rather than replacing it (the loop token slows, it does not stop: a still placeholder stops saying anything is coming). No media query stands in the component's sheetapps/sandbox-e2e/src/skeleton.spec.ts — paused freezes the sheen where it stands and the page can let it go again, in three engines, with the negative control beside it: a second skeleton the page did not name keeps moving. The sheen itself is not capped, and the reason is the component's whole point (0073); the report's row: docs/acr.md, SC 2.2.2req-a11y-touch sets has nothing to apply to--pct-control-height-*deliberately not, and one step further than the progress bar: there is no axis of ours at all. A line is 1lh and the bar inside it 1cap, both the browser's arithmetic over the consumer's type — apps/sandbox-e2e/src/skeleton.spec.ts reads the same component at two font sizes and gets two heights with nothing bounddir="rtl" screenshotlibs/components/skeleton/src/skeleton.scss — the short last line is placed by inline-size in a grid row and the sheen travels by inset-inline-start. The screenshot: skeleton-shapes-rtl; the geometry: apps/sandbox-e2e/src/rtl.spec.ts, where the short bar keeps to the reading direction's start edgeNG05xxapps/sandbox-e2e/src/hydration.spec.ts — /skeleton in SBX_ROUTESFormValueControllibs/components/parts.snapshot.md, tools/check-parts.mjs — the Parts row abovecontrast.policy.jsonlibs/tokens/src/contrast.policy.json — two entries, both warn with the reason written beside them (a placeholder has to be visible and must not read as content, and the sheen is motion rather than information); libs/tokens/tokens.snapshot.mdPCT_TEXTSnot applicable — the component draws no text at all. There is no default sentence to translate, and none to overridelibs/components/size.snapshot.md — the ./skeleton row, on @angular/core alone: it carries no ./core. No size input is no PCT_CONFIG to inject, no text is no PCT_TEXTS, no icon is no drawing to import — so what it ships is the component and nothing under itdocs/acr/at/ — three readers, whole sandbox. Not reachable by this instrument: a skeleton takes no focus, and /skeleton walks to the demo button and then out to the scaffold. Nothing in the three logs announces a placeholder, which is what is wanted and is not evidence of it — a Tab walk cannot tell silence from absence. VoiceOver spent one stop on its own instructions about entering the web area, which is the reader talking about itselfapps/sandbox/src/app/views/skeleton/ (the sandbox view). The published site: /components/skeleton — prerendered, the demo's own source is the code tablibs/components/skeleton/src/skeleton.spec.ts — 24 cases; libs/components/mutation.snapshot.md — skeleton.ts at 92.31, zero clock-kills, and both survivors equivalent: isDevMode() forced true, and parsed >= 1 weakened to parsed > 1, which no case can tell apart because the fallback IS the boundary — one line means one line whichever branch answersRead at build time from the tracked snapshots, not typed here.
The whole component is two answers (0050): it holds the space the content will take — a line is 1lh and the bar inside it 1cap, both computed by the browser out of the consumer's own type — and it says nothing, because there is nothing yet to say.
lines does not reach a block or a circle. It counts lines of text, and neither a box nor a disc has any, so shape="block" [lines]="3" draws one box. The alternative — a box three line boxes tall — needs the count inside the stylesheet, and a component may not invent a --pct-… property that is not a token of the skin.loading input and no content slot. The swap between the placeholder and the content is an @if the consumer already has, written where they can see it; a slot would put projected content inside an aria-hidden host, which is the one violation this component could cause.paused is the same boundary drawn once more: the page says when a wait has gone on too long for the motion to keep running, because the page is the only side that knows (0073).