CSS classes
Core is light DOM: it writes ordinary classes onto ordinary elements, and so can you.
The classes below are the ones aparté styles for its own use and leaves available — put
aparte-btn on a button of yours and it looks like the send button, with no component to
mount and no framework involved.
Your own class stays on the element for events and for targeting. It just stops carrying the look.
Everything here is themed by the CSS variables; the recipes read those tokens, so a rebrand reaches your buttons and aparté’s at the same time.
Controls
Section titled “Controls”Things a person operates. Each is one base class plus modifiers that compose — an intent says what the control means, a fill says how loudly to say it, and the two axes do not know about each other.
Button
Section titled “Button”aparté — the button. Ready-made classes; nothing to configure.
TWO AXES, and keeping them apart is the whole design. An INTENT says which colour the button means; a FILL says what to do with that colour. Seven intents times five fills is thirty-five buttons out of twelve classes, and every combination works because neither axis knows about the other.
An intent on its own is a ghost — the quietest form, and the one a chat uses most. Add a fill when the button has to carry more weight.
.aparte-btn-group fuses adjacent buttons into ONE control: the inner corners
drop, the shared borders overlap, and only the group’s ends keep the radius. Give
the wrapper role="group" and a name — visually one control, it should read as
one too. A SEGMENTED control is the same group with one segment selected: mark it
aria-pressed="true" (or aria-selected="true" under role="tablist") and it is
painted solid in the group’s intent, at rest — not only under the pointer.
This layer is NEUTRAL: it is what a UI library offers, not a summary of what this repo happens to use. A variant nothing wears still ships, because the plugin that needs it is not written yet, and its absence is what makes an author invent a seventh shade of orange.
Your own class stays on the element, for events and for targeting. It just stops carrying the look.
<button class="aparte-btn aparte-btn--primary aparte-btn--solid">Send</button><button class="aparte-btn aparte-btn--danger aparte-btn--outline">Delete</button><button class="aparte-btn aparte-btn--icon" aria-label="Copy"> <aparte-icon name="copy"></aparte-icon></button><div class="aparte-btn-group" role="group" aria-label="Versions"> <button class="aparte-btn aparte-btn--surface" aria-label="Previous"><aparte-icon name="prevBranch"></aparte-icon></button> <button class="aparte-btn aparte-btn--surface">1 / 2</button> <button class="aparte-btn aparte-btn--surface" aria-label="Next"><aparte-icon name="nextBranch"></aparte-icon></button></div><div class="aparte-btn-group" role="group" aria-label="View"> <button class="aparte-btn aparte-btn--primary aparte-btn--outline" aria-pressed="true">Chat</button> <button class="aparte-btn aparte-btn--primary aparte-btn--outline" aria-pressed="false">Preview</button></div>.aparte-btn · .aparte-btn--block · .aparte-btn--circle · .aparte-btn--danger · .aparte-btn--ghost · .aparte-btn--icon · .aparte-btn--info · .aparte-btn--lg · .aparte-btn--neutral · .aparte-btn--outline · .aparte-btn--pill · .aparte-btn--primary · .aparte-btn--secondary · .aparte-btn--sm · .aparte-btn--soft · .aparte-btn--solid · .aparte-btn--success · .aparte-btn--surface · .aparte-btn--warning · .aparte-btn--xl · .aparte-btn-group · .aparte-btn__icon · .aparte-icon
aparté — form fields. Ready-made classes; nothing to configure.
Every input control a UI library offers: text entry, textarea, select, checkbox,
radio, switch, range. The library renders into the LIGHT DOM — there is no shadow
root and no ::part() — so a class is the only handle a consumer has onto a native
form control, and appearance: none is how this file takes the browser’s own
chrome off a checkbox/radio/switch/range so these classes can repaint it.
.aparte-field is ONE shared recipe — border, background, radius, padding, focus
ring, placeholder — applied to <input>, <textarea> and <select> alike, so a form
built from all three still reads as one control family. Checkbox, radio and switch
are drawn from the native control itself (appearance: none plus a pseudo-element),
never a div standing in for one, so the browser’s own focus/keyboard/AT handling
stays intact — only the paint changes.
Checkbox, radio, switch and range carry an INTENT axis, exactly like the button: a checked checkbox can be success or danger, not only primary. Unlike the button there is no separate FILL axis here — a checkbox has exactly one filled look, not five — so intent is the only modifier each of these needs.
This layer is NEUTRAL: it is what a UI library offers, not a summary of what this repo happens to use. A control nothing wears yet still ships, because the form the repo hasn’t built is not a reason to leave it out.
<label class="aparte-field-label" for="email"> Email <span class="aparte-field-required" aria-hidden="true">*</span></label><input id="email" class="aparte-field" type="email" placeholder="you@example.com" /><p class="aparte-field-hint">We only use this to send a receipt.</p><p class="aparte-field-warning">Changing it signs you out of other devices.</p><div class="aparte-field-group"> <span class="aparte-field-group__prefix">https://</span> <input class="aparte-field" placeholder="example.com" /></div><p><label><input type="checkbox" class="aparte-checkbox aparte-checkbox--danger" /> Delete my account</label></p><p><label><input type="checkbox" role="switch" class="aparte-switch aparte-switch--success" checked /> Email notifications</label></p>.aparte-btn · .aparte-checkbox · .aparte-checkbox--danger · .aparte-checkbox--info · .aparte-checkbox--neutral · .aparte-checkbox--primary · .aparte-checkbox--secondary · .aparte-checkbox--success · .aparte-checkbox--warning · .aparte-color · .aparte-field · .aparte-field--lg · .aparte-field--sm · .aparte-field--textarea · .aparte-field-choice · .aparte-field-choice--boxed · .aparte-field-choice--selected · .aparte-field-choice__body · .aparte-field-error · .aparte-field-group · .aparte-field-group__prefix · .aparte-field-group__suffix · .aparte-field-hint · .aparte-field-label · .aparte-field-required · .aparte-field-row · .aparte-field-warning · .aparte-fieldset · .aparte-meter · .aparte-output · .aparte-radio · .aparte-radio--danger · .aparte-radio--info · .aparte-radio--neutral · .aparte-radio--primary · .aparte-radio--secondary · .aparte-radio--success · .aparte-radio--warning · .aparte-range · .aparte-range--danger · .aparte-range--info · .aparte-range--neutral · .aparte-range--primary · .aparte-range--secondary · .aparte-range--success · .aparte-range--warning · .aparte-switch · .aparte-switch--danger · .aparte-switch--info · .aparte-switch--neutral · .aparte-switch--primary · .aparte-switch--secondary · .aparte-switch--success · .aparte-switch--warning
Display
Section titled “Display”aparté — Display primitives: the non-interactive pieces a UI library offers.
Ready-made classes; nothing to configure.
This layer is NEUTRAL: it is what a UI library offers, not a summary of what this repo happens to use. A class nothing wears today still ships, because the plugin that needs it is not written yet.
Badge and alert repeat the button’s two-axis shape — an intent class sets a local custom property, a fill (or the intent alone) reads it — for the same reason: colour meaning and paint style are independent questions, and collapsing them into one flat list of classes is what produces a ninth accidental shade of orange.
<span class="aparte-badge aparte-badge--success aparte-badge--solid">Live</span><div class="aparte-progress" style="--aparte-progress-value: 60"> <div class="aparte-progress__bar"></div></div><div class="aparte-alert aparte-alert--warning" role="alert"> <span class="aparte-alert__icon"><aparte-icon name="alertTriangle"></aparte-icon></span> <div class="aparte-alert__body"> <p class="aparte-alert__title">Heads up</p> <p class="aparte-alert__message">Your session expires soon.</p> </div></div>Avatar
Section titled “Avatar”aparte-avatar — sizes, shape, image + initials fallback
--aparte-avatar-size is NOT invented here: it already exists centrally for the
message avatar (bubble.css), and this component reads the same one — a generic
avatar and a chat-message avatar are the same object, and a size modifier applied
here resizes a role-coloured message avatar too. The corner, the initials and the
group’s overlap are FRACTIONS of that size (--aparte-avatar-radius-ratio,
--aparte-avatar-initials-ratio, --aparte-avatar-overlap-ratio), computed on the
element so a size modifier moves them: as absolute tokens the 40px avatar drew the
same 11px initials as the 32px one, the corner drifted from squircle to square up
the ramp, and a 6px overlap was a fifth of a small avatar and a tenth of a large one.
<span class="aparte-avatar aparte-avatar--sm">AP</span><span class="aparte-avatar">JD</span><span class="aparte-avatar aparte-avatar--lg aparte-avatar--square">MK</span><span class="aparte-avatar-group"> <span class="aparte-avatar">AP</span> <span class="aparte-avatar">JD</span> <span class="aparte-avatar">+3</span></span>.aparte-avatar · .aparte-avatar--circle · .aparte-avatar--lg · .aparte-avatar--sm · .aparte-avatar--square · .aparte-avatar--xl · .aparte-avatar--xs · .aparte-avatar-group · .aparte-avatar__image
aparté — the icon. Every glyph the library draws carries this class.
The glyph does NOT carry its own size. It used to — width="14", width="16",
width="20" baked into the markup — and that is precisely what kept the same drawing
from being shared: an ✕ sized for a remove button could not also be the ✕ of a tool
call, so the library ended up with three of them, on three grids, at three stroke
widths. Size is a property of the PLACE an icon sits in, so it is declared by the
container and inherited down.
--aparte-icon-size is therefore the one knob, and it inherits: set it on any
ancestor and every glyph below follows. A container with a rule of its own
(.aparte-action-button svg, .aparte-tool-icon svg) still wins on specificity —
those already expressed size in CSS and are left alone.
<aparte-icon name="check"></aparte-icon>.aparte-icon · .aparte-icon--lg · .aparte-icon--sm · .aparte-icon--xl · .aparte-icon-spin
aparte-badge — small count or status pill
Two axes like the button’s: an intent says what it means, a fill says how loudly.
--dot is the third form — no label, so it is always painted solid.
<span class="aparte-badge aparte-badge--success aparte-badge--solid">Live</span><span class="aparte-badge aparte-badge--warning">Draft</span><span class="aparte-badge aparte-badge--danger aparte-badge--outline">Failed</span><span class="aparte-badge aparte-badge--info aparte-badge--sm">12</span><span class="aparte-badge aparte-badge--danger aparte-badge--dot" aria-label="Unread"></span>.aparte-badge · .aparte-badge--danger · .aparte-badge--dot · .aparte-badge--info · .aparte-badge--lg · .aparte-badge--neutral · .aparte-badge--outline · .aparte-badge--primary · .aparte-badge--secondary · .aparte-badge--sm · .aparte-badge--soft · .aparte-badge--solid · .aparte-badge--success · .aparte-badge--warning
aparte-tag — a removable chip
<span class="aparte-tag"> <span class="aparte-tag__label">typescript</span> <button class="aparte-tag__remove" aria-label="Remove typescript"><aparte-icon name="close"></aparte-icon></button></span><span class="aparte-tag aparte-tag--sm"><span class="aparte-tag__label">draft</span></span>.aparte-tag · .aparte-tag--sm · .aparte-tag__label · .aparte-tag__remove
Thumbnail
Section titled “Thumbnail”aparté — the thumbnail. A media tile: a fixed square that clips whatever is dropped
into it, with room for a control in a corner.
Distinct from .aparte-avatar, which is the same shape for a different reason: an
avatar stands for a PERSON and falls back to initials, so it carries type styling and
a text colour. A thumbnail stands for a FILE and falls back to an icon or a label, so
it carries none of that — and it is position: relative because a remove button or a
badge sits on it, which an avatar never has.
<span class="aparte-thumbnail aparte-thumbnail--lg"><img class="aparte-thumbnail__image" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 2 3'%3E%3Crect width='2' height='3' fill='%23b07d33'/%3E%3C/svg%3E" alt="A screenshot"></span><span class="aparte-thumbnail"><span class="aparte-thumbnail__label">PDF</span></span><span class="aparte-thumbnail aparte-thumbnail--sm"><span class="aparte-thumbnail__label">MD</span></span>.aparte-attachments · .aparte-thumb · .aparte-thumb--file · .aparte-thumb--image · .aparte-thumb__name · .aparte-thumbnail · .aparte-thumbnail--lg · .aparte-thumbnail--sm · .aparte-thumbnail__image · .aparte-thumbnail__label
Spinner
Section titled “Spinner”aparte-spinner — a rotating indicator
<span class="aparte-spinner aparte-spinner--sm" role="status" aria-label="Loading"></span><span class="aparte-spinner" role="status" aria-label="Loading"></span><span class="aparte-spinner aparte-spinner--lg" role="status" aria-label="Loading"></span>.aparte-spinner · .aparte-spinner--lg · .aparte-spinner--sm
Progress
Section titled “Progress”aparte-progress — determinate bar + indeterminate variant
The consumer owns the ARIA: role=“progressbar” plus aria-valuenow/min/max on the determinate form, and aria-valuetext (or nothing, per platform convention) on the indeterminate one — this file only paints what those attributes mean.
<div class="aparte-progress" role="progressbar" aria-valuenow="60" aria-valuemin="0" aria-valuemax="100" style="--aparte-progress-value: 60"> <div class="aparte-progress__bar"></div></div><div class="aparte-progress aparte-progress--indeterminate" role="progressbar" aria-label="Working"> <div class="aparte-progress__bar"></div></div>.aparte-progress · .aparte-progress--indeterminate · .aparte-progress__bar
Skeleton
Section titled “Skeleton”aparte-skeleton — a loading placeholder
Decorative: give the element aria-hidden=“true” (or wrap the group behind a single aria-busy region) so a screen reader does not read out an empty box. A skeleton is a block with no margin of its own: the recipe spaces consecutive text lines and nothing else, so a GROUP is laid out by its container — a grid with a gap, the way the real card it stands in for is. Four lines glued edge to edge is what the example used to show.
The last line is shorter because the last line of a paragraph is, and the block sits
in the text column, under the lines it stands for — not across the avatar’s. The
rect’s height is the family’s token (--aparte-skeleton-rect-height), not an inline
value that contradicts it.
<div style="display: grid; grid-template-columns: auto 1fr; gap: var(--aparte-space-3); align-items: start" aria-busy="true"> <span class="aparte-skeleton aparte-skeleton--circle" aria-hidden="true"></span> <div> <span class="aparte-skeleton aparte-skeleton--text" aria-hidden="true"></span> <span class="aparte-skeleton aparte-skeleton--text" aria-hidden="true"></span> <span class="aparte-skeleton aparte-skeleton--text" style="max-inline-size: 60%" aria-hidden="true"></span> </div> <span class="aparte-skeleton aparte-skeleton--rect" style="grid-column: 2" aria-hidden="true"></span></div>.aparte-skeleton · .aparte-skeleton--circle · .aparte-skeleton--rect · .aparte-skeleton--text
Divider
Section titled “Divider”aparte-divider — horizontal, vertical, optionally labelled
The line is a BORDER, not a background — see the progress bar’s comment above for why: a background disappears under forced-colors, a border does not.
<hr class="aparte-divider"><div class="aparte-divider aparte-divider--label"> <span class="aparte-divider__label">or</span></div>.aparte-divider · .aparte-divider--label · .aparte-divider--vertical · .aparte-divider__label
aparte-alert — a callout
<div class="aparte-alert aparte-alert--info" role="status"> <span class="aparte-alert__icon"><aparte-icon name="info"></aparte-icon></span> <div class="aparte-alert__body"> <p class="aparte-alert__title">Model switched</p> <p class="aparte-alert__message">The next reply uses the model you just picked.</p> </div> <button class="aparte-alert__dismiss" aria-label="Dismiss"><aparte-icon name="close"></aparte-icon></button></div><div class="aparte-alert aparte-alert--danger" role="alert"> <span class="aparte-alert__icon"><aparte-icon name="alertTriangle"></aparte-icon></span> <div class="aparte-alert__body"> <p class="aparte-alert__message">The request was refused: no API key is set.</p> </div></div>.aparte-alert · .aparte-alert--danger · .aparte-alert--info · .aparte-alert--neutral · .aparte-alert--primary · .aparte-alert--secondary · .aparte-alert--success · .aparte-alert--warning · .aparte-alert__body · .aparte-alert__dismiss · .aparte-alert__icon · .aparte-alert__message · .aparte-alert__title
aparte-card — a surface with optional header/body/footer
<div class="aparte-card"> <div class="aparte-card__header">Usage this month</div> <div class="aparte-card__body"> <p>412 000 tokens across 38 conversations.</p> </div> <div class="aparte-card__footer">Resets on the 1st.</div></div>.aparte-card · .aparte-card__body · .aparte-card__footer · .aparte-card__header
aparte-kbd — a keyboard key
The monospace stack is —aparte-code-font-family, reused rather than declared again: it is already the library’s one answer to “what font is code”, and a key label is exactly that question asked a second time.
Press <kbd class="aparte-kbd">Ctrl</kbd> + <kbd class="aparte-kbd">K</kbd> to search,or <kbd class="aparte-kbd">Esc</kbd> to close..aparte-kbd
aparté — The mark: a row a list has settled on.
One of the sheets split out of aparte.css. Order matters: src/index.ts imports them in the order listed there, and check:derived-vars reads them concatenated in that same order, the way a browser does.
WHAT IT IS. A chosen option, an accepted answer, the conversation you are in: the one item among its peers that a decision landed on. Before this sheet the select drew it one way (an accent tint plus an inset bar on the start edge), the field choice another (a primary border), the conversation list a third (its own background and weight), and the ask-user receipt none at all — four vocabularies for one meaning, measured in the UI audit of 2026-08-28. The market agrees on two signals: a tint on the ground (Radix, shadcn, Material’s state layer) and a bar on the row’s start edge (GitHub Primer’s ActionList). This recipe is both, on one intent axis, and every row that means “this one” wears it.
THE AXIS. --aparte-mark-intent is the fill, primary by default: chosen. The
modifiers set it to the theme’s other masters — success for an answer that was
given or a call that was approved, danger for a destructive outcome, neutral for
the item you are merely in. --quiet is the outcome that did NOT happen: a declined
request, a version superseded — no tint, no bar, the muted voice. The market never
strikes through; neither does this.
THE BAR IS DRAWN IN INK. The tint is the fill at 18%, which carries almost no
contrast by itself (1.2:1 in the light theme); the bar is the whole non-text signal,
so it reads --aparte-mark-intent-ink — the master’s ink, the one the button and
the badge already derive for text on a ground. The raw success fill measured 2.27:1
on the light surface, under the 3:1 WCAG asks of a graphic; its ink is 7.1:1.
THE BAR IS A PSEUDO-ELEMENT, NOT A SHADOW. An inset shadow knows only left and
right, so a right-to-left row got its bar on the wrong edge unless a document-level
[dir="rtl"] rule caught it — which a dir on the row itself, or dir="auto",
escapes. inset-inline-start is the start edge whatever the direction, at any level.
Nothing shifts: the pseudo-element is out of the flow.
THE MEASURES. --aparte-mark-tint and --aparte-mark-bar live in theme.css so a
theme moves every mark at once.
.aparte-mark · .aparte-mark--danger · .aparte-mark--neutral · .aparte-mark--quiet · .aparte-mark--success
Surfaces
Section titled “Surfaces”aparté — layered surfaces. Ready-made classes for the things that float ABOVE the
transcript: tabs, an accordion, a dropdown menu, a popover, a tooltip.
Drawer and toast are deliberately NOT here: each needs a stack manager — something
that owns the order of what floats over the page — and that is the consuming
application’s job, not a component stylesheet’s. The dialog was on that list until
0.16.0; it has its own recipe now (dialog.css), because the browser’s <dialog>
brings the top layer, the focus trap and Escape with it.
Everything below is the LOOK only. Where and when a surface opens is markup and script the caller owns: a <details open> toggling itself, a positioning library placing a menu next to its trigger. Nothing here reaches for position:fixed on your behalf.
.aparte-popover and .aparte-tooltip are single boxes: drop content straight in
and position the element yourself, however the app already places floating things
(an absolutely-placed div, a floating-UI anchor, a JS library’s own logic).
This layer is NEUTRAL: it is what a UI library offers, not a summary of what this repo happens to use. A class nothing wears today still ships, because the plugin that needs it is not written yet.
<div class="aparte-tabs" role="tablist" aria-label="Result view"> <button class="aparte-tabs__tab" role="tab" type="button" id="tabs-a-preview" aria-controls="tabs-a-preview-panel" aria-selected="true" tabindex="0">Preview</button> <button class="aparte-tabs__tab" role="tab" type="button" id="tabs-a-code" aria-controls="tabs-a-code-panel" aria-selected="false" tabindex="-1">Code</button></div><div class="aparte-tabs__panel" role="tabpanel" id="tabs-a-preview-panel" aria-labelledby="tabs-a-preview">The panel for the selected tab.</div><div class="aparte-tabs__panel" role="tabpanel" id="tabs-a-code-panel" aria-labelledby="tabs-a-code" hidden>The panel the other tab opens.</div><div class="aparte-accordion"> <details class="aparte-accordion__item" open> <summary class="aparte-accordion__header">Section title <aparte-icon name="expand"></aparte-icon> </summary> <div class="aparte-accordion__panel">What the section says once it is open.</div> </details></div><div class="aparte-menu" role="menu"> <div class="aparte-menu__label">Export as</div> <button class="aparte-menu__item" role="menuitem">PNG</button> <div class="aparte-menu__separator"></div> <button class="aparte-menu__item" role="menuitemcheckbox" aria-checked="true"> Include metadata </button></div>aparte-tabs — one row of tabs, and the panel under it
Two looks, one behaviour. --underline marks the current tab with a rule beneath it
and is the quieter of the two; --segmented puts the whole row in a track and fills
the current tab, which reads as a control rather than as navigation. Neither moves
anything: aria-selected is what says which tab is current, and the app owns it.
The markup below is the whole contract of the role, and all of it is yours to write:
ONE tab stop for the list (tabindex="0" on the selected tab, -1 on the rest),
aria-controls from each tab to its role="tabpanel", and aria-labelledby back.
The app also owns the ArrowLeft/ArrowRight/Home/End handler that moves the selection
and the stop together — @aparte/plugin-artifacts’ card has a working one to copy.
A tablist without those is louder than plain buttons and says less: every tab a tab
stop, and none of them naming what it opens.
<div class="aparte-tabs aparte-tabs--underline" role="tablist" aria-label="Result view"> <button class="aparte-tabs__tab" role="tab" type="button" id="tabs-u-preview" aria-controls="tabs-u-preview-panel" aria-selected="true" tabindex="0">Preview</button> <button class="aparte-tabs__tab" role="tab" type="button" id="tabs-u-code" aria-controls="tabs-u-code-panel" aria-selected="false" tabindex="-1">Code</button></div><div class="aparte-tabs__panel" role="tabpanel" id="tabs-u-preview-panel" aria-labelledby="tabs-u-preview">What the selected tab shows.</div><div class="aparte-tabs__panel" role="tabpanel" id="tabs-u-code-panel" aria-labelledby="tabs-u-code" hidden>What the other tab shows.</div><div class="aparte-tabs aparte-tabs--segmented" role="tablist" aria-label="Theme"> <button class="aparte-tabs__tab" role="tab" type="button" id="tabs-s-light" aria-controls="tabs-s-light-panel" aria-selected="true" tabindex="0">Light</button> <button class="aparte-tabs__tab" role="tab" type="button" id="tabs-s-dark" aria-controls="tabs-s-dark-panel" aria-selected="false" tabindex="-1">Dark</button> <button class="aparte-tabs__tab" role="tab" type="button" id="tabs-s-system" aria-controls="tabs-s-system-panel" aria-selected="false" tabindex="-1">System</button></div><div class="aparte-tabs__panel" role="tabpanel" id="tabs-s-light-panel" aria-labelledby="tabs-s-light">The light theme's settings.</div><div class="aparte-tabs__panel" role="tabpanel" id="tabs-s-dark-panel" aria-labelledby="tabs-s-dark" hidden>The dark theme's settings.</div><div class="aparte-tabs__panel" role="tabpanel" id="tabs-s-system-panel" aria-labelledby="tabs-s-system" hidden>Whatever the system asks for.</div>.aparte-tabs · .aparte-tabs--segmented · .aparte-tabs--underline · .aparte-tabs__panel · .aparte-tabs__tab
Accordion
Section titled “Accordion”aparte-accordion — stacked disclosures
A column of items, each a header you press and a panel it reveals. The header works
as a native <summary> inside <details> OR as a <div role="button" aria-expanded>
— that choice decides whether the open state is the browser’s or yours, and the CSS
does not care either way. It is the whole clickable row, so it takes the same reset a
button does.
The chevron turns 180° when the item is open, and there are two selectors for it
because there are two ways to be open: aria-expanded on the header for the ARIA
pattern, details[open] on the ancestor for the native one. Nothing here opens or
closes anything — this is a stylesheet, and the disclosure is the element’s job or
yours.
<div class="aparte-accordion"> <details class="aparte-accordion__item" open> <summary class="aparte-accordion__header"> Shipping <aparte-icon name="expand"></aparte-icon> </summary> <div class="aparte-accordion__panel">Free above 50 €, otherwise 4,90 €. Two to four working days.</div> </details> <details class="aparte-accordion__item"> <summary class="aparte-accordion__header"> Returns <aparte-icon name="expand"></aparte-icon> </summary> <div class="aparte-accordion__panel">Thirty days, in the original packaging.</div> </details> <details class="aparte-accordion__item"> <summary class="aparte-accordion__header"> Payment <aparte-icon name="expand"></aparte-icon> </summary> <div class="aparte-accordion__panel">Card, PayPal, or an invoice for a company account.</div> </details></div>.aparte-accordion · .aparte-accordion__header · .aparte-accordion__icon · .aparte-accordion__item · .aparte-accordion__panel · .aparte-icon
aparte-menu — a floating list of commands
The surface a dropdown, a context menu or an overflow menu is drawn on: a bordered, elevated column of rows. Items are the pressable ones, a label groups them, a separator divides them.
The check mark is DRAWN HERE, not by you: an item carrying aria-checked (a
menuitemradio or menuitemcheckbox) reserves a gutter, and the glyph shows when
the value is "true" — so set the attribute and do not add a ”✓” of your own, or the
row shows two. A two-line item wraps its name and its __description in a __body;
the gutter then spans both lines.
WHERE it sits is yours. There is no positioning here and no anchoring logic — floating a box next to a trigger needs collision detection and a scroll listener, which is an interaction library’s job, not a stylesheet’s. Same split as the popover and the tooltip below it.
The roles above are not decoration either: this draws a menu, it does not announce
one. role="menu" / role="menuitem" and the arrow-key handling that pattern
requires are yours to add.
<div class="aparte-menu" role="menu"> <span class="aparte-menu__label">Message</span> <button class="aparte-menu__item" role="menuitem">Copy</button> <button class="aparte-menu__item" role="menuitem">Retry</button> <div class="aparte-menu__separator" role="separator"></div> <button class="aparte-menu__item" role="menuitem" disabled>Delete</button> <div class="aparte-menu__separator" role="separator"></div> <span class="aparte-menu__label">Mode</span> <button class="aparte-menu__item" role="menuitemradio" aria-checked="true"> <span class="aparte-menu__body"> Ask <span class="aparte-menu__description">Every tool call waits for you</span> </span> </button> <button class="aparte-menu__item" role="menuitemradio" aria-checked="false"> <span class="aparte-menu__body"> Auto <span class="aparte-menu__description">Runs everything, reports after</span> </span> </button></div>.aparte-menu · .aparte-menu__body · .aparte-menu__description · .aparte-menu__item · .aparte-menu__label · .aparte-menu__separator
Popover
Section titled “Popover”aparte-popover — a floating box, and nothing else
Deliberately just a box. A popover’s content is arbitrary — a form, a colour picker, a card of details — so this supplies the surface, border and elevation that say “this is floating above the page” and leaves the layout of what is inside, and where the box itself sits, entirely to the caller.
--aparte-popover-max-width is the one cap it imposes, so arbitrary content cannot
grow the box unbounded.
<div class="aparte-popover" role="dialog" aria-label="Filters"> <label class="aparte-field-label" for="q">Search</label> <input class="aparte-field" id="q" /> <button class="aparte-btn aparte-btn--primary aparte-btn--solid">Apply</button></div>.aparte-popover
Dialog
Section titled “Dialog”aparte-dialog — a modal, drawn on the native <dialog>.
The kit said for a year that a dialog was “deliberately absent — a modal needs a
portal and a stack manager”. The browser has had both since 2022: <dialog> with
showModal() puts the box in the top layer above everything, traps focus, closes
on Escape, hands the focus back, and draws a ::backdrop. So this recipe styles
THAT element, and core’s one addition is the wiring — a data-aparte-dialog-open
on any control opens the dialog it names, a data-aparte-dialog-close inside one
closes it, and a click on the backdrop closes it unless the dialog carries
data-aparte-dialog-static. Nothing here reaches for position:fixed on your behalf:
the top layer is the browser’s.
Sizes: --sm (24rem), the default (32rem), --lg (48rem); under 30rem of window
every size becomes a full-screen sheet, which is what a settings hub is on a phone.
The value passed to data-aparte-dialog-close="…" becomes the dialog’s
returnValue, read in its native close event.
<dialog class="aparte-dialog" id="settings" aria-labelledby="settings-title"> <div class="aparte-dialog__header"> <h2 class="aparte-dialog__title" id="settings-title">Settings</h2> <button class="aparte-btn aparte-btn--icon aparte-btn--sm aparte-dialog__close" type="button" aria-label="Close" data-aparte-dialog-close> <aparte-icon name="close"></aparte-icon> </button> </div> <div class="aparte-dialog__body"> <label class="aparte-field-label" for="endpoint">Endpoint</label> <input class="aparte-field" id="endpoint" value="http://localhost:11434/v1"> </div> <div class="aparte-dialog__footer"> <button class="aparte-btn aparte-btn--ghost" type="button" data-aparte-dialog-close>Cancel</button> <button class="aparte-btn aparte-btn--primary aparte-btn--solid" type="button" data-aparte-dialog-close="saved">Save</button> </div></dialog><button class="aparte-btn aparte-btn--surface" type="button" data-aparte-dialog-open="settings">Open settings</button>.aparte-dialog · .aparte-dialog--lg · .aparte-dialog--sm · .aparte-dialog__body · .aparte-dialog__close · .aparte-dialog__footer · .aparte-dialog__header · .aparte-dialog__title
Tooltip
Section titled “Tooltip”aparte-tooltip — a short label, with an arrow
A tooltip has to be legible against whatever it happens to sit on top of — a bright image, a dark code block, either app theme — so its colours are fixed literals rather than the themed surface/text tokens. This box does NOT follow the palette, on purpose, and skips the dark-theme override block for that reason; the artifact “paper” tokens are the same decision made for the same reason.
data-side places it — top, bottom, start or end — against the
.aparte-tooltip-anchor that wraps the trigger, --aparte-tooltip-gap away, and turns
the arrow to match. The two inline styles that used to do this in the example were a
knob missing, not a division of labour: a demo that needs inline styles to work is a
recipe with a parameter it forgot. What stays yours is the viewport: nothing here flips
a tooltip that would leave the screen — that needs script, a positioning library’s job.
Without data-side the box is not positioned at all, as before, and its placement is
yours (the one inline style left in the example is the demo’s own margin).
pointer-events: none is not a detail: a tooltip describes what the pointer is over,
so it must never itself become that thing, or hovering it re-triggers the hover that
produced it.
<span class="aparte-tooltip-anchor" style="margin-block-start: 2.5rem; margin-inline-start: 5rem"> <div class="aparte-tooltip" data-side="top" role="tooltip"> Copy to clipboard <span class="aparte-tooltip__arrow"></span> </div> <button class="aparte-btn aparte-btn--icon" aria-label="Copy"><aparte-icon name="copy"></aparte-icon></button></span>.aparte-tabs--segmented · .aparte-tabs__tab · .aparte-tooltip · .aparte-tooltip-anchor · .aparte-tooltip__arrow
aparté — the shell: the chrome a whole application puts around the chat. A sidebar
of conversations, a header, and the grid that holds them beside the transcript — what a ChatGPT-style page is made of, so a site can be built on aparté alone.
One of the sheets src/index.ts imports in cascade order; check:derived-vars reads them concatenated in that same order, the way a browser does.
Sidebar
Section titled “Sidebar”aparte-sidebar — the column beside the chat.
A sized column that becomes a drawer under 48rem of window. The recipe draws the
column and its four regions; <aparte-sidebar> adds the behaviour (collapse, the
drawer, the search filter) and sets data-drawer on itself from the media query,
which is the one attribute the rules below read.
The element wears the same class and adds the behaviour; <aside class="aparte-sidebar">
is the look alone, which is what this page shows.
<aside class="aparte-sidebar"> <div class="aparte-sidebar__header"> <span class="aparte-sidebar__brand">aparté</span> <button class="aparte-btn aparte-btn--icon aparte-btn--sm" type="button" aria-label="New chat"> <aparte-icon name="edit"></aparte-icon> </button> </div> <div class="aparte-sidebar__search aparte-field-group"> <input class="aparte-field aparte-field--sm" type="search" placeholder="Search" data-aparte-sidebar-search> </div> <div class="aparte-sidebar__body"> <aparte-conversation-list></aparte-conversation-list> </div> <div class="aparte-sidebar__footer"> <span class="aparte-avatar aparte-avatar--sm">P</span> Paul </div></aside>.aparte-sidebar · .aparte-sidebar__body · .aparte-sidebar__brand · .aparte-sidebar__footer · .aparte-sidebar__header · .aparte-sidebar__scrim · .aparte-sidebar__search
App Header
Section titled “App Header”aparte-app-header — the bar above the chat.
A recipe and nothing more, because a header has no behaviour of its own: a toggle
for the sidebar (which <aparte-sidebar> wires from the data-aparte-sidebar-toggle
attribute), the conversation’s title, and an actions zone pushed to the end — a
model selector, a share button, whatever the page needs. The toggle shows only
under 48rem, where the sidebar is a drawer; above it the column is simply there.
<header class="aparte-app-header"> <button class="aparte-btn aparte-btn--icon aparte-app-header__toggle" type="button" aria-label="Toggle the sidebar" data-aparte-sidebar-toggle><aparte-icon name="menu"></aparte-icon></button> <span class="aparte-app-header__title">Deploy checklist</span> <div class="aparte-app-header__actions"> <span class="aparte-tag">gpt-4.1</span> <button class="aparte-btn aparte-btn--sm aparte-btn--ghost" type="button">Share</button> </div></header>.aparte-app-header · .aparte-app-header__actions · .aparte-app-header__title · .aparte-app-header__toggle
App Shell
Section titled “App Shell”aparte-app-shell — sidebar beside, header above, the chat in the rest.
A recipe: two columns and two rows, the sidebar spanning both rows, the header and
the main area stacked in the second column. The chat fills __main, which is what
gives it the height it needs to scroll (see the layout guide). Size the shell —
height: 100dvh for a page — and everything inside follows. Under 48rem the sidebar
leaves the grid to become a drawer, and the header’s toggle appears.
<div class="aparte-app-shell" style="height: 24rem"> <aside class="aparte-sidebar"> <div class="aparte-sidebar__header"> <span class="aparte-sidebar__brand">aparté</span> <button class="aparte-btn aparte-btn--icon aparte-btn--sm" type="button" aria-label="New chat"><aparte-icon name="edit"></aparte-icon></button> </div> <div class="aparte-sidebar__search aparte-field-group"> <input class="aparte-field aparte-field--sm" type="search" placeholder="Search"> </div> <div class="aparte-sidebar__body"> <div class="aparte-conv-group" role="group" aria-label="Today"> <div class="aparte-menu__label aparte-conv-group__label" aria-hidden="true">Today</div> <div class="aparte-menu__item aparte-conv-item aparte-conv-item--active"><button class="aparte-conv-item__select" type="button"><span class="aparte-conv-item__title">Deploy checklist</span></button></div> <div class="aparte-menu__item aparte-conv-item"><button class="aparte-conv-item__select" type="button"><span class="aparte-conv-item__title">Rename the staging bucket</span></button></div> </div> <div class="aparte-conv-group" role="group" aria-label="Yesterday"> <div class="aparte-menu__label aparte-conv-group__label" aria-hidden="true">Yesterday</div> <div class="aparte-menu__item aparte-conv-item"><button class="aparte-conv-item__select" type="button"><span class="aparte-conv-item__title">Why is total a float?</span></button></div> </div> </div> <div class="aparte-sidebar__footer"><span class="aparte-avatar aparte-avatar--sm">P</span> Paul</div> </aside> <header class="aparte-app-header"> <span class="aparte-app-header__title">Deploy checklist</span> <div class="aparte-app-header__actions"><span class="aparte-tag">gpt-4.1</span></div> </header> <main class="aparte-app-shell__main"> <aparte-chat> <aparte-chat-viewport> <aparte-chat-bubble message-id="u1" data-role="user" content="Walk me through the deploy."></aparte-chat-bubble> <aparte-chat-bubble message-id="a1" data-role="assistant" name="Assistant" content="Build, run the gate, tag, then push the tag — the workflow publishes from it."></aparte-chat-bubble> </aparte-chat-viewport> <aparte-composer> <div class="aparte-composer-shell"> <div class="aparte-composer-row"> <aparte-composer-input></aparte-composer-input> <aparte-composer-send></aparte-composer-send> </div> </div> </aparte-composer> </aparte-chat> </main></div>.aparte-app-header · .aparte-app-shell · .aparte-app-shell__main · .aparte-sidebar · .aparte-split
aparte-split — two panes and a draggable seam. The chat in one, your pane in the
other: a preview frame, an editor, an artifact card.
The grid is the whole mechanism. --aparte-split-position is the primary pane’s
size; --aparte-split-min and --aparte-split-max are clamp bounds, so the
browser does the clamping and nothing in JS parses a unit — px, %, rem and ch all
work. Used without <aparte-split> this is a static split: set the position from
your own media query and there is no drag, which is the Canvas shape.
A pane CONTAINS a chat; a chat never contains a split.
The element wears the same class and adds the behaviour (drag, arrow keys, the APG
separator’s ARIA, one pane at a time under a breakpoint); <div class="aparte-split">
is the look alone, which is what this page shows.
<div class="aparte-split" style="height: 18rem"> <div>the chat</div> <div class="aparte-split__handle"></div> <div class="aparte-split__pane">the pane</div></div>.aparte-split · .aparte-split--only-end · .aparte-split--only-start · .aparte-split--primary-end · .aparte-split--vertical · .aparte-split__handle · .aparte-split__pane · .aparte-split__scrim
What core writes for itself
Section titled “What core writes for itself”These are on the elements aparté renders. They are listed so that nothing it emits is a surprise in your inspector, and so a theme can target one — but what each region is is explained on its element’s page, not here.
| Sheet | Classes |
|---|---|
styles/base.css | .aparte-actions-left .aparte-actions-right .aparte-bottom-spacer .aparte-dot .aparte-dots .aparte-footer .aparte-header .aparte-link .aparte-link--muted .aparte-sr-only |
primitives/select.css | .aparte-optgroup-chevron .aparte-optgroup-header .aparte-optgroup-loader .aparte-select-chevron .aparte-select-dropdown .aparte-select-label .aparte-select-label-sizer .aparte-select-label-text .aparte-select-options .aparte-select-search .aparte-select-trigger .aparte-spinner-small .aparte-status-dot |
primitives/progress-spinner.css | .aparte-spinner-fill .aparte-spinner-track |
components/shell.css | .aparte-chat-container .aparte-chat-container--auto-center .aparte-composer-shell .aparte-messages-wrapper .aparte-scroll-btn .aparte-scroll-btn--hidden .aparte-status-container .aparte-status-content .aparte-status-text .aparte-viewport--framework .aparte-viewport-container |
components/bubble.css | .aparte-action-bar .aparte-action-btn .aparte-action-button .aparte-action-edit-cancel .aparte-action-edit-save .aparte-avatar .aparte-body .aparte-branch-label .aparte-branch-next .aparte-branch-picker .aparte-branch-prev .aparte-footer .aparte-header .aparte-message .aparte-message-content .aparte-message-streaming .aparte-messages-wrapper .aparte-name .aparte-timestamp |
components/composer.css | .aparte-cc-button .aparte-chat-container--auto-center .aparte-ci-editor .aparte-composer-footer .aparte-composer-row .aparte-composer-shell .aparte-editor .aparte-has-content .aparte-input-container .aparte-input-footer .aparte-input-upper .aparte-input-wrapper .aparte-is-dragover .aparte-message .aparte-model-select .aparte-model-selector .aparte-provider-select .aparte-send-button .aparte-thumb .aparte-thumb__remove .aparte-viewport--framework .aparte-viewport-container |
segment/thinking.css | .aparte-dot .aparte-dots .aparte-segment-thinking .aparte-segments .aparte-thinking-header .aparte-waiting |
segment/code.css | .aparte-code-copy .aparte-code-filename .aparte-code-header .aparte-code-header-filler .aparte-code-language |
segment/tool-call.css | .aparte-segment-tool-call .aparte-tool-detail .aparte-tool-icon .aparte-tool-label .aparte-tool-name .aparte-tool-part-body .aparte-tool-part-label .aparte-tool-spinner .aparte-tool-state .aparte-tool-summary .aparte-tool-toggle |
segment/error.css | .aparte-segment-error__details |
segment/text.css | .aparte-segment-code .aparte-segment-error .aparte-segment-text |
components/elicitation.css | .aparte-approval-args .aparte-approval-args-label .aparte-approval-instruction .aparte-approval-option .aparte-approval-options .aparte-elic-body .aparte-elic-control .aparte-elic-desc .aparte-elic-dismiss .aparte-elic-field .aparte-elic-message .aparte-elic-option .aparte-elic-option--command .aparte-elic-option--recommended .aparte-elic-option-badge .aparte-elic-option-body .aparte-elic-option-desc .aparte-elic-option-title .aparte-elic-options .aparte-elic-other-input .aparte-elic-panel .aparte-elic-panel--stepped .aparte-elic-skip .aparte-elic-step .aparte-elic-step__mark .aparte-elic-steps .aparte-elic-text .aparte-elic-title .aparte-field-choice--selected |
components/conversation.css | .aparte-conv-group .aparte-conv-item .aparte-conv-item--active .aparte-conv-item--archived .aparte-conv-item__input .aparte-conv-item__more .aparte-conv-item__select .aparte-conv-item__title .aparte-conv-menu .aparte-conv-menu__actions .aparte-conv-menu__confirm .aparte-conv-menu__item--danger .aparte-conv-menu__question |
components/suggestions.css | .aparte-suggestion .aparte-suggestions |
components/context.css | .aparte-context .aparte-context--ring .aparte-context__ring .aparte-context__text .aparte-context__track .aparte-context__value .aparte-context__value--empty .aparte-progress .aparte-progress__bar |
components/scroll-rail.css | .aparte-chat-container .aparte-scroll-rail__list .aparte-scroll-rail__tick |
styles/prose.css | .aparte-code-content-wrapper .aparte-content .aparte-segment-content .aparte-thinking-content |
styles/responsive.css | .aparte-action-bar .aparte-action-button .aparte-avatar .aparte-composer-row .aparte-conv-item__more .aparte-message .aparte-messages-wrapper .aparte-scroll-btn .aparte-send-button .aparte-split__handle .aparte-thumb__remove |