npm 0.16.0Angular 21.2+ and 22
Behaviour for the interfaces you own.
Quartz is a set of unstyled, accessible Angular primitives: focus, keyboard, ARIA, overlays and state, shipped as two npm packages. You write the markup and the styles; Quartz owns the behaviour.
- signals-first
- zoneless
- SSR-safe
- 0 bytes of CSS
The lens shows what Quartz ships: structure, not skin.
Headless, shown
One behaviour. Any skin.
These are the real Quartz Slider and Switch. Pick a skin: the markup and the class map change, the keyboard, pointer, ARIA and state handling do not.
import { ChangeDetectionStrategy, Component, computed, input, signal } from '@angular/core';
import {
SliderDirective,
SliderRangeDirective,
SliderThumbDirective,
SliderTrackDirective,
SwitchDirective,
} from '@quartz-headless/primitives';
export type VolumeSkin = 'quartz' | 'wireframe' | 'brutal' | 'soft';
type SkinPart =
| 'card'
| 'label'
| 'readout'
| 'track'
| 'range'
| 'thumb'
| 'divider'
| 'switch'
| 'knob';
/**
* The whole look of this demo. Swap the map and the controls change completely; the behaviour
* (keyboard, pointer, ARIA, disabled state) comes from Quartz and stays identical.
*/
const SKINS: Record<VolumeSkin, Record<SkinPart, string>> = {
quartz: {
card: 'rounded-2xl border border-white/10 bg-surface-2/70 p-6',
label: 'text-sm font-medium text-fg',
readout: 'font-mono text-sm text-fg-muted',
track: 'h-1.5 rounded-full bg-white/15',
range: 'rounded-full bg-brand',
thumb: 'size-6 rounded-full border-2 border-brand bg-surface-2',
divider: 'border-t border-line',
switch: 'h-7 w-12 rounded-full bg-white/15 data-[qz-state=checked]:bg-accent',
knob: 'left-1 top-1 size-5 rounded-full bg-white group-data-[qz-state=checked]:translate-x-5',
},
wireframe: {
card: 'border border-dashed border-white/30 p-6 font-mono',
label: 'text-xs uppercase tracking-widest text-fg-muted',
readout: 'text-xs text-signal',
track: 'h-2 border border-dashed border-white/40',
range: 'bg-[repeating-linear-gradient(135deg,rgb(103_232_249/0.5)_0_2px,transparent_2px_6px)]',
thumb: 'size-5 border border-signal bg-surface-0',
divider: 'border-t border-dashed border-white/25',
switch: 'h-7 w-12 border border-dashed border-white/40 data-[qz-state=checked]:border-signal',
knob: 'left-1 top-1 size-5 border border-signal group-data-[qz-state=checked]:translate-x-5',
},
brutal: {
card: 'border-[3px] border-black bg-[#ffe14d] p-6 text-black shadow-[6px_6px_0_#000]',
label: 'text-sm font-black uppercase text-black',
readout: 'font-mono text-sm font-bold text-black',
track: 'h-4 border-[3px] border-black bg-white',
range: 'bg-[#ff5c8a]',
thumb: 'size-8 border-[3px] border-black bg-white shadow-[3px_3px_0_#000]',
divider: 'border-t-[3px] border-black',
switch: 'h-8 w-14 border-[3px] border-black bg-white data-[qz-state=checked]:bg-[#5ce1e6]',
knob: 'left-0.5 top-0.5 size-5 border-[3px] border-black bg-black group-data-[qz-state=checked]:translate-x-6',
},
soft: {
card: 'rounded-[2rem] bg-gradient-to-br from-violet-200 to-sky-100 p-7 text-slate-800 shadow-[0_20px_60px_rgb(167_139_250/0.35)]',
label: 'text-sm font-semibold text-slate-700',
readout: 'rounded-full bg-white/70 px-2.5 py-0.5 text-sm font-semibold text-violet-700',
track: 'h-3 rounded-full bg-white/70 shadow-inner',
range: 'rounded-full bg-gradient-to-r from-violet-500 to-sky-500',
thumb: 'size-7 rounded-full bg-white shadow-[0_4px_14px_rgb(91_33_182/0.35)]',
divider: 'border-t border-white/70',
switch: 'h-8 w-14 rounded-full bg-white/70 shadow-inner data-[qz-state=checked]:bg-violet-500',
knob: 'left-1 top-1 size-6 rounded-full bg-white shadow group-data-[qz-state=checked]:translate-x-6',
},
};
@Component({
selector: 'app-volume-demo',
imports: [
SliderDirective,
SliderTrackDirective,
SliderRangeDirective,
SliderThumbDirective,
SwitchDirective,
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div [class]="s().card" class="skin-transition">
<div class="flex items-baseline justify-between gap-4">
<span id="volume-label" [class]="s().label">Volume</span>
<span [class]="s().readout" data-testid="volume-readout">
{{ muted() ? 'Muted' : volume() + '%' }}
</span>
</div>
<div
qzSlider
[(value)]="volume"
[disabled]="muted()"
[valueText]="valueText"
class="relative mt-3 h-10 w-full touch-none data-[qz-disabled]:opacity-40"
>
<div
qzSliderTrack
[class]="s().track"
class="skin-transition absolute inset-x-0 top-1/2 -translate-y-1/2"
>
<div
qzSliderRange
[class]="s().range"
class="h-full"
[style.width]="'var(--qz-slider-percent)'"
></div>
<button
qzSliderThumb
aria-labelledby="volume-label"
[class]="s().thumb"
class="skin-transition site-focus absolute top-1/2 -translate-x-1/2 -translate-y-1/2"
[style.left]="'var(--qz-slider-percent)'"
></button>
</div>
</div>
<div [class]="s().divider" class="mt-5 flex items-center justify-between gap-4 pt-5">
<span id="mute-label" [class]="s().label">Mute</span>
<button
qzSwitch
[(checked)]="muted"
aria-labelledby="mute-label"
[class]="s().switch"
class="skin-transition site-focus group relative shrink-0"
>
<span
[class]="s().knob"
class="motion-safe-transition absolute transition-transform"
></span>
</button>
</div>
</div>
`,
})
export class VolumeDemoComponent {
readonly skin = input<VolumeSkin>('quartz');
readonly volume = signal(40);
readonly muted = signal(false);
readonly valueText = (value: number) => `${value} percent`;
protected readonly s = computed(() => SKINS[this.skin()]);
}Keyboard
← → Home End
Arrow keys step, Home and End jump. Space toggles mute.
Accessibility
role=slider · switch
aria-valuenow, valuetext and aria-disabled are kept in sync.
CSS shipped by Quartz
0 bytes
Every visual is a class in the file under Code.
Composition
Layers that stack without a fight.
A Select lives inside a Dialog here. Quartz traps focus in the dialog, lets the popup layer above it, closes only the top layer on Escape and returns focus to the button that opened it. Every pixel of the visuals is the consumer's.
- Opens from the keyboard with EnterSpace
- Escape closes the top layer only, popup first, then the dialog Esc
- Focus is trapped while open and returns to the opener Tab
- The dialog is named by its title; the select by its label
import {
ChangeDetectionStrategy,
Component,
TemplateRef,
ViewContainerRef,
inject,
signal,
viewChild,
} from '@angular/core';
import {
DialogService,
SelectContentDirective,
SelectDirective,
SelectListboxDirective,
SelectOptionDirective,
SelectTriggerDirective,
} from '@quartz-headless/primitives';
@Component({
selector: 'app-settings-dialog-demo',
imports: [
SelectDirective,
SelectTriggerDirective,
SelectContentDirective,
SelectListboxDirective,
SelectOptionDirective,
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="rounded-2xl border border-line-strong bg-surface-2/70 p-5 shadow-2xl">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm font-semibold text-fg">Quarterly report</p>
<p class="mt-1 text-xs text-fg-subtle">42 pages · updated today</p>
</div>
<span class="rounded-md bg-brand/15 px-2 py-0.5 font-mono text-xs text-brand">ready</span>
</div>
<div class="mt-5 flex items-center justify-between gap-4 border-t border-line pt-4">
<p class="text-sm text-fg-muted" data-testid="export-format-readout">
Current format: <span class="font-mono text-fg">{{ applied() }}</span>
</p>
<button
type="button"
(click)="open()"
class="site-focus shrink-0 rounded-lg bg-fg px-3.5 py-2 text-sm font-semibold text-surface-0 hover:bg-white"
>
Export settings…
</button>
</div>
</div>
<ng-template #settings let-ref let-labelledBy="ariaLabelledBy">
<div
class="w-[min(24rem,calc(100vw-2rem))] rounded-2xl border border-line-strong bg-surface-1 p-6 shadow-2xl"
>
<h3 [id]="labelledBy" class="text-lg font-semibold text-fg">Export format</h3>
<p class="mt-1 text-sm text-fg-muted">
Pick a format. Escape, the backdrop or Cancel closes this dialog.
</p>
<p id="export-format-label" class="mt-5 text-sm font-medium text-fg">Format</p>
<div qzSelect #select="qzSelect" [(value)]="draft" class="mt-2">
<button
qzSelectTrigger
aria-labelledby="export-format-label export-format-value"
class="site-focus flex w-full items-center justify-between rounded-lg border border-line-strong bg-surface-2 px-3 py-2.5 text-left text-sm text-fg data-[qz-open]:border-brand"
>
<span id="export-format-value">{{ select.selectedLabel() }}</span>
<span aria-hidden="true" class="text-fg-subtle">▾</span>
</button>
<ng-template qzSelectContent>
<div
qzSelectListbox
class="min-w-48 rounded-lg border border-line-strong bg-surface-2 p-1 shadow-xl"
>
@for (format of formats; track format) {
<button
[qzSelectOption]="format"
class="block w-full rounded-md px-3 py-2 text-left text-sm text-fg data-[qz-highlighted]:bg-brand/20 data-[qz-selected]:font-semibold"
>
{{ format }}
</button>
}
</div>
</ng-template>
</div>
<div class="mt-6 flex justify-end gap-3">
<button
type="button"
(click)="ref.close()"
class="site-focus rounded-lg px-4 py-2 text-sm text-fg-muted hover:text-fg"
>
Cancel
</button>
<button
type="button"
(click)="apply(ref)"
class="site-focus rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-surface-0"
>
Apply
</button>
</div>
</div>
</ng-template>
`,
})
export class SettingsDialogDemoComponent {
private readonly dialog = inject(DialogService);
private readonly vcr = inject(ViewContainerRef);
private readonly settings = viewChild.required<TemplateRef<unknown>>('settings');
readonly formats = ['PDF', 'CSV', 'JSON', 'Markdown'];
readonly applied = signal('PDF');
readonly draft = signal<string | null>('PDF');
open(): void {
this.draft.set(this.applied());
this.dialog.open(this.settings(), this.vcr, {
backdropClass: 'bg-black/70',
});
}
apply(ref: { close(): void }): void {
this.applied.set(this.draft() ?? this.applied());
ref.close();
}
}Architecture
Core is the engine. Primitives are the patterns.
Build your own widget on Core, or take a finished accessible pattern from Primitives. Primitives depends on Core as a peer dependency; Core never depends on Primitives.
- @quartz-headless/core
- 10 Core APIs
- @quartz-headless/primitives
- 34 primitive families
Also documented: 1 alias backed by Core and 1 native-HTML recipe, labelled as such in the catalogue. Not sure which to install? Core versus Primitives.
Your code
Your design system
Markup, Tailwind, CSS, tokens, Volt UI. Every pixel.
@quartz-headless/primitives
Accessible patterns
Dialog, Menu, Select, Combobox, Tabs, Slider, DatePicker, FormField, Toast…
Browse Primitives →@quartz-headless/core
Interaction engine
Overlay, dismiss, focus, collection, direction, viewport, scroll lock, drag and drop, virtual scroll, splitter.
Browse Core APIs →Angular 21.2+ and 22
Signals, zoneless change detection, SSR and hydration.
The whole surface
46 building blocks. None of them has a look.
Every documented Core API and primitive, grouped by what it is for. Each one is a page with a playground and live examples, one click from its documentation.
Core foundations 7
Interaction infrastructure the primitives are built on.
Floating & overlays 6
Layered surfaces: positioning, dismissal, focus and scroll.
Selection & forms 17
Controls, pickers and form composition.
Navigation & layout 11
Disclosure, wayfinding and resizable structure.
Data & feedback 5
Status, measurement, identity and large data.
@quartz-headless/core @quartz-headless/primitives
Use cases
The interfaces teams actually build.
Real screens are compositions. Each one below names the Quartz pieces it is made of; the wireframe is everything Quartz cares about.
Command palette & search
An editable combobox in a modal layer with filtering, typeahead and focus return.
Settings & forms
Labelled fields with errors, switches, sliders and selects that work with Angular Forms.
Dates & scheduling
Keyboard-navigable calendars for one date or a range, locale-aware and RTL-safe.
Data-dense screens
Large lists, trees and toolbars that stay fast and keyboard-reachable.
Custom widgets
Build a one-off popup or a sortable list from the same engine the primitives use: positioning, dismissal, keyboard collections and drag and drop.
Developer experience
Built for the Angular you write today.
Signals in, signals out. No zone.js, no NgModules, no CSS to override, and every claim below links to the page that proves it.
A typed public API
Every export, selector, input and output is listed in a generated reference, and a guard fails CI when the surface changes unexpectedly.
Example: SliderDirectiveSignals and controlled state
State is model() and signal() all the way down. Bind it two-way or control it yourself; user commits are separate outputs.
Signals guideAngular Forms, opt-in
Adapters live in a secondary entry point, so the main entry never imports @angular/forms.
Forms adaptersSSR and hydration
No DOM side effects at import time, deterministic ids per request, and an SSR/hydration test suite against packed tarballs.
SSR and lifecycleCopy the source instead
The CLI copies raw TypeScript for a primitive into your repo when you would rather own and edit it.
CLI guideVerified compatibility
Tested as installed packages in AOT-compiled apps on Angular 21.2 (minimum and current) and 22.
Compatibility
Install
Install what you need.
Latest on npm: 0.16.0. Requires Angular 21.2+ or 22 with zoneless change detection.
On npm: @quartz-headless/core, @quartz-headless/primitives. Prefer owning the code? Copy the source with the CLI.
Core only
Overlay, focus, dismiss, collection and the other infrastructure.
npm install @quartz-headless/coreCore + Primitives
The accessible patterns. Primitives needs Core installed beside it.
npm install @quartz-headless/core @quartz-headless/primitivesCore only
Overlay, focus, dismiss, collection and the other infrastructure.
pnpm add @quartz-headless/coreCore + Primitives
The accessible patterns. Primitives needs Core installed beside it.
pnpm add @quartz-headless/core @quartz-headless/primitives
Own every pixel.
Keep the behaviour.
Start with one primitive in an existing app. Nothing to theme, nothing to override.