# Radix NG Primitives — Full Documentation Unstyled, accessible, signals-first Angular primitives for building design systems and web apps. Examples use Angular signals and Tailwind CSS v4 utilities; the primitives themselves are styling-agnostic. --- # Accordion #### A vertically stacked set of interactive headings that each reveal an associated section of content. ```html

Yes. It adheres to the WAI-ARIA design pattern.

Yes. It's unstyled by default, giving you freedom over the look and feel.

Yes! You can animate the Accordion with CSS or JavaScript.
``` ## Features - ✅ Full keyboard navigation, with optional focus looping (`loopFocus`). - ✅ Supports horizontal/vertical orientation. - ✅ Supports Right to Left direction. - ✅ Expand a single item or multiple items at once. - ✅ Can be controlled or uncontrolled. - ✅ Emits an event per item when its panel opens or closes (`onOpenChange`). - ✅ Can keep collapsed panels mounted in the DOM (`keepMounted`). ## Import Get started with importing the directives: ```typescript import { RdxAccordionRootDirective, RdxAccordionItemDirective, RdxAccordionHeaderDirective, RdxAccordionTriggerDirective, RdxAccordionContentDirective } from '@radix-ng/primitives/accordion'; ``` ## Anatomy ```html
``` ## Examples ### Disabled Disable the whole accordion via `disabled` on the root, or a single item via `disabled` on the item. ```typescript import { Component } from '@angular/core'; import { RdxAccordionContentDirective, RdxAccordionHeaderDirective, RdxAccordionItemDirective, RdxAccordionRootDirective, RdxAccordionTriggerDirective } from '@radix-ng/primitives/accordion'; import { cn, demoAccordion } from '../../storybook/styles'; @Component({ selector: 'accordion-disabled-example', imports: [ RdxAccordionRootDirective, RdxAccordionItemDirective, RdxAccordionHeaderDirective, RdxAccordionTriggerDirective, RdxAccordionContentDirective ], template: `

Yes. It adheres to the WAI-ARIA design pattern.

Yes. It's unstyled by default, giving you freedom over the look and feel.

Yes! You can animate the Accordion with CSS or JavaScript.
` }) export class AccordionDisabledExample { protected readonly cn = cn; protected readonly a = demoAccordion; } ``` ### Multiple Set `multiple` (or `type="multiple"`) to let several items stay open at once. ```typescript import { Component } from '@angular/core'; import { RdxAccordionContentDirective, RdxAccordionHeaderDirective, RdxAccordionItemDirective, RdxAccordionRootDirective, RdxAccordionTriggerDirective } from '@radix-ng/primitives/accordion'; import { cn, demoAccordion } from '../../storybook/styles'; @Component({ selector: 'accordion-multiple-example', imports: [ RdxAccordionRootDirective, RdxAccordionItemDirective, RdxAccordionHeaderDirective, RdxAccordionTriggerDirective, RdxAccordionContentDirective ], template: `

Yes. It adheres to the WAI-ARIA design pattern.

Yes. It's unstyled by default, giving you freedom over the look and feel.

Yes. It's unstyled by default, giving you freedom over the look and feel.
` }) export class AccordionMultipleExample { protected readonly cn = cn; protected readonly a = demoAccordion; } ``` ### Collapsible In single mode, `collapsible` lets the user close the currently open item. ```typescript import { Component } from '@angular/core'; import { RdxAccordionContentDirective, RdxAccordionHeaderDirective, RdxAccordionItemDirective, RdxAccordionRootDirective, RdxAccordionTriggerDirective } from '@radix-ng/primitives/accordion'; import { cn, demoAccordion } from '../../storybook/styles'; @Component({ selector: 'accordion-collapsible-example', imports: [ RdxAccordionRootDirective, RdxAccordionItemDirective, RdxAccordionHeaderDirective, RdxAccordionTriggerDirective, RdxAccordionContentDirective ], template: `

Yes. It adheres to the WAI-ARIA design pattern.

Yes. It's unstyled by default, giving you freedom over the look and feel.

Yes! You can animate the Accordion with CSS or JavaScript.
` }) export class AccordionCollapsibleExample { protected readonly cn = cn; protected readonly a = demoAccordion; } ``` ### Horizontal Set `orientation="horizontal"` to lay items out in a row; arrow-key navigation follows the orientation. ```typescript import { Component } from '@angular/core'; import { RdxAccordionContentDirective, RdxAccordionHeaderDirective, RdxAccordionItemDirective, RdxAccordionRootDirective, RdxAccordionTriggerDirective } from '@radix-ng/primitives/accordion'; import { cn, demoAccordion } from '../../storybook/styles'; @Component({ selector: 'accordion-horizontal-example', imports: [ RdxAccordionRootDirective, RdxAccordionItemDirective, RdxAccordionHeaderDirective, RdxAccordionTriggerDirective, RdxAccordionContentDirective ], template: `
Yes. It adheres to the WAI-ARIA design pattern.
Yes. It's unstyled by default, giving you freedom over the look and feel.
Yes! You can animate the Accordion with CSS or JavaScript.
` }) export class AccordionHorizontalExample { protected readonly cn = cn; protected readonly a = demoAccordion; } ``` ### Events Each item emits `onOpenChange` with its new open state whenever it expands or collapses. ```typescript import { Component, signal } from '@angular/core'; import { RdxAccordionContentDirective, RdxAccordionHeaderDirective, RdxAccordionItemDirective, RdxAccordionRootDirective, RdxAccordionTriggerDirective } from '@radix-ng/primitives/accordion'; import { cn, demoAccordion } from '../../storybook/styles'; @Component({ selector: 'accordion-events-example', imports: [ RdxAccordionRootDirective, RdxAccordionItemDirective, RdxAccordionHeaderDirective, RdxAccordionTriggerDirective, RdxAccordionContentDirective ], template: `

Yes. It adheres to the WAI-ARIA design pattern.

Yes. It's unstyled by default.

Last event: {{ status() }}

` }) export class AccordionEventsExample { protected readonly cn = cn; protected readonly a = demoAccordion; readonly status = signal('—'); log(title: string, open: boolean): void { this.status.set(`${title} ${open ? 'opened' : 'closed'}`); } } ``` ### Keep mounted With `keepMounted`, collapsed panels keep their element in the DOM instead of receiving a `hidden` attribute — useful to preserve form state and keep content reachable by the browser's find-in-page. ```typescript import { Component } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { RdxAccordionContentDirective, RdxAccordionHeaderDirective, RdxAccordionItemDirective, RdxAccordionRootDirective, RdxAccordionTriggerDirective } from '@radix-ng/primitives/accordion'; import { cn, demoAccordion, demoInput } from '../../storybook/styles'; /** * With `keepMounted`, collapsed panels keep their element in the DOM (no `hidden` * attribute) instead of being hidden from assistive tech and find-in-page. Type * something below, collapse the panel, and reopen it — the value is retained and * the field stays reachable by the browser's Ctrl/Cmd+F search. */ @Component({ selector: 'accordion-keep-mounted-example', imports: [ FormsModule, RdxAccordionRootDirective, RdxAccordionItemDirective, RdxAccordionHeaderDirective, RdxAccordionTriggerDirective, RdxAccordionContentDirective ], template: `

Same as shipping.
` }) export class AccordionKeepMountedExample { protected readonly cn = cn; protected readonly a = demoAccordion; protected readonly input = demoInput; address = ''; } ``` ## Keyboard interactions | Key | Description | | -------------------------- | ----------------------------------------------------------------------------------- | | `Space` / `Enter` | Toggles the focused item (in single non-collapsible mode an open item stays open). | | `ArrowDown` / `ArrowRight` | Moves focus to the next trigger, wrapping to the first when `loopFocus` is enabled. | | `ArrowUp` / `ArrowLeft` | Moves focus to the previous trigger, wrapping to the last when `loopFocus` is enabled. | | `Home` | Moves focus to the first trigger. | | `End` | Moves focus to the last trigger. | Arrow keys follow `orientation`: Up/Down for vertical accordions, Left/Right for horizontal ones. ## Data attributes State is exposed through `data-*` attributes for styling: | Attribute | Parts | Values | | ----------------- | ------------------------------ | ----------------------------------------------------------- | | `data-state` | item, header, trigger, content | `"open"` \| `"closed"` | | `data-disabled` | root, item, header, content | Present when disabled | | `data-orientation`| root, item, header, trigger, content | `"horizontal"` \| `"vertical"` | | `data-index` | item, header, trigger, content | Zero-based position of the item | | `data-panel-open` | trigger | Present while the trigger's panel is open (e.g. rotate a chevron) | ## API Reference ### RdxAccordionRootDirective ### RdxAccordionItemDirective ### RdxAccordionTriggerDirective ### RdxAccordionContentDirective --- # Alert Dialog #### A modal dialog that interrupts the user with important content and expects a response. Alert Dialog is the Dialog primitive with three fixed invariants: it is always modal, it does **not** dismiss on outside clicks or focus leaving the popup (only an explicit action or Escape closes it), and its popup uses `role="alertdialog"`. Each `rdxAlertDialog*` part is a thin wrapper around the matching dialog part. ```typescript import { Component } from '@angular/core'; import { alertDialogImports } from '@radix-ng/primitives/alert-dialog'; import { cn, demoButton, demoDialog } from '../../storybook/styles'; @Component({ selector: 'rdx-alert-dialog-default', imports: [...alertDialogImports], template: `

Are you absolutely sure?

This action cannot be undone. This will permanently delete your account and remove your data from our servers.

` }) export class RdxAlertDialogDefaultComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDialog; } ``` ## Features - ✅ Always modal: document scrolling is locked and outside pointer events are disabled. - ✅ Does not close on outside interaction — requires an explicit choice; Escape still closes. - ✅ Renders the popup with `role="alertdialog"` for assertive screen-reader semantics. - ✅ Supports uncontrolled state, `defaultOpen`, and Angular two-way binding with `[(open)]`. - ✅ Supports multiple triggers, controlled `triggerId`, and detached triggers through a shared handle. - ✅ Traps and restores focus; links the popup to title and description for accessible labeling. ## Import ```typescript import { alertDialogImports, createRdxAlertDialogHandle, RdxAlertDialogBackdrop, RdxAlertDialogClose, RdxAlertDialogDescription, RdxAlertDialogPopup, RdxAlertDialogPortal, RdxAlertDialogPortalPresence, RdxAlertDialogRoot, RdxAlertDialogTitle, RdxAlertDialogTrigger } from '@radix-ng/primitives/alert-dialog'; ``` Or import all parts through the module: ```typescript import { RdxAlertDialogModule } from '@radix-ng/primitives/alert-dialog'; ``` ## Anatomy ```html

Are you sure?

This action cannot be undone.

``` ## Examples ### Default A destructive confirmation with Cancel and an action button. Clicking the backdrop keeps it open. ```typescript import { Component } from '@angular/core'; import { alertDialogImports } from '@radix-ng/primitives/alert-dialog'; import { cn, demoButton, demoDialog } from '../../storybook/styles'; @Component({ selector: 'rdx-alert-dialog-default', imports: [...alertDialogImports], template: `

Are you absolutely sure?

This action cannot be undone. This will permanently delete your account and remove your data from our servers.

` }) export class RdxAlertDialogDefaultComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDialog; } ``` ### Controlled Bind `[(open)]` to open or close the alert dialog from component state. ```typescript import { Component, signal } from '@angular/core'; import { alertDialogImports } from '@radix-ng/primitives/alert-dialog'; import { cn, demoButton, demoDialog } from '../../storybook/styles'; @Component({ selector: 'rdx-alert-dialog-controlled', imports: [...alertDialogImports], template: `

Alert dialog is {{ open() ? 'open' : 'closed' }}

Discard unsaved changes?

The open state is owned by the component and bound with [(open)] .

` }) export class RdxAlertDialogControlledComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDialog; protected readonly open = signal(false); } ``` ### Multiple triggers Several triggers can open the same alert dialog. The active trigger's `payload` is exposed on the root so the content can adapt to what is being confirmed. ```typescript import { Component } from '@angular/core'; import { alertDialogImports } from '@radix-ng/primitives/alert-dialog'; import { cn, demoButton, demoDialog } from '../../storybook/styles'; @Component({ selector: 'rdx-alert-dialog-multiple-triggers', imports: [...alertDialogImports], template: `

Delete this {{ root.payload() || 'item' }}?

Every trigger opens the same alert dialog; the active trigger's payload decides what is being deleted.

` }) export class RdxAlertDialogMultipleTriggersComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDialog; } ``` ### Controlled mode with multiple triggers Bind both `[(open)]` and `[(triggerId)]` to choose which trigger is active from component state. ```typescript import { Component, signal } from '@angular/core'; import { alertDialogImports } from '@radix-ng/primitives/alert-dialog'; import { cn, demoButton, demoDialog } from '../../storybook/styles'; @Component({ selector: 'rdx-alert-dialog-controlled-multiple', imports: [...alertDialogImports], template: `

open: {{ open() }} · triggerId: {{ triggerId() ?? '—' }}

{{ triggerId() === 'delete' ? 'Delete account?' : 'Log out?' }}

Both open and triggerId are bound, so the active action is driven from component state.

` }) export class RdxAlertDialogControlledMultipleComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDialog; protected readonly open = signal(false); protected readonly triggerId = signal(null); protected confirmDelete() { this.triggerId.set('delete'); this.open.set(true); } } ``` ### Detached triggers Connect a trigger rendered outside the root with `createRdxAlertDialogHandle()`; the handle also exposes imperative `open(id)` / `close()`. ```typescript import { Component } from '@angular/core'; import { alertDialogImports, createRdxAlertDialogHandle } from '@radix-ng/primitives/alert-dialog'; import { cn, demoButton, demoDialog } from '../../storybook/styles'; @Component({ selector: 'rdx-alert-dialog-detached', imports: [...alertDialogImports], template: `

Delete this file?

The trigger and this alert dialog are connected with createRdxAlertDialogHandle() .

` }) export class RdxAlertDialogDetachedComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDialog; protected readonly handle = createRdxAlertDialogHandle(); } ``` ### Close confirmation A regular Dialog editor asks an Alert Dialog to confirm before discarding unsaved changes — the classic reason to reach for an alert dialog. ```typescript import { Component, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { alertDialogImports } from '@radix-ng/primitives/alert-dialog'; import { dialogImports, RdxDialogOpenChange } from '@radix-ng/primitives/dialog'; import { cn, demoButton, demoDialog, demoInput } from '../../storybook/styles'; @Component({ selector: 'rdx-alert-dialog-close-confirmation', imports: [...dialogImports, ...alertDialogImports, FormsModule], template: `

Edit note

Closing with unsaved changes asks an alert dialog to confirm.

Discard changes?

Your unsaved note will be lost.

` }) export class RdxAlertDialogCloseConfirmationComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDialog; protected readonly input = demoInput; protected readonly editorOpen = signal(false); protected readonly confirmOpen = signal(false); protected text = ''; private get hasChanges() { return this.text.trim().length > 0; } protected onEditorOpenChange(change: RdxDialogOpenChange) { // Re-open the editor and ask for confirmation when there are unsaved changes. if (!change.open && this.hasChanges) { this.editorOpen.set(true); this.confirmOpen.set(true); } } protected requestClose() { if (this.hasChanges) { this.confirmOpen.set(true); } else { this.editorOpen.set(false); } } protected save() { this.text = ''; this.editorOpen.set(false); } protected discard() { this.text = ''; this.confirmOpen.set(false); this.editorOpen.set(false); } } ``` ### Open from a menu Control the alert dialog's `open` state from a menu item to launch a destructive confirmation from a `Menu`. ```typescript import { Component, signal } from '@angular/core'; import { alertDialogImports } from '@radix-ng/primitives/alert-dialog'; import { RdxMenuModule } from '@radix-ng/primitives/menu'; import { cn, demoButton, demoDialog, demoMenu } from '../../storybook/styles'; @Component({ selector: 'rdx-alert-dialog-from-menu', imports: [...alertDialogImports, RdxMenuModule], template: ` @if (menu.open()) {
}

Delete project?

This permanently deletes the project. Opened by controlling the alert dialog from a menu item.

` }) export class RdxAlertDialogFromMenuComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDialog; protected readonly m = demoMenu; protected readonly deleteOpen = signal(false); } ``` ## API Reference ### Root `RdxAlertDialogRoot` composes the Dialog root and forces alert semantics (always modal, no pointer dismissal, `role="alertdialog"`). ### Trigger `RdxAlertDialogTrigger` opens the alert dialog; it behaves like the dialog trigger. ### Portal, Backdrop, Popup, Title, Description, Close, Viewport These are thin wrappers around the matching dialog parts and read their behavior from context. See the Dialog docs for their full reference. --- # Aspect Ratio #### Displays content within a desired ratio. ```html
Landscape photograph by Tobias Tullius
``` --- # Avatar #### An image element with a fallback for representing the user. ```html ``` ## Features - ✅ Automatic and manual control over when the image renders. - ✅ Fallback part accepts any children. - ✅ Optionally delay fallback rendering to avoid content flashing. ## Import ```typescript import { RdxAvatarRootDirective, RdxAvatarImageDirective, RdxAvatarFallbackDirective } from '@radix-ng/primitives/avatar'; ``` ## Anatomy Assemble the parts: a root, the image, and a fallback shown until the image loads. ```html ... AB ``` ## Global configuration Configure the default options for all avatars with `provideRdxAvatarConfig` in a providers array. ```ts import { provideRdxAvatarConfig } from '@radix-ng/primitives/avatar'; bootstrapApplication(AppComponent, { providers: [provideRdxAvatarConfig({ delayMs: 1000 })] }); ``` ## Examples ### Sizes `sm`, `md`, and `lg` from the demo style layer. ```html ``` ### Fallback The fallback renders when there is no image or the image fails to load. ```html ``` ### Delayed fallback `delayMs` waits before showing the fallback, so it only appears for slower connections. ```html ``` ## API Reference ### Image ### Fallback --- # Button #### A button, or any element that should behave like one. Headless button behavior modeled on [Base UI](https://base-ui.com/)'s `useButton`. It carries no styles — visual variants in the examples come from the centralized demo style layer (see the **Guides/Styling** page). ```typescript import { Component } from '@angular/core'; import { cn, demoButton } from '../../storybook/styles'; import { RdxButtonDirective } from '../src/button.directive'; /** * Visual variants — the recommended `demoButton` styling from the centralized * style layer applied on top of the headless `rdxButton` directive. */ @Component({ selector: 'rdx-button-variants', imports: [RdxButtonDirective], template: `
` }) export class RdxButtonVariantsComponent { protected readonly cn = cn; protected readonly b = demoButton; } ``` ## Features - ✅ Works on a native ` ... ``` ## Examples ### Variants Visual variants — `primary`, `secondary`, `outline`, `ghost`, and `destructive` — come from the shared `demoButton` style layer, not the directive itself. ```typescript import { Component } from '@angular/core'; import { cn, demoButton } from '../../storybook/styles'; import { RdxButtonDirective } from '../src/button.directive'; /** * Visual variants — the recommended `demoButton` styling from the centralized * style layer applied on top of the headless `rdxButton` directive. */ @Component({ selector: 'rdx-button-variants', imports: [RdxButtonDirective], template: `
` }) export class RdxButtonVariantsComponent { protected readonly cn = cn; protected readonly b = demoButton; } ``` ### Sizes `sm`, `md`, `lg`, and a square `icon` size. ```typescript import { Component } from '@angular/core'; import { LucideDynamicIcon, LucidePlus } from '@lucide/angular'; import { cn, demoButton } from '../../storybook/styles'; import { RdxButtonDirective } from '../src/button.directive'; /** * Sizes, including a square icon button. */ @Component({ selector: 'rdx-button-sizes', imports: [RdxButtonDirective, LucideDynamicIcon, LucidePlus], template: `
` }) export class RdxButtonSizesComponent { protected readonly cn = cn; protected readonly b = demoButton; } ``` ### Disabled The first button uses the native `disabled` attribute (removed from the tab order). The second sets `focusableWhenDisabled`, so it stays focusable via `aria-disabled` while its activation is suppressed. ```typescript import { Component } from '@angular/core'; import { cn, demoButton } from '../../storybook/styles'; import { RdxButtonDirective } from '../src/button.directive'; /** * Disabled handling. The first button uses the native `disabled` attribute * (removed from the tab order). The second sets `focusableWhenDisabled`, so it * stays focusable via `aria-disabled` while its activation is suppressed. */ @Component({ selector: 'rdx-button-disabled', imports: [RdxButtonDirective], template: `
` }) export class RdxButtonDisabledComponent { protected readonly cn = cn; protected readonly b = demoButton; } ``` ### As link The directive applies button semantics to any element. Here an `` renders as a button while keeping native link behavior. ```typescript import { Component } from '@angular/core'; import { cn, demoButton } from '../../storybook/styles'; import { RdxButtonDirective } from '../src/button.directive'; /** * The directive works on any host. Here it renders an `` as a button while * keeping native link behavior. */ @Component({ selector: 'rdx-button-as-link', imports: [RdxButtonDirective], template: ` Open Base UI ` }) export class RdxButtonAsLinkComponent { protected readonly cn = cn; protected readonly b = demoButton; } ``` ### Loading For buttons that enter a loading state after being clicked, set `disabled` while pending together with `focusableWhenDisabled` so focus stays on the button. Add `aria-busy` and render a spinner to communicate the state. ```typescript import { Component, signal } from '@angular/core'; import { LucideDynamicIcon, LucidePlus } from '@lucide/angular'; import { cn, demoButton } from '../../storybook/styles'; import { RdxButtonDirective } from '../src/button.directive'; /** * Loading state. Following Base UI's guidance, the button becomes `disabled` * while loading but uses `focusableWhenDisabled` so focus stays on it. `aria-busy` * announces the pending state, and a spinner is rendered in place of the icon. */ @Component({ selector: 'rdx-button-loading', imports: [RdxButtonDirective, LucideDynamicIcon, LucidePlus], template: ` ` }) export class RdxButtonLoadingComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly loading = signal(false); protected run(): void { if (this.loading()) { return; } this.loading.set(true); setTimeout(() => this.loading.set(false), 2000); } } ``` ```html Docs ``` ## API Reference --- # Calendar #### Displays dates and days of the week, facilitating date-related interactions. ```typescript import { Component } from '@angular/core'; import { CalendarDate, DateValue } from '@internationalized/date'; import { LucideChevronLeft, LucideChevronRight } from '@lucide/angular'; import { cn, demoCalendar } from '../../storybook/styles'; import { RdxCalendarCellTriggerDirective } from '../src/calendar-cell-trigger.directive'; import { RdxCalendarCellDirective } from '../src/calendar-cell.directive'; import { RdxCalendarGridBodyDirective } from '../src/calendar-grid-body.directive'; import { RdxCalendarGridHeadDirective } from '../src/calendar-grid-head.directive'; import { RdxCalendarGridDirective } from '../src/calendar-grid.directive'; import { RdxCalendarHeadCellDirective } from '../src/calendar-head-cell.directive'; import { RdxCalendarHeaderDirective } from '../src/calendar-header.directive'; import { RdxCalendarHeadingDirective } from '../src/calendar-heading.directive'; import { RdxCalendarNextDirective } from '../src/calendar-next.directive'; import { RdxCalendarPrevDirective } from '../src/calendar-prev.directive'; import { RdxCalendarRootDirective } from '../src/calendar-root.directive'; @Component({ selector: 'app-calendar-default', imports: [ RdxCalendarRootDirective, RdxCalendarHeaderDirective, RdxCalendarGridDirective, RdxCalendarGridHeadDirective, RdxCalendarGridBodyDirective, RdxCalendarCellTriggerDirective, RdxCalendarCellDirective, RdxCalendarHeadCellDirective, RdxCalendarHeadingDirective, RdxCalendarNextDirective, RdxCalendarPrevDirective, LucideChevronLeft, LucideChevronRight ], template: `
{{ head.headingValue() }}
@for (month of root.months(); track $index) { @for (day of root.weekDays(); track $index) { } @for (weekDates of month.weeks; track $index) { @for (weekDate of weekDates; track $index) { } } }
{{ day }}
{{ cell.dayValue() }}
` }) export class CalendarDefault { date: DateValue = new CalendarDate(2024, 10, 3); protected readonly cn = cn; protected readonly c = demoCalendar; } ``` ## Features - ✅ Full keyboard navigation. - ✅ Can be controlled or uncontrolled - ✅ Focus is fully managed - ✅ Localization support - ✅ Highly composable ## Preface The component depends on the [@internationalized/date package](https://react-spectrum.adobe.com/internationalized/date/index.html), which solves a lot of the problems that come with working with dates and times in JavaScript. We highly recommend reading through the documentation for the package to get a solid feel for how it works, and you'll need to install it in your project to use the date-related components. ## Installation Install the date package. ```bash npm install @internationalized/date ``` Install the component from your command line. ```bash npm install @radix-ng/primitives ``` ## Anatomy Import all parts and piece them together. ```html
{{ head.headingValue() }}
@for (month of root.months(); track $index) { @for (day of root.weekDays(); track $index) { } @for (weekDates of month.weeks; track $index) { @for (weekDate of weekDates; track $index) { } } }
{{ day }}
{{ cell.dayValue() }}
``` ## API Reference ### Root `RdxCalendarRootDirective` Contains all the parts of a calendar ### Header `RdxCalendarHeaderDirective` Contains the navigation buttons and the heading segments. ### Prev Button `RdxCalendarPrevDirective` Calendar navigation button. It navigates the calendar one month/year/decade in the past based on the current calendar view. ### Next Button `RdxCalendarNextDirective` Calendar navigation button. It navigates the calendar one month/year/decade in the future based on the current calendar view. ### Heading `RdxCalendarHeadingDirective` Heading for displaying the current month and year. | exportAs | Description | | ------------- | --------- | | `headingValue` | `string` Current month and year | ### Grid `RdxCalendarGridDirective` Container for wrapping the calendar grid. | Data Attribute | Value | | ------------------ | --------- | | `[data-readonly]` | Present when readonly | | `[data-disabled]` | Present when disabled | ### Grid Head `RdxCalendarGridHeadDirective` Container for wrapping the grid head as `thead`. ### Grid Body `RdxCalendarGridBodyDirective` Container for wrapping the grid body as `tbody`. ### Grid Row `RdxCalendarGridRowDirective` Container for wrapping the grid row as `tr`. ### Head Cell `RdxCalendarHeadCellDirective` Container for wrapping the head cell. Used for displaying the week days as `th`. ### Cell `RdxCalendarCellDirective` Container for wrapping the calendar cells as `td`. | Data Attribute | Value | | ------------------ | --------- | | `[data-disabled]` | Present when disabled | ### Cell Trigger `RdxCalendarCellTriggerDirective` Interactable container for displaying the cell dates. Clicking it selects the date. | Data Attribute | Value | | ------------------ | --------- | | `[data-selected]` | Present when selected | | `[data-value]` | The ISO string value of the date. | | `[data-disabled]` | Present when disabled | | `[data-unavailable]` | Present when unavailable | | `[data-today]` | Present when today | | `[data-outside-view]` | Present when the date is outside the current month it is displayed in. | | `[data-outside-visible-view]` | Present when the date is outside the months that are visible on the calendar. | | `[data-focused]` | Present when focused | ## Examples ### Calendar with Locale and Calendar System Selection This example showcases some of the available locales and how the calendar systems are displayed. ```typescript import { Component, computed, signal } from '@angular/core'; import { CalendarIdentifier, createCalendar, getLocalTimeZone, toCalendar, today } from '@internationalized/date'; import { LucideChevronLeft, LucideChevronRight } from '@lucide/angular'; import { cn, demoCalendar, demoInput } from '../../storybook/styles'; import { RdxCalendarCellTriggerDirective } from '../src/calendar-cell-trigger.directive'; import { RdxCalendarCellDirective } from '../src/calendar-cell.directive'; import { RdxCalendarGridBodyDirective } from '../src/calendar-grid-body.directive'; import { RdxCalendarGridHeadDirective } from '../src/calendar-grid-head.directive'; import { RdxCalendarGridDirective } from '../src/calendar-grid.directive'; import { RdxCalendarHeadCellDirective } from '../src/calendar-head-cell.directive'; import { RdxCalendarHeaderDirective } from '../src/calendar-header.directive'; import { RdxCalendarHeadingDirective } from '../src/calendar-heading.directive'; import { RdxCalendarNextDirective } from '../src/calendar-next.directive'; import { RdxCalendarPrevDirective } from '../src/calendar-prev.directive'; import { RdxCalendarRootDirective } from '../src/calendar-root.directive'; @Component({ selector: 'app-calendar-with-locale', imports: [ RdxCalendarRootDirective, RdxCalendarHeaderDirective, RdxCalendarGridDirective, RdxCalendarGridHeadDirective, RdxCalendarGridBodyDirective, RdxCalendarCellTriggerDirective, RdxCalendarCellDirective, RdxCalendarHeadCellDirective, RdxCalendarHeadingDirective, RdxCalendarNextDirective, RdxCalendarPrevDirective, LucideChevronLeft, LucideChevronRight ], template: `
{{ head.headingValue() }}
@for (month of root.months(); track $index) { @for (day of root.weekDays(); track $index) { } @for (weekDates of month.weeks; track $index) { @for (weekDate of weekDates; track $index) { } } }
{{ day }}
{{ cell.dayValue() }}
` }) export class CalendarWithLocale { readonly preferences = [ { locale: 'en-US', label: 'Default', ordering: 'gregory' }, { label: 'Arabic (Algeria)', locale: 'ar-DZ', territories: 'DJ DZ EH ER IQ JO KM LB LY MA MR OM PS SD SY TD TN YE', ordering: 'gregory islamic islamic-civil islamic-tbla' }, { label: 'Arabic (United Arab Emirates)', locale: 'ar-AE', territories: 'AE BH KW QA', ordering: 'gregory islamic-umalqura islamic islamic-civil islamic-tbla' }, { label: 'Arabic (Egypt)', locale: 'AR-EG', territories: 'EG', ordering: 'gregory coptic islamic islamic-civil islamic-tbla' }, { label: 'Arabic (Saudi Arabia)', locale: 'ar-SA', territories: 'SA', ordering: 'islamic-umalqura gregory islamic islamic-rgsa' }, { label: 'Farsi (Iran)', locale: 'fa-IR', territories: 'IR', ordering: 'persian gregory islamic islamic-civil islamic-tbla' }, { label: 'Farsi (Afghanistan)', locale: 'fa-AF', territories: 'AF IR', ordering: 'persian gregory islamic islamic-civil islamic-tbla' }, { label: 'Amharic (Ethiopia)', locale: 'am-ET', territories: 'ET', ordering: 'gregory ethiopic ethioaa' }, { label: 'Hebrew (Israel)', locale: 'he-IL', territories: 'IL', ordering: 'gregory hebrew islamic islamic-civil islamic-tbla' }, { label: 'Hindi (India)', locale: 'hi-IN', territories: 'IN', ordering: 'gregory indian' }, { label: 'Japanese (Japan)', locale: 'ja-JP', territories: 'JP', ordering: 'gregory japanese' }, { label: 'Thai (Thailand)', locale: 'th-TH', territories: 'TH', ordering: 'buddhist gregory' }, { label: 'Chinese (Taiwan)', locale: 'zh-TW', territories: 'TW', ordering: 'gregory roc chinese' } ]; readonly calendars = [ { key: 'gregory', name: 'Gregorian' }, { key: 'japanese', name: 'Japanese' }, { key: 'buddhist', name: 'Buddhist' }, { key: 'roc', name: 'Taiwan' }, { key: 'persian', name: 'Persian' }, { key: 'indian', name: 'Indian' }, { key: 'islamic-umalqura', name: 'Islamic (Umm al-Qura)' }, { key: 'islamic-civil', name: 'Islamic Civil' }, { key: 'islamic-tbla', name: 'Islamic Tabular' }, { key: 'hebrew', name: 'Hebrew' }, { key: 'coptic', name: 'Coptic' }, { key: 'ethiopic', name: 'Ethiopic' }, { key: 'ethioaa', name: 'Ethiopic (Amete Alem)' } ]; readonly locale = signal(this.preferences[0].locale); readonly calendar = signal(this.calendars[0].key); readonly pref = computed(() => this.preferences.find((p) => p.locale === this.locale())); readonly preferredCalendars = computed(() => { const currentPref = this.pref(); return currentPref ? currentPref.ordering .split(' ') .map((p) => this.calendars.find((c) => c.key === p)) .filter(Boolean) : [this.calendars[0]]; }); readonly value = computed(() => toCalendar(today(getLocalTimeZone()), createCalendar(this.calendar() as CalendarIdentifier)) ); protected readonly cn = cn; protected readonly c = demoCalendar; protected readonly input = demoInput; updateLocale(event: Event) { const newLocale = (event.target as HTMLSelectElement).value; this.locale.set(newLocale); this.calendar.set(this.pref()!.ordering.split(' ')[0]); } updateCalendar(event: Event) { this.calendar.set((event.target as HTMLSelectElement).value); } } ``` ### Multiple selection Set `multiple` to let the calendar hold an array of selected dates; clicking a selected date deselects it. ```typescript import { Component, signal } from '@angular/core'; import { CalendarDate } from '@internationalized/date'; import { LucideChevronLeft, LucideChevronRight } from '@lucide/angular'; import { cn, demoCalendar } from '../../storybook/styles'; import { RdxCalendarCellTriggerDirective } from '../src/calendar-cell-trigger.directive'; import { RdxCalendarCellDirective } from '../src/calendar-cell.directive'; import { RdxCalendarGridBodyDirective } from '../src/calendar-grid-body.directive'; import { RdxCalendarGridHeadDirective } from '../src/calendar-grid-head.directive'; import { RdxCalendarGridDirective } from '../src/calendar-grid.directive'; import { RdxCalendarHeadCellDirective } from '../src/calendar-head-cell.directive'; import { RdxCalendarHeaderDirective } from '../src/calendar-header.directive'; import { RdxCalendarHeadingDirective } from '../src/calendar-heading.directive'; import { RdxCalendarNextDirective } from '../src/calendar-next.directive'; import { RdxCalendarPrevDirective } from '../src/calendar-prev.directive'; import { RdxCalendarRootDirective } from '../src/calendar-root.directive'; @Component({ selector: 'app-calendar-multiple', imports: [ RdxCalendarRootDirective, RdxCalendarHeaderDirective, RdxCalendarGridDirective, RdxCalendarGridHeadDirective, RdxCalendarGridBodyDirective, RdxCalendarCellTriggerDirective, RdxCalendarCellDirective, RdxCalendarHeadCellDirective, RdxCalendarHeadingDirective, RdxCalendarNextDirective, RdxCalendarPrevDirective, LucideChevronLeft, LucideChevronRight ], template: `
{{ head.headingValue() }}
@for (month of root.months(); track $index) { @for (day of root.weekDays(); track $index) { } @for (weekDates of month.weeks; track $index) { @for (weekDate of weekDates; track $index) { } } }
{{ day }}
{{ cell.dayValue() }}
` }) export class CalendarMultiple { readonly value = signal([new CalendarDate(2025, 1, 15), new CalendarDate(2025, 1, 20)]); protected readonly cn = cn; protected readonly c = demoCalendar; } ``` ### Week numbers Render an extra leading column with the ISO week number via `getWeekNumber`. ```typescript import { Component } from '@angular/core'; import { CalendarDate, DateValue } from '@internationalized/date'; import { LucideChevronLeft, LucideChevronRight } from '@lucide/angular'; import { getWeekNumber } from '@radix-ng/primitives/core'; import { cn, demoCalendar } from '../../storybook/styles'; import { RdxCalendarCellTriggerDirective } from '../src/calendar-cell-trigger.directive'; import { RdxCalendarCellDirective } from '../src/calendar-cell.directive'; import { RdxCalendarGridBodyDirective } from '../src/calendar-grid-body.directive'; import { RdxCalendarGridHeadDirective } from '../src/calendar-grid-head.directive'; import { RdxCalendarGridDirective } from '../src/calendar-grid.directive'; import { RdxCalendarHeadCellDirective } from '../src/calendar-head-cell.directive'; import { RdxCalendarHeaderDirective } from '../src/calendar-header.directive'; import { RdxCalendarHeadingDirective } from '../src/calendar-heading.directive'; import { RdxCalendarNextDirective } from '../src/calendar-next.directive'; import { RdxCalendarPrevDirective } from '../src/calendar-prev.directive'; import { RdxCalendarRootDirective } from '../src/calendar-root.directive'; @Component({ selector: 'app-calendar-week', imports: [ RdxCalendarRootDirective, RdxCalendarHeaderDirective, RdxCalendarGridDirective, RdxCalendarGridHeadDirective, RdxCalendarGridBodyDirective, RdxCalendarCellTriggerDirective, RdxCalendarCellDirective, RdxCalendarHeadCellDirective, RdxCalendarHeadingDirective, RdxCalendarNextDirective, RdxCalendarPrevDirective, LucideChevronLeft, LucideChevronRight ], template: `
{{ head.headingValue() }}
@for (month of root.months(); track $index) { @for (day of root.weekDays(); track $index) { } @for (weekDates of month.weeks; track $index) {
{{ getWeekNumber(weekDates[0]) }}
@for (weekDate of weekDates; track $index) { } } }
Wk{{ day }}
{{ cell.dayValue() }}
` }) export class CalendarWeek { date: DateValue = new CalendarDate(2024, 10, 3); protected readonly cn = cn; protected readonly c = demoCalendar; protected readonly getWeekNumber = getWeekNumber; } ``` ### Disabled dates Pass an `isDateDisabled` matcher — a `(date) => boolean` callback run for every rendered date. Disabled dates are not focusable or selectable. This example disables weekends. ```typescript import { Component } from '@angular/core'; import { CalendarDate, DateValue, isWeekend } from '@internationalized/date'; import { LucideChevronLeft, LucideChevronRight } from '@lucide/angular'; import { cn, demoCalendar } from '../../storybook/styles'; import { RdxCalendarCellTriggerDirective } from '../src/calendar-cell-trigger.directive'; import { RdxCalendarCellDirective } from '../src/calendar-cell.directive'; import { RdxCalendarGridBodyDirective } from '../src/calendar-grid-body.directive'; import { RdxCalendarGridHeadDirective } from '../src/calendar-grid-head.directive'; import { RdxCalendarGridDirective } from '../src/calendar-grid.directive'; import { RdxCalendarHeadCellDirective } from '../src/calendar-head-cell.directive'; import { RdxCalendarHeaderDirective } from '../src/calendar-header.directive'; import { RdxCalendarHeadingDirective } from '../src/calendar-heading.directive'; import { RdxCalendarNextDirective } from '../src/calendar-next.directive'; import { RdxCalendarPrevDirective } from '../src/calendar-prev.directive'; import { RdxCalendarRootDirective } from '../src/calendar-root.directive'; @Component({ selector: 'app-calendar-disabled-dates', imports: [ RdxCalendarRootDirective, RdxCalendarHeaderDirective, RdxCalendarGridDirective, RdxCalendarGridHeadDirective, RdxCalendarGridBodyDirective, RdxCalendarCellTriggerDirective, RdxCalendarCellDirective, RdxCalendarHeadCellDirective, RdxCalendarHeadingDirective, RdxCalendarNextDirective, RdxCalendarPrevDirective, LucideChevronLeft, LucideChevronRight ], template: `
{{ head.headingValue() }}
@for (month of root.months(); track $index) { @for (day of root.weekDays(); track $index) { } @for (weekDates of month.weeks; track $index) { @for (weekDate of weekDates; track $index) { } } }
{{ day }}
{{ cell.dayValue() }}
` }) export class CalendarDisabledDates { date: DateValue = new CalendarDate(2024, 10, 3); /** Disable weekends — the matcher runs for every rendered date. */ readonly isDateDisabled = (date: DateValue): boolean => isWeekend(date, 'en-US'); protected readonly cn = cn; protected readonly c = demoCalendar; } ``` ### Unavailable dates `isDateUnavailable` is a `(date) => boolean` callback that marks dates as present-but-not-selectable (e.g. already booked). They render struck-through and ignore pointer interaction. ```typescript import { Component } from '@angular/core'; import { CalendarDate, DateValue } from '@internationalized/date'; import { LucideChevronLeft, LucideChevronRight } from '@lucide/angular'; import { cn, demoCalendar } from '../../storybook/styles'; import { RdxCalendarCellTriggerDirective } from '../src/calendar-cell-trigger.directive'; import { RdxCalendarCellDirective } from '../src/calendar-cell.directive'; import { RdxCalendarGridBodyDirective } from '../src/calendar-grid-body.directive'; import { RdxCalendarGridHeadDirective } from '../src/calendar-grid-head.directive'; import { RdxCalendarGridDirective } from '../src/calendar-grid.directive'; import { RdxCalendarHeadCellDirective } from '../src/calendar-head-cell.directive'; import { RdxCalendarHeaderDirective } from '../src/calendar-header.directive'; import { RdxCalendarHeadingDirective } from '../src/calendar-heading.directive'; import { RdxCalendarNextDirective } from '../src/calendar-next.directive'; import { RdxCalendarPrevDirective } from '../src/calendar-prev.directive'; import { RdxCalendarRootDirective } from '../src/calendar-root.directive'; @Component({ selector: 'app-calendar-unavailable-dates', imports: [ RdxCalendarRootDirective, RdxCalendarHeaderDirective, RdxCalendarGridDirective, RdxCalendarGridHeadDirective, RdxCalendarGridBodyDirective, RdxCalendarCellTriggerDirective, RdxCalendarCellDirective, RdxCalendarHeadCellDirective, RdxCalendarHeadingDirective, RdxCalendarNextDirective, RdxCalendarPrevDirective, LucideChevronLeft, LucideChevronRight ], template: `
{{ head.headingValue() }}
@for (month of root.months(); track $index) { @for (day of root.weekDays(); track $index) { } @for (weekDates of month.weeks; track $index) { @for (weekDate of weekDates; track $index) { } } }
{{ day }}
{{ cell.dayValue() }}
` }) export class CalendarUnavailableDates { date: DateValue = new CalendarDate(2024, 10, 3); /** Mark the 10th–14th as unavailable: rendered struck-through and not selectable. */ readonly isDateUnavailable = (date: DateValue): boolean => date.day >= 10 && date.day <= 14; protected readonly cn = cn; protected readonly c = demoCalendar; } ``` ### Custom navigation Override how the previous/next buttons move the view with `propsPrevPage` / `propsNextPage` — each a `(placeholder) => DateValue` callback. This example jumps a whole year per click. ```typescript import { Component } from '@angular/core'; import { CalendarDate, DateValue } from '@internationalized/date'; import { LucideChevronLeft, LucideChevronRight } from '@lucide/angular'; import { cn, demoCalendar } from '../../storybook/styles'; import { RdxCalendarCellTriggerDirective } from '../src/calendar-cell-trigger.directive'; import { RdxCalendarCellDirective } from '../src/calendar-cell.directive'; import { RdxCalendarGridBodyDirective } from '../src/calendar-grid-body.directive'; import { RdxCalendarGridHeadDirective } from '../src/calendar-grid-head.directive'; import { RdxCalendarGridDirective } from '../src/calendar-grid.directive'; import { RdxCalendarHeadCellDirective } from '../src/calendar-head-cell.directive'; import { RdxCalendarHeaderDirective } from '../src/calendar-header.directive'; import { RdxCalendarHeadingDirective } from '../src/calendar-heading.directive'; import { RdxCalendarNextDirective } from '../src/calendar-next.directive'; import { RdxCalendarPrevDirective } from '../src/calendar-prev.directive'; import { RdxCalendarRootDirective } from '../src/calendar-root.directive'; @Component({ selector: 'app-calendar-custom-navigation', imports: [ RdxCalendarRootDirective, RdxCalendarHeaderDirective, RdxCalendarGridDirective, RdxCalendarGridHeadDirective, RdxCalendarGridBodyDirective, RdxCalendarCellTriggerDirective, RdxCalendarCellDirective, RdxCalendarHeadCellDirective, RdxCalendarHeadingDirective, RdxCalendarNextDirective, RdxCalendarPrevDirective, LucideChevronLeft, LucideChevronRight ], template: `
{{ head.headingValue() }}
@for (month of root.months(); track $index) { @for (day of root.weekDays(); track $index) { } @for (weekDates of month.weeks; track $index) { @for (weekDate of weekDates; track $index) { } } }
{{ day }}
{{ cell.dayValue() }}
` }) export class CalendarCustomNavigation { date: DateValue = new CalendarDate(2024, 10, 3); /** The prev/next buttons jump a whole year instead of a month. */ readonly nextYear = (placeholder: DateValue): DateValue => placeholder.add({ years: 1 }); readonly prevYear = (placeholder: DateValue): DateValue => placeholder.subtract({ years: 1 }); protected readonly cn = cn; protected readonly c = demoCalendar; } ``` ### Multiple months Set `numberOfMonths` to render several months at once. Arrow-key navigation flows across the page boundary between months, and (without `pagedNavigation`) the prev/next buttons shift the view by one month. ```typescript import { Component } from '@angular/core'; import { CalendarDate, DateValue } from '@internationalized/date'; import { LucideChevronLeft, LucideChevronRight } from '@lucide/angular'; import { cn, demoCalendar } from '../../storybook/styles'; import { RdxCalendarCellTriggerDirective } from '../src/calendar-cell-trigger.directive'; import { RdxCalendarCellDirective } from '../src/calendar-cell.directive'; import { RdxCalendarGridBodyDirective } from '../src/calendar-grid-body.directive'; import { RdxCalendarGridHeadDirective } from '../src/calendar-grid-head.directive'; import { RdxCalendarGridDirective } from '../src/calendar-grid.directive'; import { RdxCalendarHeadCellDirective } from '../src/calendar-head-cell.directive'; import { RdxCalendarHeaderDirective } from '../src/calendar-header.directive'; import { RdxCalendarHeadingDirective } from '../src/calendar-heading.directive'; import { RdxCalendarNextDirective } from '../src/calendar-next.directive'; import { RdxCalendarPrevDirective } from '../src/calendar-prev.directive'; import { RdxCalendarRootDirective } from '../src/calendar-root.directive'; @Component({ selector: 'app-calendar-number-of-months', imports: [ RdxCalendarRootDirective, RdxCalendarHeaderDirective, RdxCalendarGridDirective, RdxCalendarGridHeadDirective, RdxCalendarGridBodyDirective, RdxCalendarCellTriggerDirective, RdxCalendarCellDirective, RdxCalendarHeadCellDirective, RdxCalendarHeadingDirective, RdxCalendarNextDirective, RdxCalendarPrevDirective, LucideChevronLeft, LucideChevronRight ], template: `
{{ head.headingValue() }}
@for (month of root.months(); track $index) { @for (day of root.weekDays(); track $index) { } @for (weekDates of month.weeks; track $index) { @for (weekDate of weekDates; track $index) { } }
{{ day }}
{{ cell.dayValue() }}
}
` }) export class CalendarNumberOfMonths { date: DateValue = new CalendarDate(2024, 10, 3); protected readonly cn = cn; protected readonly c = demoCalendar; } ``` ## Accessibility ### Keyboard Interactions | Key | Description | | ------------------ | --------- | | `Tab` | When focus moves onto the calendar, focuses the first navigation button. | | `Space` | When the focus is on either `CalendarNext` or `CalendarPrev`, it navigates the calendar. Otherwise, it selects the date. | | `Enter` | When the focus is on either `CalendarNext` or `CalendarPrev`, it navigates the calendar. Otherwise, it selects the date. | | `ArrowLeft` `ArrowRight` `ArrowUp` `ArrowDown` | When the focus is on `CalendarCellTrigger`, it navigates the dates, changing the month/year/decade if necessary. | --- # Checkbox #### A control that allows the user to toggle between checked and not checked. ```html
``` ## Features - ✅ Full keyboard navigation. - ✅ Supports indeterminate state. - ✅ Can be controlled or uncontrolled. - ✅ Hidden native input for form submission and validation. ## Import ```typescript import { RdxCheckboxRootDirective, RdxCheckboxButtonDirective, RdxCheckboxIndicatorDirective, RdxCheckboxInputDirective } from '@radix-ng/primitives/checkbox'; ``` ## Anatomy Assemble the parts: a root, a button trigger, the indicator, and a hidden input for forms. ```html
``` ## Examples ### Indeterminate The third "mixed" state. Clicking a checkbox in the indeterminate state resolves it to checked. ```typescript import { JsonPipe } from '@angular/common'; import { Component, computed, model } from '@angular/core'; import { LucideDynamicIcon } from '@lucide/angular'; import { RdxCheckboxButtonDirective } from '@radix-ng/primitives/checkbox'; import { RdxLabelDirective } from '@radix-ng/primitives/label'; import { cn, demoButton, demoCheckbox } from '../../storybook/styles'; import { RdxCheckboxIndicatorDirective } from '../src/checkbox-indicator'; import { RdxCheckboxInputDirective } from '../src/checkbox-input'; import { RdxCheckboxRootDirective } from '../src/checkbox-root'; @Component({ selector: 'checkbox-indeterminate-example', imports: [ RdxLabelDirective, RdxCheckboxRootDirective, RdxCheckboxButtonDirective, RdxCheckboxIndicatorDirective, LucideDynamicIcon, RdxCheckboxInputDirective, JsonPipe ], template: `

checked: {{ checked() | json }}

indeterminate: {{ indeterminate() | json }}

` }) export class CheckboxIndeterminate { readonly checked = model(false); readonly indeterminate = model(false); // `checked` and `indeterminate` are orthogonal; the mixed state takes visual priority. readonly iconName = computed(() => (this.indeterminate() ? 'minus' : 'check')); protected readonly cn = cn; protected readonly b = demoButton; protected readonly c = demoCheckbox; toggleIndeterminate() { this.indeterminate.update((value) => !value); } } ``` ### Presence Mount/unmount the indicator with enter/leave animation support via `rdxCheckboxIndicatorPresence`. ```typescript import { JsonPipe } from '@angular/common'; import { Component, model } from '@angular/core'; import { LucideCheck as Check, LucideDynamicIcon } from '@lucide/angular'; import { RdxCheckboxButtonDirective } from '@radix-ng/primitives/checkbox'; import { RdxLabelDirective } from '@radix-ng/primitives/label'; import { demoCheckbox } from '../../storybook/styles'; import { RdxCheckboxIndicatorDirective } from '../src/checkbox-indicator'; import { RdxCheckboxIndicatorPresenceDirective } from '../src/checkbox-indicator-presence'; import { RdxCheckboxInputDirective } from '../src/checkbox-input'; import { RdxCheckboxRootDirective } from '../src/checkbox-root'; @Component({ selector: 'checkbox-presence-example', imports: [ RdxLabelDirective, RdxCheckboxRootDirective, RdxCheckboxButtonDirective, RdxCheckboxIndicatorDirective, RdxCheckboxInputDirective, RdxCheckboxIndicatorPresenceDirective, LucideDynamicIcon, JsonPipe ], template: `

checked state: {{ checked() | json }}

` }) export class CheckboxPresence { readonly checked = model(false); protected readonly Check = Check; protected readonly c = demoCheckbox; } ``` ### Template-driven forms Two-way bind the root with `[(ngModel)]`. ```typescript import { Component } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { LucideCheck } from '@lucide/angular'; import { RdxLabelDirective } from '@radix-ng/primitives/label'; import { demoCheckbox } from '../../storybook/styles'; import { RdxCheckboxButtonDirective } from '../src/checkbox-button'; import { RdxCheckboxIndicatorDirective } from '../src/checkbox-indicator'; import { RdxCheckboxInputDirective } from '../src/checkbox-input'; import { RdxCheckboxRootDirective } from '../src/checkbox-root'; /** * Template-driven forms: two-way bind the root with `[(ngModel)]`. */ @Component({ selector: 'checkbox-ngmodel-example', imports: [ FormsModule, RdxLabelDirective, RdxCheckboxRootDirective, RdxCheckboxButtonDirective, RdxCheckboxIndicatorDirective, RdxCheckboxInputDirective, LucideCheck ], template: `

subscribed: {{ subscribed }}

` }) export class CheckboxNgModelExample { protected readonly c = demoCheckbox; subscribed = false; } ``` ### Reactive forms Bind the root with `formControlName`; `disabled` reacts to the control's enable/disable state. ```typescript import { JsonPipe } from '@angular/common'; import { Component, inject } from '@angular/core'; import { FormBuilder, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { LucideCheck } from '@lucide/angular'; import { RdxCheckboxButtonDirective } from '@radix-ng/primitives/checkbox'; import { RdxLabelDirective } from '@radix-ng/primitives/label'; import { cn, demoButton, demoCheckbox } from '../../storybook/styles'; import { RdxCheckboxIndicatorDirective } from '../src/checkbox-indicator'; import { RdxCheckboxInputDirective } from '../src/checkbox-input'; import { RdxCheckboxRootDirective } from '../src/checkbox-root'; @Component({ selector: 'checkbox-groups-forms-example', template: `

You chose: 

{{ personality.value | json }}
`, imports: [ FormsModule, ReactiveFormsModule, JsonPipe, RdxLabelDirective, RdxCheckboxRootDirective, RdxCheckboxButtonDirective, RdxCheckboxIndicatorDirective, LucideCheck, RdxCheckboxInputDirective ] }) export class CheckboxReactiveFormsExampleComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly c = demoCheckbox; private readonly formBuilder = inject(FormBuilder); personality = this.formBuilder.group({ fun: false, serious: false, smart: false }); toggleDisable() { const checkbox = this.personality.get('serious'); if (checkbox != null) { checkbox.disabled ? checkbox.enable() : checkbox.disable(); } } } ``` ### Validation `Validators.requiredTrue` enforces acceptance; the error shows once the field is touched and submit is guarded. ```typescript import { Component, inject, signal } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { LucideCheck } from '@lucide/angular'; import { RdxLabelDirective } from '@radix-ng/primitives/label'; import { cn, demoButton, demoCheckbox } from '../../storybook/styles'; import { RdxCheckboxButtonDirective } from '../src/checkbox-button'; import { RdxCheckboxIndicatorDirective } from '../src/checkbox-indicator'; import { RdxCheckboxInputDirective } from '../src/checkbox-input'; import { RdxCheckboxRootDirective } from '../src/checkbox-root'; /** * Reactive forms with validation: `Validators.requiredTrue` forces the box to be * ticked, the error shows after the field is touched, and submit is guarded. */ @Component({ selector: 'checkbox-validation-example', imports: [ ReactiveFormsModule, RdxLabelDirective, RdxCheckboxRootDirective, RdxCheckboxButtonDirective, RdxCheckboxIndicatorDirective, RdxCheckboxInputDirective, LucideCheck ], template: `
@if (form.controls.terms.invalid && form.controls.terms.touched) {

You must accept the terms to continue.

} @if (submitted()) {

Submitted ✓

}
` }) export class CheckboxValidationExample { protected readonly cn = cn; protected readonly b = demoButton; protected readonly c = demoCheckbox; private readonly formBuilder = inject(FormBuilder); readonly form = this.formBuilder.group({ terms: this.formBuilder.control(false, Validators.requiredTrue) }); readonly submitted = signal(false); onSubmit(): void { if (this.form.invalid) { this.form.controls.terms.markAsTouched(); return; } this.submitted.set(true); } } ``` There are two ways to build a "select all" parent. Pick by how much you want to own: | | **Select all** (below) | **Checkbox group** | | --- | --- | --- | | Source of truth | your own model (one boolean per item) | the group's `string[]` value (the checked `name`s) | | Parent / indeterminate logic | written by hand in the component | built in (`parent` + `allValues`) | | Parent click | flat toggle: all ↔ none | remembers the partial selection: partial → all → none → partial | | Disabled child during select-all | you handle it | preserved automatically | | Forms integration | wire each control yourself | the group is one control (`[(value)]` / `ngModel` / reactive forms) | Reach for **Select all** when you already manage the items yourself and just need the derived parent state. Reach for the **Checkbox group** when you want the array value and the Base UI parent behavior for free. ### Select all A parent checkbox derived from its children — `indeterminate` when only some are checked. Here the parent/child logic (and the flat all ↔ none toggle) is wired **by hand** in the component. ```typescript import { Component, computed, signal } from '@angular/core'; import { LucideCheck, LucideDynamicIcon } from '@lucide/angular'; import { RdxLabelDirective } from '@radix-ng/primitives/label'; import { demoCheckbox } from '../../storybook/styles'; import { RdxCheckboxButtonDirective } from '../src/checkbox-button'; import { RdxCheckboxIndicatorDirective } from '../src/checkbox-indicator'; import { RdxCheckboxInputDirective } from '../src/checkbox-input'; import { CheckedState, RdxCheckboxRootDirective } from '../src/checkbox-root'; interface Item { id: string; label: string; checked: boolean; } /** * A "select all" parent whose state is derived from its children: checked when * all are ticked, `indeterminate` when only some are, unchecked otherwise. */ @Component({ selector: 'checkbox-select-all-example', imports: [ RdxLabelDirective, RdxCheckboxRootDirective, RdxCheckboxButtonDirective, RdxCheckboxIndicatorDirective, RdxCheckboxInputDirective, LucideDynamicIcon, LucideCheck ], template: `
@for (item of items(); track item.id) {
}
` }) export class CheckboxSelectAllExample { protected readonly c = demoCheckbox; readonly items = signal([ { id: 'apples', label: 'Apples', checked: true }, { id: 'bananas', label: 'Bananas', checked: false }, { id: 'cherries', label: 'Cherries', checked: false } ]); protected readonly parentState = computed(() => { const items = this.items(); if (items.every((item) => item.checked)) { return true; } return items.some((item) => item.checked) ? 'indeterminate' : false; }); protected toggleAll(checked: boolean): void { // `onCheckedChange` emits a boolean: clicking the parent resolves // indeterminate -> checked (tick all), or checked -> unchecked (clear all). this.items.update((items) => items.map((item) => ({ ...item, checked }))); } protected toggleItem(id: string, checked: boolean): void { this.items.update((items) => items.map((item) => (item.id === id ? { ...item, checked } : item))); } } ``` ### Checkbox group The same UI, but `rdxCheckboxGroup` owns it. It manages a single array value — the `name`s of the checked checkboxes — and a child marked `parent` becomes the "select all", with its state derived from `allValues`. Clicking the parent **remembers the partial selection** (partial → all → none → back to the partial), disabled-but-checked children are preserved, and the group is itself a form control, so `[(value)]`, `ngModel`, and reactive forms bind to the `string[]` value. ```typescript import { Component, signal } from '@angular/core'; import { LucideCheck, LucideDynamicIcon } from '@lucide/angular'; import { RdxLabelDirective } from '@radix-ng/primitives/label'; import { demoCheckbox } from '../../storybook/styles'; import { RdxCheckboxButtonDirective } from '../src/checkbox-button'; import { RdxCheckboxGroupDirective } from '../src/checkbox-group'; import { RdxCheckboxIndicatorDirective } from '../src/checkbox-indicator'; import { RdxCheckboxRootDirective } from '../src/checkbox-root'; /** * `rdxCheckboxGroup` holds the array of checked names. Each child participates by its `name`, and * the checkbox marked `parent` becomes a "select all" whose state (checked / indeterminate / * unchecked) is derived from `allValues` — no manual wiring. * * Try the parent from a partial selection: it cycles partial → all → none → back to your partial * selection, instead of a flat all/none toggle. */ @Component({ selector: 'checkbox-group-example', imports: [ RdxLabelDirective, RdxCheckboxGroupDirective, RdxCheckboxRootDirective, RdxCheckboxButtonDirective, RdxCheckboxIndicatorDirective, LucideDynamicIcon, LucideCheck ], template: `
@for (item of items; track item.name) {
}
` }) export class CheckboxGroupExample { protected readonly c = demoCheckbox; protected readonly items = [ { name: 'apples', label: 'Apples' }, { name: 'bananas', label: 'Bananas' }, { name: 'cherries', label: 'Cherries' } ]; protected readonly all = this.items.map((item) => item.name); value = signal(['apples']); } ``` ## API Reference The `Button`, `Indicator` and `Input` parts take no inputs of their own — they read the checkbox state from the root context. All configuration lives on the root. ### Root ### Checkbox Group `rdxCheckboxGroup` — a `role="group"` container holding the array of checked `name`s. Mark a child checkbox `parent` to make it select/deselect every name in `allValues`. ## Accessibility Adheres to the [tri-state Checkbox WAI-ARIA design pattern](https://www.w3.org/WAI/ARIA/apg/patterns/checkbox). ### Keyboard Interactions | Key | Description | | ----- | ----------------------------- | | Space | Checks/unchecks the checkbox. | --- # Collapsible #### A panel that expands and collapses, revealing or hiding its contents. ```html
@peduarte starred 3 repositories
@radix-ui/primitives
@radix-ui/colors
@stitches/react
``` ## Features - ✅ Can be controlled or uncontrolled. - ✅ Full keyboard support. - ✅ Exposes open/close state via `data-open` / `data-closed` for styling. - ✅ Enter and exit transitions via `data-starting-style` / `data-ending-style`. - ✅ Exposes the panel size as `--collapsible-panel-height` / `--collapsible-panel-width` for height/width animations. - ✅ Optional `hiddenUntilFound` so the browser's find-in-page can reveal collapsed content. ## Import Get started with importing the directives: ```typescript import { RdxCollapsibleRootDirective, RdxCollapsibleTriggerDirective, RdxCollapsiblePanelDirective, RdxCollapsiblePanelPresenceDirective } from '@radix-ng/primitives/collapsible'; ``` ## Anatomy Assemble the collapsible from its parts. ```html
Panel
``` To unmount the panel contents while collapsed (instead of keeping them hidden in the DOM), wrap them in the presence directive: ```html
Panel
``` ## Examples ### Keep mounted With `keepMounted`, the closed panel stays in the DOM without a `hidden` attribute, so it can be collapsed with CSS using the `data-open` / `data-closed` attributes. ```html
@peduarte starred 3 repositories
@radix-ui/primitives
@radix-ui/colors
@stitches/react
``` ### Animation The panel exposes `--collapsible-panel-height` so its contents can animate open and closed. ```html ``` ### External trigger The open state can be controlled from outside the collapsible via the `open` model. ```html ``` ## API Reference ### Root `RdxCollapsibleRootDirective` ### Trigger `RdxCollapsibleTriggerDirective` A button that toggles the panel. Reads everything from the root context; it exposes `aria-expanded`, `aria-controls`, and a `data-panel-open` attribute while open. ### Panel `RdxCollapsiblePanelDirective` --- # Context Menu #### A menu that appears at the pointer on right click (or touch long-press), built on the Menu primitive. Context Menu reuses the full [Menu](?path=/docs/primitives-menu--docs) primitive for its popup, items, submenus, checkbox/radio items, and separators. The only difference is the trigger: instead of a button that anchors the popup to itself, `rdxContextMenuTrigger` opens the menu at the pointer position on a right click or long-press. ```typescript import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; import { LucideCheck, LucideDot } from '@lucide/angular'; import { RdxContextMenuModule } from '@radix-ng/primitives/context-menu'; import { RdxMenuModule } from '@radix-ng/primitives/menu'; import { cn, demoMenu } from '../../storybook/styles'; @Component({ selector: 'rdx-context-menu-default', imports: [RdxContextMenuModule, RdxMenuModule, LucideCheck, LucideDot], changeDetection: ChangeDetectionStrategy.OnPush, template: `
Right click here
@if (root.menuRoot.open()) {
@if (moreSub.open()) {
}
People
}
` }) export class RdxContextMenuDefaultComponent { protected readonly cn = cn; protected readonly m = demoMenu; protected readonly shortcut = 'ml-auto pl-4 text-xs text-muted-foreground'; showBookmarks = signal(true); showFullUrls = signal(false); person = signal('pedro'); } ``` ## Features - ✅ Opens at the pointer on right click; on touch, opens after a long-press. - ✅ Suppresses the native browser context menu. - ✅ Reuses every Menu popup part — items, submenus, checkbox/radio items, groups, separators. - ✅ Opened by pointer, the popup is focused with no item highlighted; opened by keyboard, the first item is highlighted. - ✅ Full keyboard navigation once open (ArrowDown / ArrowUp / Home / End / typeahead). - ✅ Closes on Escape, outside pointer interaction, and item selection — restoring focus. - ✅ Headless — state is exposed via `data-state`; styling is up to the consumer. ## Import ```typescript import { RdxContextMenuRoot, RdxContextMenuTrigger } from '@radix-ng/primitives/context-menu'; import { RdxMenuModule } from '@radix-ng/primitives/menu'; ``` Or import the context-menu parts through the module: ```typescript import { RdxContextMenuModule } from '@radix-ng/primitives/context-menu'; ``` ## Anatomy Wrap the trigger area and the Menu popup in a `rdxContextMenuRoot`. The popup is assembled from the standard Menu parts and positioned at the pointer, so `rdxMenuPositioner` needs no `anchor`. ```html
Right click here
@if (root.menuRoot.open()) {
@if (sub.open()) {
}
}
``` ## Examples ### Default A right-click area with shortcuts, a disabled item, a submenu, checkbox items, and a radio group. Right-click the dashed area to open the menu at the pointer. ```typescript import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; import { LucideCheck, LucideDot } from '@lucide/angular'; import { RdxContextMenuModule } from '@radix-ng/primitives/context-menu'; import { RdxMenuModule } from '@radix-ng/primitives/menu'; import { cn, demoMenu } from '../../storybook/styles'; @Component({ selector: 'rdx-context-menu-default', imports: [RdxContextMenuModule, RdxMenuModule, LucideCheck, LucideDot], changeDetection: ChangeDetectionStrategy.OnPush, template: `
Right click here
@if (root.menuRoot.open()) {
@if (moreSub.open()) {
}
People
}
` }) export class RdxContextMenuDefaultComponent { protected readonly cn = cn; protected readonly m = demoMenu; protected readonly shortcut = 'ml-auto pl-4 text-xs text-muted-foreground'; showBookmarks = signal(true); showFullUrls = signal(false); person = signal('pedro'); } ``` ## Keyboard interactions When opened with a pointer (right click / long-press) the popup itself receives focus and no item is highlighted; pressing ArrowDown / ArrowUp then moves into the items. When opened from the keyboard (the Menu key / Shift + F10) the first item is highlighted right away. Otherwise the popup behaves exactly like a [Menu](?path=/docs/primitives-menu--docs): ArrowDown / ArrowUp move between items, Home / End jump to the first / last item, typeahead matches item text, and Escape closes the menu. ## API Reference ### RdxContextMenuRoot Groups all parts of the context menu and provides the Menu context consumed by the popup. Composes the Menu primitive, so it forwards the `open` (two-way), `modal`, `loopFocus`, and `highlightItemOnHover` inputs and the `onOpenChange` / `onOpenChangeComplete` outputs from `rdxMenuRoot`. ### RdxContextMenuTrigger The area that opens the menu on right click or touch long-press. Exposes `data-state` (`"open"` / `"closed"`) and `data-disabled`. ### Menu parts All other parts — `rdxMenuPositioner`, `rdxMenuPopup`, `rdxMenuItem`, `rdxMenuCheckboxItem`, `rdxMenuRadioGroup`, `rdxMenuSubTrigger`, `rdxMenuSeparator`, … — come from the [Menu](?path=/docs/primitives-menu--docs) primitive and behave identically here. --- # Cropper #### Headless component for interactive image cropping — inspired by the experience on [X](https://x.com/) Based on React version [image-cropper](https://github.com/origin-space/image-cropper) ```html
``` ## Features - ✅ Interactive: Supports zooming (mouse wheel, pinch gesture) and panning (mouse drag, touch drag, arrow keys). - ✅ Aspect Ratio: Enforces a specified aspect ratio for the crop area. - ✅ Controlled/Uncontrolled: Manage zoom state internally or control it via props. - ✅ Crop Calculation: Outputs precise pixel coordinates of the cropped area relative to the original image. - ✅ Accessible: Designed with ARIA attributes and requires a description element for screen reader users. - ✅ Customizable: Control zoom limits, sensitivity, padding, keyboard steps, and apply custom styles. ## Anatomy Import all parts and piece them together. ```html
``` ## API Reference ### Root `RdxCropperRootDirective` The main container and controller. It handles logic, state, and interactions. ### Description `RdxCropperDescriptionDirective` The description element for screen reader users. Renders a `
` intended for accessibility instructions. Its id is automatically linked via `aria-describedby` on the *Root* element. ### Image `RdxCropperImageComponent` Renders the actual `` tag. It's positioned and scaled by `rdxCropperRoot`. ### Crop Area `RdxCropperCropAreaDirective` A simple `
` representing the visual crop area. You style this component to show the bounds. ## Accessibility It is crucial to include a `CropperDescription` component within `CropperRoot`. This provides necessary context for screen reader users about how to interact with the cropper. If you don't provide one, a warning will appear in the console. You can visually hide the description using standard CSS techniques (e.g., an `sr-only` class). ## Examples ### With Data Area ```html
``` --- # Date Field #### Enables users to input specific dates within a designated field. ```typescript import { Component, input, model } from '@angular/core'; import { DateValue } from '@internationalized/date'; import { Granularity } from '@radix-ng/primitives/core'; import { RdxVisuallyHiddenInputDirective } from '@radix-ng/primitives/visually-hidden'; import { RdxDateFieldInputDirective } from '../src/date-field-input.directive'; import { RdxDateFieldRootDirective } from '../src/date-field-root.directive'; @Component({ selector: 'app-date-field', imports: [RdxDateFieldRootDirective, RdxDateFieldInputDirective, RdxVisuallyHiddenInputDirective], template: `
@for (item of root.segmentContents(); track $index) { @if (item.part === 'literal') {
{{ item.value }}
} @else {
{{ item.value }}
} }
` }) export class DateFieldComponent { /** Locale used to format and order the segments. */ readonly locale = input('en'); /** How much of the date/time to render — `'day'` shows date only, `'second'` adds the time. */ readonly granularity = input('day'); readonly value = model(); } ``` ## Features - ✅ Full keyboard navigation - ✅ Can be controlled or uncontrolled - ✅ Focus is fully managed - ✅ Localization support - ✅ Highly composable - ✅ Accessible by default - ✅ Supports both date and date-time formats ## Preface The component depends on the [@internationalized/date package](https://react-spectrum.adobe.com/internationalized/date/index.html), which solves a lot of the problems that come with working with dates and times in JavaScript. We highly recommend reading through the documentation for the package to get a solid feel for how it works, and you'll need to install it in your project to use the date-related components. ## Installation Install the date package. ```bash npm install @internationalized/date ``` Install the component from your command line. ```bash npm install @radix-ng/primitives ``` ## Anatomy Import all parts and piece them together. ```html
``` ## API Reference ### Root `RdxDateFieldRootDirective` Contains all the parts of a date field ### Input `RdxDateFieldInputDirective` Contains the date field segments ## Examples ### With locale #### Gregorian ```html ``` #### Hebrew ```html ``` #### Taiwan ```html ``` #### Japanese ```html ``` #### Persian ```html ``` #### Russian ```html ``` ### Invalid ```typescript import { Component } from '@angular/core'; import { DateValue } from '@internationalized/date'; import { RdxVisuallyHiddenInputDirective } from '@radix-ng/primitives/visually-hidden'; import { RdxDateFieldInputDirective } from '../src/date-field-input.directive'; import { RdxDateFieldRootDirective } from '../src/date-field-root.directive'; @Component({ selector: 'app-date-field-invalid', imports: [RdxDateFieldRootDirective, RdxDateFieldInputDirective, RdxVisuallyHiddenInputDirective], template: `
@for (item of root.segmentContents(); track $index) { @if (item.part === 'literal') {
{{ item.value }}
} @else {
{{ item.value }}
} } @if (root.isInvalid()) { Invalid Day }
` }) export class DateFieldInvalid { isDateUnavailable(date: DateValue): boolean { return date.day === 19; } } ``` --- # Dialog #### A window overlaid on the page, rendering the content underneath inert. Dialog composes the shared Portal, Presence, Dismissable Layer, and Focus Scope primitives. It stays headless: styles and native CSS animations belong to the consumer. ```typescript import { Component } from '@angular/core'; import { LucideX } from '@lucide/angular'; import { dialogImports } from '@radix-ng/primitives/dialog'; import { cn, demoButton, demoDialog, demoInput } from '../../storybook/styles'; @Component({ selector: 'rdx-dialog-default', imports: [...dialogImports, LucideX], template: `

Edit profile

Make changes to your profile here. Click save when you're done.

` }) export class RdxDialogDefaultComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDialog; protected readonly input = demoInput; } ``` ## Features - ✅ Opens and closes from a native button trigger. - ✅ Supports uncontrolled state, `defaultOpen`, and Angular two-way binding with `[(open)]`. - ✅ Supports modal, non-modal, and focus-trapping-only behavior. - ✅ Locks document scrolling and disables outside pointer events while modal. - ✅ Closes on Escape, outside pointer interaction, or an explicit close button. - ✅ Keeps the dialog open on outside clicks with `disablePointerDismissal`. - ✅ Traps and restores focus through the shared Focus Scope behavior. - ✅ Exposes state and transition attributes (`data-state`, `data-starting-style`, `data-ending-style`) for styling. - ✅ Keeps portal content mounted while CSS exit keyframes finish. - ✅ Links the popup to optional title and description parts for accessible labeling. - ✅ Supports multiple triggers, controlled `triggerId`, and detached triggers through a shared handle. - ✅ Supports nested dialogs with `data-nested` / `data-nested-dialog-open` styling hooks. - ✅ Provides an optional scrollable `Viewport` for outside-scroll dialogs. ## Import ```typescript import { createRdxDialogHandle, RdxDialogBackdrop, RdxDialogClose, RdxDialogDescription, RdxDialogPopup, RdxDialogPortal, RdxDialogPortalPresence, RdxDialogRoot, RdxDialogTitle, RdxDialogTrigger, RdxDialogViewport } from '@radix-ng/primitives/dialog'; ``` Or import all parts through the module: ```typescript import { RdxDialogModule } from '@radix-ng/primitives/dialog'; ``` The `dialogImports` array re-exports every part for standalone `imports`. ## Anatomy Apply the parts to your own markup. `rdxDialogPortalPresence` manages mounting and waits for exit keyframes on the first DOM element inside its template. ```html

Edit profile

Make changes to your profile.

``` ## Examples ### Default A modal dialog with an accessible title and description, form fields, and close buttons. ```typescript import { Component } from '@angular/core'; import { LucideX } from '@lucide/angular'; import { dialogImports } from '@radix-ng/primitives/dialog'; import { cn, demoButton, demoDialog, demoInput } from '../../storybook/styles'; @Component({ selector: 'rdx-dialog-default', imports: [...dialogImports, LucideX], template: `

Edit profile

Make changes to your profile here. Click save when you're done.

` }) export class RdxDialogDefaultComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDialog; protected readonly input = demoInput; } ``` ### Controlled Bind `[(open)]` when application state should open or close the dialog programmatically. ```typescript import { Component, signal } from '@angular/core'; import { LucideX } from '@lucide/angular'; import { dialogImports } from '@radix-ng/primitives/dialog'; import { cn, demoButton, demoDialog } from '../../storybook/styles'; @Component({ selector: 'rdx-dialog-controlled', imports: [...dialogImports, LucideX], template: `

Dialog is {{ open() ? 'open' : 'closed' }}

Controlled dialog

The open state is owned by the component and bound with [(open)] .

` }) export class RdxDialogControlledComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDialog; protected readonly open = signal(false); } ``` ### Non-modal Set `[modal]="false"` to keep document scrolling and outside pointer interactions available. There is no backdrop in this mode. ```typescript import { Component, signal } from '@angular/core'; import { LucideX } from '@lucide/angular'; import { dialogImports } from '@radix-ng/primitives/dialog'; import { cn, demoButton, demoDialog } from '../../storybook/styles'; @Component({ selector: 'rdx-dialog-non-modal', imports: [...dialogImports, LucideX], template: `

Non-modal: page scrolling and outside pointer interactions stay enabled while the dialog is open.

Non-modal dialog

There is no backdrop, so you can keep interacting with the rest of the page.

` }) export class RdxDialogNonModalComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDialog; protected readonly outsideClicks = signal(0); } ``` ### Trap focus only Use `modal="trap-focus"` to keep keyboard focus inside the dialog while leaving document scrolling and outside pointer interactions enabled. ```typescript import { Component, signal } from '@angular/core'; import { LucideX } from '@lucide/angular'; import { dialogImports } from '@radix-ng/primitives/dialog'; import { cn, demoButton, demoDialog, demoInput } from '../../storybook/styles'; @Component({ selector: 'rdx-dialog-trap-focus', imports: [...dialogImports, LucideX], template: `

Trap focus: keyboard focus stays inside the dialog (Tab cycles its controls), while page scrolling and outside pointer interactions remain available.

Focus is trapped

Press Tab and notice focus never leaves the dialog.

` }) export class RdxDialogTrapFocusComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDialog; protected readonly input = demoInput; protected readonly outsideClicks = signal(0); } ``` ### Without pointer dismissal Set `disablePointerDismissal` so clicking the backdrop no longer closes the dialog. Escape and explicit close buttons still close it. ```typescript import { Component } from '@angular/core'; import { LucideX } from '@lucide/angular'; import { dialogImports } from '@radix-ng/primitives/dialog'; import { cn, demoButton, demoDialog } from '../../storybook/styles'; @Component({ selector: 'rdx-dialog-without-dismiss', imports: [...dialogImports, LucideX], template: `

Confirm your choice

Clicking the backdrop will not close this dialog. Use a button or press Escape instead.

` }) export class RdxDialogWithoutDismissComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDialog; } ``` ### Multiple triggers Several `rdxDialogTrigger` buttons can open the same dialog. The active trigger and its `payload` are exposed on the root so the content can adapt. ```typescript import { Component } from '@angular/core'; import { LucideX } from '@lucide/angular'; import { dialogImports } from '@radix-ng/primitives/dialog'; import { cn, demoButton, demoDialog } from '../../storybook/styles'; @Component({ selector: 'rdx-dialog-multiple-triggers', imports: [...dialogImports, LucideX], template: `

{{ root.payload() || 'Fruit' }}

Every trigger opens the same dialog; the active trigger's payload is shown here.

` }) export class RdxDialogMultipleTriggersComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDialog; } ``` ### Controlled mode with multiple triggers Bind both `[(open)]` and `[(triggerId)]` to choose which trigger is active from component state. ```typescript import { Component, signal } from '@angular/core'; import { LucideX } from '@lucide/angular'; import { dialogImports } from '@radix-ng/primitives/dialog'; import { cn, demoButton, demoDialog } from '../../storybook/styles'; @Component({ selector: 'rdx-dialog-controlled-multiple', imports: [...dialogImports, LucideX], template: `

open: {{ open() }} · triggerId: {{ triggerId() ?? '—' }}

{{ triggerId() === 'billing' ? 'Billing' : 'Account' }}

Both open and triggerId are bound, so the active panel is driven from component state.

` }) export class RdxDialogControlledMultipleComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDialog; protected readonly open = signal(false); protected readonly triggerId = signal(null); protected openBilling() { this.triggerId.set('billing'); this.open.set(true); } } ``` ### Detached triggers Create a shared handle with `createRdxDialogHandle()` when triggers live outside `rdxDialogRoot`. The handle also supports imperative `open(id)`, `toggle(id)`, and `close()`. ```typescript import { Component } from '@angular/core'; import { LucideX } from '@lucide/angular'; import { createRdxDialogHandle, dialogImports } from '@radix-ng/primitives/dialog'; import { cn, demoButton, demoDialog } from '../../storybook/styles'; @Component({ selector: 'rdx-dialog-detached', imports: [...dialogImports, LucideX], template: `

Detached triggers

The triggers and this dialog are connected with createRdxDialogHandle() rather than DOM nesting.

` }) export class RdxDialogDetachedComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDialog; protected readonly handle = createRdxDialogHandle(); } ``` ### Nested dialogs Dialogs can be nested. The parent popup gains `data-nested-dialog-open` and the child popup gains `data-nested`, which the demo uses to scale the parent back. ```typescript import { Component } from '@angular/core'; import { LucideX } from '@lucide/angular'; import { dialogImports } from '@radix-ng/primitives/dialog'; import { cn, demoButton, demoDialog } from '../../storybook/styles'; @Component({ selector: 'rdx-dialog-nested', imports: [...dialogImports, LucideX], template: `

Parent dialog

Opening the nested dialog scales this popup back via [data-nested-dialog-open] .

Nested dialog

Escape or the backdrop closes this one first, then the parent.

` }) export class RdxDialogNestedComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDialog; } ``` ### Close confirmation Drive the dialog with `[(open)]` and listen to `onOpenChange` to intercept a close request and show a confirmation dialog when there are unsaved changes. ```typescript import { Component, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { LucideX } from '@lucide/angular'; import { dialogImports, RdxDialogOpenChange } from '@radix-ng/primitives/dialog'; import { cn, demoButton, demoDialog, demoInput } from '../../storybook/styles'; @Component({ selector: 'rdx-dialog-close-confirmation', imports: [...dialogImports, FormsModule, LucideX], template: `

Edit description

Closing with unsaved changes asks for confirmation.

Discard changes?

Your edits will be lost.

` }) export class RdxDialogCloseConfirmationComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDialog; protected readonly input = demoInput; protected readonly editorOpen = signal(false); protected readonly confirmOpen = signal(false); protected text = ''; private get hasChanges() { return this.text.trim().length > 0; } protected onEditorOpenChange(change: RdxDialogOpenChange) { // Re-open the editor and ask for confirmation when there are unsaved changes. if (!change.open && this.hasChanges) { this.editorOpen.set(true); this.confirmOpen.set(true); } } protected requestClose() { if (this.hasChanges) { this.confirmOpen.set(true); } else { this.editorOpen.set(false); } } protected save() { this.text = ''; this.editorOpen.set(false); } protected discard() { this.text = ''; this.confirmOpen.set(false); this.editorOpen.set(false); } } ``` ### Outside scroll Wrap the popup in `rdxDialogViewport` to make the whole dialog scroll when it is taller than the screen. ```typescript import { Component } from '@angular/core'; import { LucideX } from '@lucide/angular'; import { dialogImports } from '@radix-ng/primitives/dialog'; import { cn, demoButton, demoDialog } from '../../storybook/styles'; @Component({ selector: 'rdx-dialog-outside-scroll', imports: [...dialogImports, LucideX], template: `

Terms of service

The whole dialog scrolls within the viewport.

@for (paragraph of paragraphs; track $index) {

{{ paragraph }}

}
` }) export class RdxDialogOutsideScrollComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDialog; protected readonly paragraphs = Array.from( { length: 12 }, (_, i) => `Section ${i + 1}. This is filler content that makes the dialog taller than the viewport so the outer container scrolls.` ); } ``` ### Inside scroll Keep the popup fixed on screen and scroll an inner region instead. ```typescript import { Component } from '@angular/core'; import { LucideX } from '@lucide/angular'; import { dialogImports } from '@radix-ng/primitives/dialog'; import { cn, demoButton, demoDialog } from '../../storybook/styles'; @Component({ selector: 'rdx-dialog-inside-scroll', imports: [...dialogImports, LucideX], template: `

Release notes

Header and footer stay put while the body scrolls.

@for (paragraph of paragraphs; track $index) {

{{ paragraph }}

}
` }) export class RdxDialogInsideScrollComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDialog; protected readonly paragraphs = Array.from( { length: 14 }, (_, i) => `Change ${i + 1}. Lots of details that overflow the fixed-height popup and scroll inside it.` ); } ``` ### Open from a menu Control the dialog's `open` state from a menu item to launch it from a `Menu`. ```typescript import { Component, signal } from '@angular/core'; import { LucideX } from '@lucide/angular'; import { dialogImports } from '@radix-ng/primitives/dialog'; import { RdxMenuModule } from '@radix-ng/primitives/menu'; import { cn, demoButton, demoDialog, demoMenu } from '../../storybook/styles'; @Component({ selector: 'rdx-dialog-from-menu', imports: [...dialogImports, RdxMenuModule, LucideX], template: ` @if (menu.open()) {
}

Rename item

Opened by controlling the dialog from a menu item.

` }) export class RdxDialogFromMenuComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDialog; protected readonly m = demoMenu; protected readonly renameOpen = signal(false); } ``` ## API Reference ### Root `RdxDialogRoot` owns the open state and modal behavior, and exposes `onOpenChange` / `onOpenChangeComplete`. ### Trigger `RdxDialogTrigger` toggles the dialog and exposes ARIA attributes. ### Portal `RdxDialogPortal` moves content to `document.body` by default or to a configured container. ### Viewport `RdxDialogViewport` is an optional scrollable container placed inside the portal, around the popup. It reads everything from context and exposes no inputs. ### Popup `RdxDialogPopup` owns dialog semantics, dismissal events, scroll locking, and focus lifecycle events. ### Backdrop, Title, Description, and Close These parts read their behavior and state from context and do not expose additional inputs or outputs. --- # Drawer #### An edge-anchored sheet that opens over the page and dismisses with a swipe. Drawer builds on the declarative Dialog: it composes the same Portal, Presence, Dismissable Layer, Focus Scope, and scroll-lock behavior, then layers a headless swipe-to-dismiss gesture on top. It is modal by default but, unlike Alert Dialog, leaves modality and dismissal fully configurable. Styling and native CSS animations belong to the consumer. ```typescript import { Component } from '@angular/core'; import { LucideX } from '@lucide/angular'; import { drawerImports } from '@radix-ng/primitives/drawer'; import { cn, demoButton, demoDrawer } from '../../storybook/styles'; @Component({ selector: 'rdx-drawer-default', imports: [...drawerImports, LucideX], template: `

Drag me down

Swipe the sheet downwards or press Escape to dismiss it. Releasing before the halfway point snaps it back.

The grab handle above is purely visual — the whole panel is draggable. Scrollable regions yield to scrolling until they reach their edge.

` }) export class RdxDrawerDefaultComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDrawer; } ``` ## Features - ✅ Reuses the Dialog primitive: trigger, portal, presence, backdrop, viewport, title, description, close. - ✅ Modal by default, but `[modal]` (`true | false | 'trap-focus'`) and `disablePointerDismissal` stay user-overridable. - ✅ Swipe-to-dismiss in any direction via `[swipeDirection]` (`'up' | 'down' | 'left' | 'right'`). - ✅ Headless gesture contract: `--drawer-swipe-movement-x/y`, `--drawer-swipe-strength`, `[data-swiping]`, `[data-swipe-direction]`, `[data-swipe-dismiss]`. - ✅ Rubber-band resistance when dragging against the dismiss direction; velocity- or distance-based release. - ✅ Yields to scrollable regions inside `rdxDrawerContent` until they reach their edge; opt out with `data-base-ui-swipe-ignore`. - ✅ Swipe-to-open from an off-canvas `rdxDrawerSwipeArea` strip. - ✅ Snap points (`[snapPoints]`, `[(snapPoint)]`, `[defaultSnapPoint]`, `[snapToSequentialPoints]`) with velocity skipping and `data-expanded`. - ✅ Nested drawers (detected through the dialog hierarchy): the parent gains `data-nested-drawer-open` / `--nested-drawers`. - ✅ Optional `rdxDrawerProvider` + `rdxDrawerIndent` / `rdxDrawerIndentBackground` for an app-wide page-scale effect. - ✅ Exposes swipe progress on the backdrop with `--drawer-swipe-progress` for a gesture-linked fade. - ✅ Supports two-way `[(open)]`, `defaultOpen`, multiple triggers, controlled `triggerId`, and detached triggers via a shared handle. - ✅ Closes on Escape, swipe, outside pointer interaction, or an explicit close button, and reports the reason on `onOpenChange`. ## Import ```typescript import { createRdxDrawerHandle, provideRdxDrawerProvider, RdxDrawerBackdrop, RdxDrawerClose, RdxDrawerContent, RdxDrawerDescription, RdxDrawerIndent, RdxDrawerIndentBackground, RdxDrawerPopup, RdxDrawerPortal, RdxDrawerPortalPresence, RdxDrawerProviderDirective, RdxDrawerRoot, RdxDrawerSwipeArea, RdxDrawerTitle, RdxDrawerTrigger, RdxDrawerViewport } from '@radix-ng/primitives/drawer'; ``` Or import all parts through the module: ```typescript import { RdxDrawerModule } from '@radix-ng/primitives/drawer'; ``` The `drawerImports` array re-exports every part for standalone `imports`. ## Anatomy Apply the parts to your own markup. `rdxDrawerPortalPresence` manages mounting and waits for exit keyframes on the first DOM element inside its template — the `rdxDrawerPortal`. That element **must** have a `data-[state=closed]` exit animation, otherwise presence sees no animation on the root node and unmounts the drawer instantly, skipping the slide-out. Give the portal an overlay fade (it also dims the backdrop) sized to at least the popup's slide duration: ```css [rdxDrawerPortal][data-state='open'] { animation: overlay-in 250ms ease-out; } [rdxDrawerPortal][data-state='closed'] { animation: overlay-out 200ms ease-in forwards; } ``` The popup's resting transform should read the swipe variables so the gesture and snap-back are visible, and its slide-out keyframe should hold the closed position with `forwards`: ```css [rdxDrawerPopup] { transform: translate3d(var(--drawer-swipe-movement-x, 0), var(--drawer-swipe-movement-y, 0), 0); transition: transform 0.3s ease; } [rdxDrawerPopup][data-swiping] { transition: none; } [rdxDrawerPopup][data-state='closed'] { animation: slide-out-bottom 200ms ease-in forwards; } ``` ```html

Title

Description

…scrollable body…
``` ## Examples ### Default A bottom sheet you can swipe down to dismiss, with an accessible title, description, and close buttons. ```typescript import { Component } from '@angular/core'; import { LucideX } from '@lucide/angular'; import { drawerImports } from '@radix-ng/primitives/drawer'; import { cn, demoButton, demoDrawer } from '../../storybook/styles'; @Component({ selector: 'rdx-drawer-default', imports: [...drawerImports, LucideX], template: `

Drag me down

Swipe the sheet downwards or press Escape to dismiss it. Releasing before the halfway point snaps it back.

The grab handle above is purely visual — the whole panel is draggable. Scrollable regions yield to scrolling until they reach their edge.

` }) export class RdxDrawerDefaultComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDrawer; } ``` ### State Own the open state with `[(open)]` and drive it from anywhere — buttons outside the drawer open and close it alongside the trigger. ```typescript import { Component, signal } from '@angular/core'; import { drawerImports } from '@radix-ng/primitives/drawer'; import { cn, demoButton, demoDrawer } from '../../storybook/styles'; @Component({ selector: 'rdx-drawer-controlled', imports: [...drawerImports], template: `

Drawer is {{ open() ? 'open' : 'closed' }}

Controlled drawer

The open state is owned by the component and bound with [(open)] .

` }) export class RdxDrawerControlledComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDrawer; protected readonly open = signal(false); } ``` ### Position Set `[swipeDirection]` and position the popup with CSS to anchor the drawer to any edge. The direction controls the dismiss gesture; the visual side is consumer CSS. ```typescript import { Component } from '@angular/core'; import { drawerImports, RdxDrawerSwipeDirection } from '@radix-ng/primitives/drawer'; import { cn, demoButton, demoDrawer } from '../../storybook/styles'; const SIDES: RdxDrawerSwipeDirection[] = ['top', 'right', 'bottom', 'left']; @Component({ selector: 'rdx-drawer-sides', imports: [...drawerImports], template: `
@for (side of sides; track side) {

{{ side }} drawer

Swipe toward the {{ side }} edge to dismiss.

}
` }) export class RdxDrawerSidesComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDrawer; protected readonly sides = SIDES; } ``` ### Snap points Pass `[snapPoints]` (fractions `0–1`, pixel numbers, or strings like `'160px'`, `'30rem'`, `'40%'`, ordered ascending by openness) to let the drawer rest at intermediate heights. Bind `[(snapPoint)]` to read or drive the active point; a fast flick skips points and dragging past the lowest one dismisses. The popup gains `data-expanded` at the most open point and exposes `--drawer-snap-point-offset` / `--drawer-height`. Add `[snapToSequentialPoints]` to step one point per release instead of skipping. ```typescript import { Component, signal } from '@angular/core'; import { drawerImports, RdxDrawerSnapPoint } from '@radix-ng/primitives/drawer'; import { cn, demoButton, demoDrawer } from '../../storybook/styles'; @Component({ selector: 'rdx-drawer-snap-points', imports: [...drawerImports], template: `
Active snap point: {{ snap() }}

Snap points

Drag the sheet between {{ snapPoints.length }} resting positions. A fast flick skips points; dragging past the lowest one dismisses it.

The active snap point is two-way bound with [(snapPoint)] , so app state and the gesture stay in sync.

` }) export class RdxDrawerSnapPointsComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDrawer; protected readonly snapPoints: RdxDrawerSnapPoint[] = ['160px', 0.5, 1]; protected readonly snap = signal(null); } ``` ### Swipe to open An off-canvas `rdxDrawerSwipeArea` strip opens the drawer when swiped inward. ```typescript import { Component } from '@angular/core'; import { drawerImports } from '@radix-ng/primitives/drawer'; import { cn, demoButton, demoDrawer } from '../../storybook/styles'; @Component({ selector: 'rdx-drawer-swipe-to-open', imports: [...drawerImports], template: `

Swipe up from the highlighted strip at the bottom to open the drawer (or use the button).

Opened by swipe

Swipe back down to dismiss.

` }) export class RdxDrawerSwipeToOpenComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDrawer; } ``` ### Mobile navigation A taller sheet whose body scrolls. The swipe gesture yields to scrolling inside `rdxDrawerContent` until the scroll reaches its edge, so the drawer only swipes away from the top of the list. ```typescript import { Component } from '@angular/core'; import { drawerImports } from '@radix-ng/primitives/drawer'; import { cn, demoButton, demoDrawer } from '../../storybook/styles'; const LINKS = [ 'Home', 'Discover', 'Library', 'Downloads', 'Playlists', 'Artists', 'Albums', 'Podcasts', 'Settings', 'Account', 'Help & feedback', 'About' ]; @Component({ selector: 'rdx-drawer-scrollable', imports: [...drawerImports], template: `

Navigation

Scroll the list; the drawer only swipes away once the list is at the top.

` }) export class RdxDrawerScrollableComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDrawer; protected readonly links = LINKS; } ``` ### Non-modal Set `[modal]="false"` to keep page scrolling and outside pointer interactions available while the drawer is open; there is no backdrop in this mode. ```typescript import { Component, signal } from '@angular/core'; import { drawerImports } from '@radix-ng/primitives/drawer'; import { cn, demoButton, demoDrawer } from '../../storybook/styles'; @Component({ selector: 'rdx-drawer-non-modal', imports: [...drawerImports], template: `

Non-modal: page scrolling and outside pointer interactions stay enabled while the drawer is open, and there is no backdrop.

Non-modal drawer

Keep interacting with the rest of the page; the counter below still works.

` }) export class RdxDrawerNonModalComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDrawer; protected readonly outsideClicks = signal(0); } ``` ### Action sheet with separate destructive action An iOS-style action sheet: grouped actions with a separated destructive action and a cancel button, each closing the drawer. ```typescript import { Component } from '@angular/core'; import { drawerImports } from '@radix-ng/primitives/drawer'; import { cn, demoButton, demoDrawer } from '../../storybook/styles'; /** A full-width action-sheet row. */ const action = cn( 'block w-full px-4 py-3 text-center text-sm text-foreground', 'hover:bg-muted focus-visible:bg-muted focus-visible:outline-none' ); @Component({ selector: 'rdx-drawer-action-sheet', imports: [...drawerImports], template: `

Photo options

Choose an action for this photo

` }) export class RdxDrawerActionSheetComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDrawer; protected readonly action = action; } ``` ### Nested drawers Drawers stack on top of each other — each level's content hosts the trigger for the next. Nesting is detected through the dialog hierarchy, so every parent gains `data-nested-drawer-open` (and `--nested-drawers`) and recedes behind the one in front. ```typescript import { Component, input } from '@angular/core'; import { drawerImports } from '@radix-ng/primitives/drawer'; import { cn, demoButton, demoDrawer } from '../../storybook/styles'; /** * A self-recursive drawer: each level's content hosts the trigger for the next level, so drawers * stack on top of each other. Nesting is detected through the dialog hierarchy, so every parent * gains `data-nested-drawer-open` and recedes behind the one in front (see `demoDrawer.popup`). */ @Component({ selector: 'rdx-drawer-nested', imports: [...drawerImports], template: `

Drawer level {{ level() }}

@if (level() < max()) { Open another to stack it on top — this one scales back and peeks behind it. } @else { Deepest level. Swipe down or press Escape to peel the stack back one at a time. }

@if (level() < max()) { }
` }) export class RdxDrawerNestedComponent { /** Current depth (1 = the root drawer). */ readonly level = input(1); /** How many levels can be stacked. */ readonly max = input(4); protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDrawer; } ``` ### Indent effect Wrap content in `rdxDrawerProvider` (or call `provideRdxDrawerProvider()` at the app root) and mark a region with `rdxDrawerIndentBackground` / `rdxDrawerIndent`. It gains `[data-active]`, `--nested-drawers`, and `--drawer-frontmost-height` while any drawer is open, for an iOS-style page-scale effect. ```typescript import { Component } from '@angular/core'; import { drawerImports } from '@radix-ng/primitives/drawer'; import { cn, demoButton, demoDrawer } from '../../storybook/styles'; @Component({ selector: 'rdx-drawer-page-scale', imports: [...drawerImports], template: `

Page content

This panel scales back while the drawer is open, like an iOS sheet pushing the page away.

Sheet

Close me to restore the page.

` }) export class RdxDrawerPageScaleComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDrawer; } ``` ### Detached triggers Create a shared handle with `createRdxDrawerHandle()` when triggers live outside `rdxDrawerRoot`. The handle also supports imperative `open(id)`, `toggle(id)`, and `close()`. ```typescript import { Component } from '@angular/core'; import { createRdxDrawerHandle, drawerImports } from '@radix-ng/primitives/drawer'; import { cn, demoButton, demoDrawer } from '../../storybook/styles'; @Component({ selector: 'rdx-drawer-detached', imports: [...drawerImports], template: `

Detached triggers

The triggers and this drawer are connected with createRdxDrawerHandle() rather than DOM nesting.

` }) export class RdxDrawerDetachedComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly d = demoDrawer; protected readonly handle = createRdxDrawerHandle(); } ``` ## API Reference ### Root `RdxDrawerRoot` composes `RdxDialogRoot` (proxying `[open]`, `[defaultOpen]`, `[modal]`, `[disablePointerDismissal]`, `[handle]`, `[triggerId]`) and adds `[swipeDirection]`. Open-state outputs `onOpenChange` / `onOpenChangeComplete` come from the dialog. ### Popup `RdxDrawerPopup` composes the dialog popup (focus trap, dismissal, scroll lock, a11y) and owns the swipe gesture. It exposes the dialog popup's dismissal and focus events and reads everything else from context. ### SwipeArea `RdxDrawerSwipeArea` opens the drawer when swiped inward from a screen edge. ### Provider, Indent, and IndentBackground `rdxDrawerProvider` hosts the optional app-level coordinator (also available as `provideRdxDrawerProvider()`). `rdxDrawerIndent` and `rdxDrawerIndentBackground` read it and expose `[data-active]`, `--nested-drawers`, and `--drawer-frontmost-height`; they take no inputs. ### Trigger, Portal, Viewport, Backdrop, Content, Title, Description, and Close These parts wrap their Dialog counterparts (or, for `Content`, mark the scrollable body) and read state from context. See the Dialog docs for the trigger and portal inputs. --- # Field #### Groups a control with accessible label, description, error message, and field state. Field is form-agnostic. It does not replace Angular Forms; pass validation state from Reactive Forms, template-driven forms, or Signal Forms when using them. ```typescript import { Component } from '@angular/core'; import { RdxFieldControl } from '../src/field-control'; import { RdxFieldDescription } from '../src/field-description'; import { RdxFieldError } from '../src/field-error'; import { RdxFieldLabel } from '../src/field-label'; import { RdxFieldRoot } from '../src/field-root'; import { fieldDescription, fieldError, fieldLabel } from './field.shared'; @Component({ selector: 'field-default-example', imports: [RdxFieldRoot, RdxFieldLabel, RdxFieldControl, RdxFieldDescription, RdxFieldError], template: `

Used for account notifications.

Enter a valid email address.

` }) export class FieldDefaultExample { protected readonly inputClass = 'border-border bg-background text-foreground placeholder:text-muted-foreground h-9 w-full rounded-md border px-3 text-sm outline-none'; protected readonly labelClass = fieldLabel; protected readonly descriptionClass = fieldDescription; protected readonly errorClass = fieldError; } ``` ## Features - ✅ Wires labels to controls with `for` and generated control ids. - ✅ Wires descriptions and errors with `aria-describedby`. - ✅ Exposes state via `data-invalid`, `data-disabled`, `data-required`, `data-dirty`, `data-touched`, `data-filled`, and `data-focused`. - ✅ Works with native controls and custom controls. - ✅ Leaves validation and form submission to Angular Forms. ## Import ```typescript import { RdxFieldRoot, RdxFieldLabel, RdxFieldControl, RdxFieldDescription, RdxFieldError } from '@radix-ng/primitives/field'; ``` ## Anatomy ```html

Description

Error

``` ## Examples ### Default The root owns the relationships between the field parts. ```typescript import { Component } from '@angular/core'; import { RdxFieldControl } from '../src/field-control'; import { RdxFieldDescription } from '../src/field-description'; import { RdxFieldError } from '../src/field-error'; import { RdxFieldLabel } from '../src/field-label'; import { RdxFieldRoot } from '../src/field-root'; import { fieldDescription, fieldError, fieldLabel } from './field.shared'; @Component({ selector: 'field-default-example', imports: [RdxFieldRoot, RdxFieldLabel, RdxFieldControl, RdxFieldDescription, RdxFieldError], template: `

Used for account notifications.

Enter a valid email address.

` }) export class FieldDefaultExample { protected readonly inputClass = 'border-border bg-background text-foreground placeholder:text-muted-foreground h-9 w-full rounded-md border px-3 text-sm outline-none'; protected readonly labelClass = fieldLabel; protected readonly descriptionClass = fieldDescription; protected readonly errorClass = fieldError; } ``` ### Invalid Pass invalid, dirty, and touched state from your form model. ```typescript import { Component } from '@angular/core'; import { RdxFieldControl } from '../src/field-control'; import { RdxFieldDescription } from '../src/field-description'; import { RdxFieldError } from '../src/field-error'; import { RdxFieldLabel } from '../src/field-label'; import { RdxFieldRoot } from '../src/field-root'; import { fieldDescription, fieldError, fieldInputInvalid, fieldLabel } from './field.shared'; @Component({ selector: 'field-invalid-example', imports: [RdxFieldRoot, RdxFieldLabel, RdxFieldControl, RdxFieldDescription, RdxFieldError], template: `

Use lowercase letters and hyphens.

Workspace name is required.

` }) export class FieldInvalidExample { protected readonly inputClass = fieldInputInvalid; protected readonly labelClass = fieldLabel; protected readonly descriptionClass = fieldDescription; protected readonly errorClass = fieldError; } ``` ### Reactive Forms Angular Forms remains responsible for validation. Field reflects the resulting state and ARIA relationships. ```typescript import { Component, inject, signal } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { RdxFieldControl } from '../src/field-control'; import { RdxFieldDescription } from '../src/field-description'; import { RdxFieldError } from '../src/field-error'; import { RdxFieldLabel } from '../src/field-label'; import { RdxFieldRoot } from '../src/field-root'; import { fieldDescription, fieldError, fieldInputInvalid, fieldLabel, fieldSubmitButton } from './field.shared'; @Component({ selector: 'field-reactive-forms-example', imports: [ReactiveFormsModule, RdxFieldRoot, RdxFieldLabel, RdxFieldControl, RdxFieldDescription, RdxFieldError], template: `

Use the email connected to your account.

@if (email.hasError('required')) { Email is required. } @else { Enter a valid email address. }

` }) export class FieldReactiveFormsExample { protected readonly inputClass = fieldInputInvalid; protected readonly buttonClass = fieldSubmitButton; protected readonly labelClass = fieldLabel; protected readonly descriptionClass = fieldDescription; protected readonly errorClass = fieldError; private readonly formBuilder = inject(FormBuilder); readonly submitted = signal(false); readonly form = this.formBuilder.group({ email: this.formBuilder.control('', [Validators.required, Validators.email]) }); get email() { return this.form.controls.email; } submit(): void { this.submitted.set(true); this.form.markAllAsTouched(); } } ``` ### Custom Control For custom controls, pass `filled` and `focused` state to the root when the native events are not enough. ```typescript import { Component, computed, signal } from '@angular/core'; import { RdxFieldControl } from '../src/field-control'; import { RdxFieldDescription } from '../src/field-description'; import { RdxFieldError } from '../src/field-error'; import { RdxFieldLabel } from '../src/field-label'; import { RdxFieldRoot } from '../src/field-root'; import { fieldCustomTrigger, fieldDescription, fieldError, fieldLabel } from './field.shared'; @Component({ selector: 'field-custom-control-example', imports: [RdxFieldRoot, RdxFieldLabel, RdxFieldControl, RdxFieldDescription, RdxFieldError], template: `

Custom controls can pass state into the field root.

Choose a plan.

` }) export class FieldCustomControlExample { readonly open = signal(false); readonly selected = computed(() => this.open()); protected readonly triggerClass = fieldCustomTrigger; protected readonly labelClass = fieldLabel; protected readonly descriptionClass = fieldDescription; protected readonly errorClass = fieldError; } ``` ## API Reference --- # Fieldset #### Groups related form controls with a legend and shared disabled state. ```html
Shipping address

Used to calculate delivery options.

Street address is required.

``` ## Features - ✅ Uses native `fieldset` and `legend` semantics. - ✅ Disables all form controls in the group with one prop. - ✅ Exposes disabled state with `data-disabled`. - ✅ Composes with Field and Input. ## Import ```typescript import { RdxFieldsetRoot, RdxFieldsetLegend } from '@radix-ng/primitives/fieldset'; ``` ## Anatomy ```html
Shipping address
``` ## Examples ### Default A fieldset groups related Field and Input primitives under one native legend. ```html
Shipping address

Used to calculate delivery options.

Street address is required.

``` ### Disabled Disabled state is applied to the native fieldset and exposed to the legend for styling. ```html
Billing address
``` ### Signup form A larger form groups account details and submits values from fields inside the fieldset. ```typescript import { Component, inject } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { RdxFieldDescription, RdxFieldError, RdxFieldLabel, RdxFieldRoot } from '@radix-ng/primitives/field'; import { RdxFieldsetLegend, RdxFieldsetRoot } from '@radix-ng/primitives/fieldset'; import { RdxInputDirective } from '@radix-ng/primitives/input'; import { cn, demoButton, demoInput } from '../../storybook/styles'; @Component({ selector: 'fieldset-signup-form-example', imports: [ ReactiveFormsModule, RdxFieldsetRoot, RdxFieldsetLegend, RdxFieldRoot, RdxFieldLabel, RdxFieldDescription, RdxFieldError, RdxInputDirective ], template: `
Account details

Used in your workspace profile.

First name is required.

We'll send the invite confirmation here.

Enter a valid email address.

Receive occasional release notes and migration tips.

@if (submittedEmail) {

Submitted {{ submittedEmail }}

}
` }) export class FieldsetSignupFormExample { private readonly formBuilder = inject(FormBuilder); protected readonly cn = cn; protected readonly button = demoButton; protected readonly inputClass = demoInput; protected submitting = false; protected submittedEmail = ''; protected readonly form = this.formBuilder.nonNullable.group({ firstName: ['', Validators.required], email: ['', [Validators.required, Validators.email]], updates: [true] }); protected isInvalid(controlName: 'firstName' | 'email'): boolean { const control = this.form.controls[controlName]; return control.invalid && (control.touched || control.dirty); } protected submit(): void { if (this.form.invalid) { this.form.markAllAsTouched(); return; } this.submitting = true; this.submittedEmail = this.form.controls.email.value; } } ``` ## API Reference ### Root `RdxFieldsetRoot` ### Legend `RdxFieldsetLegend` Reads disabled state from the root context and exposes it via `data-disabled`. ## Accessibility Use Fieldset when a label applies to a group of related controls. The native `legend` gives the group an accessible name, and the native `disabled` attribute disables descendant form controls. --- # Input #### A native text input with headless state attributes and Field integration. ```html ``` ## Features - ✅ Works as a standalone native input. - ✅ Integrates with Field labels, descriptions, errors, and state. - ✅ Supports controlled value, default value, disabled, required, and invalid states. - ✅ Exposes state through data attributes for styling. ## Import ```ts import { RdxInputDirective } from '@radix-ng/primitives/input'; ``` ## Anatomy Use `rdxInput` directly on a native input. Wrap it in Field when you need label, description, and error relationships. ```html

Used for account notifications.

Enter a valid email address.

``` ## Examples ### Disabled Disables the native input and exposes `data-disabled`. ```html ``` ### With Field Connects the input to Field label, description, and validation state. ```typescript import { Component } from '@angular/core'; import { RdxFieldDescription, RdxFieldError, RdxFieldLabel, RdxFieldRoot } from '@radix-ng/primitives/field'; import { demoInput } from '../../storybook/styles'; import { RdxInputDirective } from '../src/input.directive'; @Component({ selector: 'input-field-example', imports: [RdxFieldRoot, RdxFieldLabel, RdxFieldDescription, RdxFieldError, RdxInputDirective], template: `

Used for account notifications.

Enter a valid email address.

` }) export class InputFieldExample { protected readonly inputClass = demoInput; } ``` ### Reactive Forms Uses Angular reactive forms while Field reflects validation state. ```typescript import { Component } from '@angular/core'; import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; import { RdxFieldDescription, RdxFieldError, RdxFieldLabel, RdxFieldRoot } from '@radix-ng/primitives/field'; import { demoInput } from '../../storybook/styles'; import { RdxInputDirective } from '../src/input.directive'; @Component({ selector: 'input-reactive-forms-example', imports: [ReactiveFormsModule, RdxFieldRoot, RdxFieldLabel, RdxFieldDescription, RdxFieldError, RdxInputDirective], template: `

Use the email connected to your account.

Email must be valid.

` }) export class InputReactiveFormsExample { readonly email = new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] }); protected readonly inputClass = demoInput; } ``` ### Signup Form Combines multiple inputs, Field state, a checkbox, and a submit button in a larger form. ```typescript import { Component } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { LucideCheck } from '@lucide/angular'; import { RdxButtonDirective } from '@radix-ng/primitives/button'; import { RdxCheckboxButtonDirective, RdxCheckboxIndicatorDirective, RdxCheckboxInputDirective, RdxCheckboxRootDirective } from '@radix-ng/primitives/checkbox'; import { RdxFieldDescription, RdxFieldError, RdxFieldLabel, RdxFieldRoot } from '@radix-ng/primitives/field'; import { cn, demoButton, demoCheckbox, demoInput } from '../../storybook/styles'; import { RdxInputDirective } from '../src/input.directive'; @Component({ selector: 'input-signup-form-example', imports: [ ReactiveFormsModule, LucideCheck, RdxButtonDirective, RdxCheckboxRootDirective, RdxCheckboxButtonDirective, RdxCheckboxIndicatorDirective, RdxCheckboxInputDirective, RdxFieldRoot, RdxFieldLabel, RdxFieldDescription, RdxFieldError, RdxInputDirective ], template: `

Enter your first name.

Enter your last name.

Used for product updates and account recovery.

Enter a valid email address.

Use at least 8 characters.

Password must be at least 8 characters.

` }) export class InputSignupFormExample { readonly form = new FormGroup({ firstName: new FormControl('', { nonNullable: true, validators: [Validators.required] }), lastName: new FormControl('', { nonNullable: true, validators: [Validators.required] }), email: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.email] }), password: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.minLength(8)] }), terms: new FormControl(false, { nonNullable: true, validators: [Validators.requiredTrue] }) }); protected readonly inputClass = demoInput; protected readonly checkbox = demoCheckbox; protected readonly submitClass = cn(demoButton.base, demoButton.primary, demoButton.size.md, 'w-full'); protected get firstName() { return this.form.controls.firstName; } protected get lastName() { return this.form.controls.lastName; } protected get email() { return this.form.controls.email; } protected get password() { return this.form.controls.password; } submit(): void { this.form.markAllAsTouched(); } } ``` ## API Reference --- # Label #### Renders an accessible label associated with controls. ```html
``` ## Features - ✅ Text selection is prevented when double-clicking label. ## Import Get started with importing the directive: ```typescript import { RdxLabelDirective } from '@radix-ng/primitives/label'; ``` ## Examples ```html ``` ## API Reference ## Accessibility This component is based on the native `label` element, it will automatically apply the correct labelling when wrapping controls or using the `for` attribute. For your own custom controls to work correctly, ensure they use native elements such as `button` or `input` as a base. --- # Menu #### A headless dropdown menu anchored to a trigger button. Menu composes the shared Popper, Dismissable Layer, and Focus Scope primitives. It remains fully headless — state is exposed through `data-*` attributes and the consumer provides all visual styles. ```typescript import { ChangeDetectionStrategy, Component } from '@angular/core'; import { RdxMenuModule } from '@radix-ng/primitives/menu'; import { cn, demoButton, demoMenu } from '../../storybook/styles'; @Component({ selector: 'rdx-menu-default', imports: [RdxMenuModule], changeDetection: ChangeDetectionStrategy.OnPush, template: ` @if (root.open()) {
}
` }) export class RdxMenuDefaultComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly m = demoMenu; } ``` ## Features - ✅ Opens and closes from a trigger button with click or arrow-key interaction. - ✅ Supports uncontrolled state, `defaultOpen`, and two-way binding with `[(open)]`. - ✅ Positions the popup with the shared Floating UI-based Popper primitive. - ✅ Optional visual arrow connecting the popup to its trigger (`rdxMenuArrow`). - ✅ Optional backdrop overlay behind the popup (`rdxMenuBackdrop`). - ✅ Closes on Escape (restoring focus to the trigger), outside pointer interaction, and Tab. - ✅ Full keyboard navigation: ArrowDown, ArrowUp, Home, End, and character typeahead. - ✅ Focus loop at list boundaries, configurable with `loopFocus`. - ✅ Skips disabled items during keyboard navigation. - ✅ `closeOnClick` per item — defaults to `true` for regular items, `false` for checkbox, radio, and link items. - ✅ Checkbox items toggle state independently; radio groups enforce single selection. - ✅ Grouped items with optional group labels and visual separators. - ✅ Nested submenus via `rdxMenuSubTrigger` — opens on hover (200 ms delay), click, or ArrowRight; closes on ArrowLeft. - ✅ CSS transition lifecycle via `data-starting-style` / `data-ending-style` and `(onOpenChangeComplete)`. - ✅ All collision, side, and alignment metadata exposed via `data-side` / `data-align`. - ✅ `data-highlighted` on the focused item and `data-disabled` on disabled items. ## Import ```typescript import { RdxMenuArrow, RdxMenuBackdrop, RdxMenuCheckboxItem, RdxMenuCheckboxItemIndicator, RdxMenuGroup, RdxMenuGroupLabel, RdxMenuItem, RdxMenuLinkItem, RdxMenuPopup, RdxMenuPortal, RdxMenuPositioner, RdxMenuRadioGroup, RdxMenuRadioItem, RdxMenuRadioItemIndicator, RdxMenuRoot, RdxMenuSeparator, RdxMenuSubTrigger, RdxMenuTrigger } from '@radix-ng/primitives/menu'; ``` Or import all parts through the module: ```typescript import { RdxMenuModule } from '@radix-ng/primitives/menu'; ``` ## Anatomy Apply the directives to your own markup. Use `@if (root.open())` to conditionally mount the popup so it is removed from the DOM when closed. ```html @if (root.open()) {
} @if (root.open()) {
Settings
Section
@if (sub.open()) {
}
}
``` ## Examples ### Default A basic dropdown with regular items, a disabled item, and a separator. ```typescript import { ChangeDetectionStrategy, Component } from '@angular/core'; import { RdxMenuModule } from '@radix-ng/primitives/menu'; import { cn, demoButton, demoMenu } from '../../storybook/styles'; @Component({ selector: 'rdx-menu-default', imports: [RdxMenuModule], changeDetection: ChangeDetectionStrategy.OnPush, template: ` @if (root.open()) {
}
` }) export class RdxMenuDefaultComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly m = demoMenu; } ``` ### Radio items A radio group selects exactly one option. Selecting an item keeps the menu open (`closeOnClick` defaults to `false` for radio items). Bind `[(value)]` on `rdxMenuRadioGroup` for controlled state. ```html ``` ### Checkbox items Checkbox items toggle their state without closing the menu (`closeOnClick` defaults to `false`). An indeterminate state is supported when only some items in a related set are selected. ```html ``` ### With labels Group items visually and semantically with `rdxMenuGroup` and label them with `rdxMenuGroupLabel`. Disabled items are skipped by keyboard navigation and styled via `data-disabled`. ```html ``` ### Nested submenus Wrap a `rdxMenuSubTrigger` and its popup inside a nested `ng-container rdxMenuRoot`. The subtrigger opens the submenu on hover (200 ms delay), click, or ArrowRight; ArrowLeft closes it and returns focus to the subtrigger. ```typescript import { ChangeDetectionStrategy, Component } from '@angular/core'; import { RdxMenuModule } from '@radix-ng/primitives/menu'; import { cn, demoButton, demoMenu } from '../../storybook/styles'; @Component({ selector: 'rdx-menu-nested', imports: [RdxMenuModule], changeDetection: ChangeDetectionStrategy.OnPush, template: ` @if (root.open()) {
@if (findSub.open()) {
}
@if (spellSub.open()) {
}
}
` }) export class RdxMenuNestedComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly m = demoMenu; } ``` ### Arrow Add `rdxMenuArrow` inside the popup to render a visual pointer connecting the popup to its trigger. The arrow SVG fills with `currentColor`, so match the popup surface with a `text-*` token (e.g. `text-popover`) rather than a `fill-*` class. A directional `drop-shadow` in the border color lets the popup border flow into the arrow as one continuous outline. ```typescript import { ChangeDetectionStrategy, Component } from '@angular/core'; import { RdxMenuModule } from '@radix-ng/primitives/menu'; import { cn, demoButton, demoMenu } from '../../storybook/styles'; @Component({ selector: 'rdx-menu-arrow', imports: [RdxMenuModule], changeDetection: ChangeDetectionStrategy.OnPush, template: ` @if (root.open()) {
}
` }) export class RdxMenuArrowExampleComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly m = demoMenu; } ``` ### Backdrop Add `rdxMenuBackdrop` before the positioner (both inside `@if (root.open())`) to render an overlay behind the popup. Set `[modal]="true"` on `rdxMenuRoot` to block outside pointer events. ```typescript import { ChangeDetectionStrategy, Component } from '@angular/core'; import { RdxMenuModule } from '@radix-ng/primitives/menu'; import { cn, demoButton, demoMenu } from '../../storybook/styles'; @Component({ selector: 'rdx-menu-backdrop', imports: [RdxMenuModule], changeDetection: ChangeDetectionStrategy.OnPush, template: ` @if (root.open()) {
}
` }) export class RdxMenuBackdropExampleComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly m = demoMenu; } ``` ### Viewport (animated resize) Wrap the popup content in `rdxMenuViewport` to smoothly animate the popup size when the content changes — for example revealing an advanced section, or switching between menubar menus of different sizes. The viewport measures content and exposes `--popup-width` / `--popup-height`; bind them on the popup with a CSS transition (here via Tailwind arbitrary utilities, so no story-local CSS): ```html
…items…
``` ```typescript import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; import { RdxMenuModule } from '@radix-ng/primitives/menu'; import { cn, demoButton, demoMenu } from '../../storybook/styles'; /** * Demonstrates `rdxMenuViewport`: the popup smoothly resizes as its content * changes size. The viewport measures the content and exposes `--popup-width` / * `--popup-height`; the popup binds them with a CSS transition (expressed here * with Tailwind arbitrary utilities, so no story-local CSS is needed). */ @Component({ selector: 'rdx-menu-viewport', imports: [RdxMenuModule], changeDetection: ChangeDetectionStrategy.OnPush, template: ` @if (root.open()) {
@if (expanded()) {
}
}
` }) export class RdxMenuViewportExampleComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly m = demoMenu; readonly expanded = signal(false); toggle(): void { this.expanded.update((v) => !v); } } ``` ### CSS animations `rdxMenuPopup` exposes `data-starting-style` on the enter frame and `data-ending-style` while the exit animation plays. `(onOpenChangeComplete)` fires after the animation finishes. Use Angular `styles` on the component (or global CSS) to define the keyframes. ```typescript import { ChangeDetectionStrategy, Component } from '@angular/core'; import { RdxMenuModule } from '@radix-ng/primitives/menu'; import { cn, demoButton, demoMenu } from '../../storybook/styles'; @Component({ selector: 'rdx-menu-animated', imports: [RdxMenuModule], changeDetection: ChangeDetectionStrategy.OnPush, styles: [ ` @keyframes popup-in { from { opacity: 0; transform: scale(0.95) translateY(-4px); } to { opacity: 1; transform: scale(1) translateY(0); } } @keyframes popup-out { from { opacity: 1; transform: scale(1) translateY(0); } to { opacity: 0; transform: scale(0.95) translateY(-4px); } } .animated-popup { animation: popup-in 150ms ease; } .animated-popup[data-ending-style] { animation: popup-out 150ms ease; } ` ], template: ` @if (root.open()) {
}
` }) export class RdxMenuAnimatedComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly m = demoMenu; } ``` #### Animation recipe ```css @keyframes popup-in { from { opacity: 0; transform: scale(0.95) translateY(-4px); } to { opacity: 1; transform: scale(1) translateY(0); } } @keyframes popup-out { from { opacity: 1; transform: scale(1) translateY(0); } to { opacity: 0; transform: scale(0.95) translateY(-4px); } } [rdxMenuPopup] { animation: popup-in 150ms ease; transform-origin: var(--transform-origin); } [rdxMenuPopup][data-ending-style] { animation: popup-out 150ms ease; } ``` The optional backdrop uses the same attributes: ```css [rdxMenuBackdrop] { position: fixed; inset: 0; background: rgb(0 0 0 / 0.3); animation: fade-in 150ms ease; } [rdxMenuBackdrop][data-ending-style] { animation: fade-out 150ms ease; } ``` > **Note:** exit animations require the popup to remain mounted during the animation. With the > `@if (root.open())` pattern the popup is removed immediately on close. To keep it mounted, render > the positioner/popup unconditionally and toggle visibility via CSS `data-state` instead, or use > a presence wrapper that waits for `animationend` before unmounting. #### `onOpenChangeComplete` Bind `(onOpenChangeComplete)` on `rdxMenuRoot` to run logic after the popup has fully appeared or disappeared: ```html ``` The event carries `true` when the open animation finishes and `false` when the close animation finishes. ## API Reference ### RdxMenuRoot ### RdxMenuTrigger The button that opens the menu. Auto-detects a native `
@if (shareSub.open()) {
}
` }) export class RdxMenubarDefaultComponent { protected readonly cn = cn; protected readonly mb = demoMenubar; protected readonly m = demoMenu; showBookmarks = signal(true); showFullUrls = signal(false); activeProfile = signal('andy'); } ``` ## Features - ✅ Horizontal row of menus with a single open menu at a time. - ✅ Open a menu by click or keyboard; once one is open, hovering a sibling trigger switches to it. - ✅ ArrowLeft / ArrowRight move between triggers; ArrowDown / ArrowUp open the focused menu. - ✅ Each menu reuses the full Menu primitive — items, checkbox/radio, submenus, separators. - ✅ Escape closes the open menu and returns focus to its trigger. - ✅ Headless — state is exposed via `data-state` and menu item state attributes; styling is up to the consumer. ## Import ```typescript import { RdxMenubarRoot } from '@radix-ng/primitives/menubar'; import { RdxMenuModule } from '@radix-ng/primitives/menu'; ``` Or import the menubar parts through the module: ```typescript import { RdxMenubarModule } from '@radix-ng/primitives/menubar'; ``` ## Anatomy The menubar is a `rdxMenubarRoot` containing one `ng-container rdxMenuRoot` per menu. Inside each, a standard `rdxMenuTrigger` opens a Menu popup. Keep the top-level popup mounted and hide it with `data-closed` styles. ```html
@if (shareSub.open()) {
}
``` ## Examples ### Default A four-menu bar (File, Edit, View, Profiles) with a submenu, checkbox items, and a radio group. Click a trigger to open it, then hover the other triggers to switch, or use the arrow keys. ```typescript import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; import { LucideCheck, LucideDot } from '@lucide/angular'; import { RdxMenuModule } from '@radix-ng/primitives/menu'; import { RdxMenubarRoot } from '@radix-ng/primitives/menubar'; import { cn, demoMenu, demoMenubar } from '../../storybook/styles'; @Component({ selector: 'rdx-menubar-default', imports: [RdxMenuModule, RdxMenubarRoot, LucideCheck, LucideDot], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@if (shareSub.open()) {
}
` }) export class RdxMenubarDefaultComponent { protected readonly cn = cn; protected readonly mb = demoMenubar; protected readonly m = demoMenu; showBookmarks = signal(true); showFullUrls = signal(false); activeProfile = signal('andy'); } ``` ## Keyboard interactions | Key | Behavior | | ----------------------------------- | ------------------------------------------------------------------ | | ArrowDown | Opens the focused trigger's menu and moves focus to the first item. | | ArrowUp | Opens the focused trigger's menu and moves focus to the last item. | | ArrowRight | Moves to the next trigger. Opens it if a menu was already open. | | ArrowLeft | Moves to the previous trigger. Opens it if a menu was already open. | | Home | Moves to the first trigger. | | End | Moves to the last trigger. | | Escape | Closes the open menu and returns focus to its trigger. | | Enter / Space | Activates the focused item (handled by the Menu primitive). | Triggers move with a roving tabindex: only one trigger is in the tab order at a time, and arrow keys move focus between them without leaving the menubar. Disabled triggers are skipped. ## API Reference ### RdxMenubarRoot The horizontal container. Provides the coordination context consumed by each `rdxMenuTrigger`. Exposes `data-orientation`, `data-has-submenu-open`, and `role="menubar"`. ### Menu Trigger Use the standard `rdxMenuTrigger` inside each top-level `rdxMenuRoot`. When it is a direct child menu of `rdxMenubarRoot`, the menubar wires it for roving focus, hover switching, and ArrowLeft / ArrowRight navigation. There is no separate menubar trigger directive — the menu trigger reports its interactions to the menubar through context, mirroring [Base UI](https://base-ui.com/react/components/menubar). During hover-switching the trigger keeps focus (the popup opens without grabbing it), so the bar stays keyboard-navigable. It also reports `role="menuitem"` and is taken out of the tab order (`tabindex="-1"`) so the whole bar acts as a single tab stop. | Data attribute | Value | | -------------- | ---------------------- | | `[data-state]` | `"open"` or `"closed"` | ### Menu parts All other parts — `rdxMenuPositioner`, `rdxMenuPopup`, `rdxMenuItem`, `rdxMenuCheckboxItem`, `rdxMenuRadioGroup`, `rdxMenuSubTrigger`, `rdxMenuSeparator`, … — come from the [Menu](?path=/docs/primitives-menu--docs) primitive and behave identically here. --- # Meter #### A graphical display of a numeric value within a range. ```typescript import { Component, computed, DestroyRef, inject, signal } from '@angular/core'; import { cn } from '../../storybook/styles'; import { RdxMeterIndicatorDirective } from '../src/meter-indicator.directive'; import { RdxMeterLabelDirective } from '../src/meter-label.directive'; import { RdxMeterRootDirective } from '../src/meter-root.directive'; import { RdxMeterTrackDirective } from '../src/meter-track.directive'; import { RdxMeterValueDirective } from '../src/meter-value.directive'; const storageSteps = [24, 38, 52, 67, 81] as const; @Component({ selector: 'meter-storage', imports: [ RdxMeterRootDirective, RdxMeterLabelDirective, RdxMeterValueDirective, RdxMeterTrackDirective, RdxMeterIndicatorDirective ], template: `
Storage used
` }) export class MeterStorageComponent { private readonly destroyRef = inject(DestroyRef); protected readonly value = signal<(typeof storageSteps)[number]>(storageSteps[0]); protected readonly format: Intl.NumberFormatOptions = { style: 'unit', unit: 'gigabyte', unitDisplay: 'short' }; protected readonly getAriaValueText = (formattedValue: string) => `${formattedValue} of storage used`; protected readonly indicatorClass = computed(() => cn('h-full rounded-full bg-primary transition-all duration-700 ease-out', this.widthClass()) ); private readonly widthClass = computed(() => { switch (this.value()) { case 24: return 'w-[24%]'; case 38: return 'w-[38%]'; case 52: return 'w-[52%]'; case 67: return 'w-[67%]'; case 81: return 'w-[81%]'; } }); constructor() { let stepIndex = 0; const interval = window.setInterval(() => { stepIndex = (stepIndex + 1) % storageSteps.length; this.value.set(storageSteps[stepIndex]); }, 1200); this.destroyRef.onDestroy(() => window.clearInterval(interval)); } } ``` ## Features - ✅ Exposes native meter semantics with `role="meter"` and `aria-valuenow`. - ✅ Supports custom `min` and `max` ranges. - ✅ Formats display text with `Intl.NumberFormat`. - ✅ Provides Label and Value parts for accessible naming and value text. - ✅ Mirrors value metadata through `data-value`, `data-min`, `data-max`, and `data-percent`. ## Import ```typescript import { RdxMeterRootDirective, RdxMeterLabelDirective, RdxMeterValueDirective, RdxMeterTrackDirective, RdxMeterIndicatorDirective } from '@radix-ng/primitives/meter'; ``` ## Anatomy ```html
Storage used
``` ## Examples ### Default An animated labelled meter with formatted value text. ```typescript import { Component, computed, DestroyRef, inject, signal } from '@angular/core'; import { cn } from '../../storybook/styles'; import { RdxMeterIndicatorDirective } from '../src/meter-indicator.directive'; import { RdxMeterLabelDirective } from '../src/meter-label.directive'; import { RdxMeterRootDirective } from '../src/meter-root.directive'; import { RdxMeterTrackDirective } from '../src/meter-track.directive'; import { RdxMeterValueDirective } from '../src/meter-value.directive'; const storageSteps = [24, 38, 52, 67, 81] as const; @Component({ selector: 'meter-storage', imports: [ RdxMeterRootDirective, RdxMeterLabelDirective, RdxMeterValueDirective, RdxMeterTrackDirective, RdxMeterIndicatorDirective ], template: `
Storage used
` }) export class MeterStorageComponent { private readonly destroyRef = inject(DestroyRef); protected readonly value = signal<(typeof storageSteps)[number]>(storageSteps[0]); protected readonly format: Intl.NumberFormatOptions = { style: 'unit', unit: 'gigabyte', unitDisplay: 'short' }; protected readonly getAriaValueText = (formattedValue: string) => `${formattedValue} of storage used`; protected readonly indicatorClass = computed(() => cn('h-full rounded-full bg-primary transition-all duration-700 ease-out', this.widthClass()) ); private readonly widthClass = computed(() => { switch (this.value()) { case 24: return 'w-[24%]'; case 38: return 'w-[38%]'; case 52: return 'w-[52%]'; case 67: return 'w-[67%]'; case 81: return 'w-[81%]'; } }); constructor() { let stepIndex = 0; const interval = window.setInterval(() => { stepIndex = (stepIndex + 1) % storageSteps.length; this.value.set(storageSteps[stepIndex]); }, 1200); this.destroyRef.onDestroy(() => window.clearInterval(interval)); } } ``` ### Custom range Use `min`, `max`, and `format` when the measured value is not a simple 0-100 range. ```html
Fuel level
``` ### Aria value text Use `aria-valuetext` when the numeric value needs a more descriptive text alternative. ```html
Memory pressure
``` ## API Reference ### Root `RdxMeterRootDirective` ### Label `RdxMeterLabelDirective` Gives the meter its accessible name through `aria-labelledby`. ### Value `RdxMeterValueDirective` Displays the formatted value text and provides `aria-describedby` text for the meter. ### Track `RdxMeterTrackDirective` Contains the visual indicator and mirrors root value attributes. ### Indicator `RdxMeterIndicatorDirective` Displays the visual meter fill and exposes `data-percent` for styling. --- # Navigation Menu #### A collection of links and menus for website navigation. Navigation Menu is a menubar of triggers and links whose content is rendered into one shared popup. It composes the shared Popper, Portal, Presence, Dismissable Layer, and Roving Focus primitives and remains headless: styles and native CSS animations belong to the consumer. ```typescript import { Component } from '@angular/core'; import { LucideChevronDown } from '@lucide/angular'; import { navigationMenuImports } from '@radix-ng/primitives/navigation-menu'; import { cn, demoNavigationMenu } from '../../storybook/styles'; @Component({ selector: 'rdx-navigation-menu-default', imports: [...navigationMenuImports, LucideChevronDown], template: `
` }) export class RdxNavigationMenuDefaultComponent { protected readonly cn = cn; protected readonly m = demoNavigationMenu; protected readonly products = [ { title: 'Analytics', description: 'Understand your traffic with privacy-first analytics.' }, { title: 'Automations', description: 'Build workflows that run on your schedule.' }, { title: 'Reports', description: 'Share insights with beautiful, exportable reports.' }, { title: 'Integrations', description: 'Connect the tools your team already uses.' } ]; protected readonly resources = [ { title: 'Documentation', description: 'Guides and references to get you started.' }, { title: 'Changelog', description: 'See what shipped in every release.' }, { title: 'Community', description: 'Ask questions and share what you build.' } ]; } ``` ## Features - ✅ A single shared popup, anchored to the active trigger with the Floating UI-based Popper primitive. - ✅ Content morphs between items, exposing `data-activation-direction` and a `data-previous` snapshot for slide animations. - ✅ Opens on hover with configurable `delay` / `closeDelay`, and a polygon grace area that keeps the popup reachable. - ✅ `value` / `defaultValue` model with `onValueChange`, `onOpenChange`, and `onOpenChangeComplete` outputs. - ✅ Menubar keyboard navigation (arrow keys) via the shared Roving Focus primitive. - ✅ Enter / Space / arrow keys open a trigger and move focus into its content; arrows / Home / End navigate the open panel. - ✅ Open-follows-focus: while open, moving keyboard focus to another trigger switches the active item. - ✅ Horizontal and vertical orientations and LTR / RTL layouts. - ✅ Nested navigation menus, each owning its own keyboard navigation. - ✅ Closes on Escape and outside pointer interaction, restoring focus to the trigger. - ✅ Keeps the popup mounted while CSS exit keyframes finish. - ✅ Exposes state, transition, placement, and size attributes and CSS variables for styling. ## Import ```typescript import { RdxNavigationMenuArrow, RdxNavigationMenuBackdrop, RdxNavigationMenuContent, RdxNavigationMenuIcon, RdxNavigationMenuItem, RdxNavigationMenuLink, RdxNavigationMenuList, RdxNavigationMenuPopup, RdxNavigationMenuPortal, RdxNavigationMenuPortalPresence, RdxNavigationMenuPositioner, RdxNavigationMenuRoot, RdxNavigationMenuTrigger, RdxNavigationMenuViewport } from '@radix-ng/primitives/navigation-menu'; ``` Or import all parts through the module: ```typescript import { RdxNavigationMenuModule } from '@radix-ng/primitives/navigation-menu'; ``` ## Anatomy Apply the parts to your own markup. Each item's `*rdxNavigationMenuContent` template is rendered into the shared `rdxNavigationMenuViewport`. `rdxNavigationMenuPortalPresence` manages mounting and waits for exit keyframes on the first DOM element inside its template. ```html ``` ## Examples ### Default A horizontal menubar with two content panels and a standalone link. Content slides and resizes as the active item changes. ```typescript import { Component } from '@angular/core'; import { LucideChevronDown } from '@lucide/angular'; import { navigationMenuImports } from '@radix-ng/primitives/navigation-menu'; import { cn, demoNavigationMenu } from '../../storybook/styles'; @Component({ selector: 'rdx-navigation-menu-default', imports: [...navigationMenuImports, LucideChevronDown], template: ` ` }) export class RdxNavigationMenuDefaultComponent { protected readonly cn = cn; protected readonly m = demoNavigationMenu; protected readonly products = [ { title: 'Analytics', description: 'Understand your traffic with privacy-first analytics.' }, { title: 'Automations', description: 'Build workflows that run on your schedule.' }, { title: 'Reports', description: 'Share insights with beautiful, exportable reports.' }, { title: 'Integrations', description: 'Connect the tools your team already uses.' } ]; protected readonly resources = [ { title: 'Documentation', description: 'Guides and references to get you started.' }, { title: 'Changelog', description: 'See what shipped in every release.' }, { title: 'Community', description: 'Ask questions and share what you build.' } ]; } ``` ### Vertical Set `orientation="vertical"` on the root and position the popup to the side with `side="right"`. ```typescript import { Component } from '@angular/core'; import { LucideChevronRight } from '@lucide/angular'; import { navigationMenuImports } from '@radix-ng/primitives/navigation-menu'; import { cn, demoNavigationMenu } from '../../storybook/styles'; @Component({ selector: 'rdx-navigation-menu-vertical', imports: [...navigationMenuImports, LucideChevronRight], template: ` ` }) export class RdxNavigationMenuVerticalComponent { protected readonly cn = cn; protected readonly m = demoNavigationMenu; protected readonly groups = [ { value: 'account', label: 'Account', links: ['Profile', 'Billing', 'Security'] }, { value: 'workspace', label: 'Workspace', links: ['Members', 'Usage', 'Audit log'] }, { value: 'support', label: 'Support', links: ['Help center', 'Contact us'] } ]; } ``` ### Right to left Set `dir="rtl"` on the root. Placement, alignment, and arrow-key direction follow the reading direction. ```typescript import { Component } from '@angular/core'; import { LucideChevronDown } from '@lucide/angular'; import { navigationMenuImports } from '@radix-ng/primitives/navigation-menu'; import { cn, demoNavigationMenu } from '../../storybook/styles'; @Component({ selector: 'rdx-navigation-menu-rtl', imports: [...navigationMenuImports, LucideChevronDown], template: ` ` }) export class RdxNavigationMenuRtlComponent { protected readonly cn = cn; protected readonly m = demoNavigationMenu; protected readonly groups = [ { value: 'about', label: 'حول', links: ['نبذة', 'الفريق', 'الوظائف'] }, { value: 'products', label: 'المنتجات', links: ['التحليلات', 'التقارير', 'التكاملات'] } ]; } ``` ### Links only A menubar of links without any popup. Links are plain tabbable anchors and expose `data-active`. ```typescript import { Component } from '@angular/core'; import { navigationMenuImports } from '@radix-ng/primitives/navigation-menu'; import { demoNavigationMenu } from '../../storybook/styles'; @Component({ selector: 'rdx-navigation-menu-links', imports: [...navigationMenuImports], template: ` ` }) export class RdxNavigationMenuLinksComponent { protected readonly m = demoNavigationMenu; } ``` ### Custom links `rdxNavigationMenuLink` composes onto your own anchor markup — rich rows with icons, external links, and an "action" link that runs a handler via `(onSelect)` without closing the menu (`[closeOnClick]="false"`). The same directive sits on a router link. ```typescript import { Component, signal } from '@angular/core'; import { LucideArrowUpRight, LucideBookOpen, LucideChevronDown, LucideCode, LucideLifeBuoy } from '@lucide/angular'; import { navigationMenuImports } from '@radix-ng/primitives/navigation-menu'; import { cn, demoNavigationMenu } from '../../storybook/styles'; /** * Custom links: `rdxNavigationMenuLink` composes onto your own anchor markup — rich rows with icons, * an external link (opens in a new tab), and an "action" link that runs a handler via `(onSelect)` * without closing the menu (`[closeOnClick]="false"`). The same directive would sit on a router link. */ @Component({ selector: 'rdx-navigation-menu-custom-links', imports: [ ...navigationMenuImports, LucideChevronDown, LucideArrowUpRight, LucideBookOpen, LucideCode, LucideLifeBuoy ], template: ` ` }) export class RdxNavigationMenuCustomLinksComponent { protected readonly cn = cn; protected readonly m = demoNavigationMenu; protected readonly copied = signal(false); protected readonly row = cn( 'flex items-start gap-3 rounded-md p-3 no-underline outline-none transition-colors', 'hover:bg-muted focus-visible:bg-muted' ); protected readonly iconBox = 'flex size-9 shrink-0 items-center justify-center rounded-md bg-muted text-foreground'; protected copy(event: Event) { event.preventDefault(); this.copied.set(true); } } ``` ### Large menus When the content exceeds the available space, constrain it with a `max-height` and let it scroll. The viewport measures the capped height, so the popup stays a fixed size and the list scrolls inside it. ```typescript import { Component } from '@angular/core'; import { LucideChevronDown } from '@lucide/angular'; import { navigationMenuImports } from '@radix-ng/primitives/navigation-menu'; import { cn, demoNavigationMenu } from '../../storybook/styles'; /** * Large menus: when the content exceeds the available space, constrain the content with a * `max-height` and let it scroll (`overflow-y-auto`). The viewport measures the capped height, so the * popup stays a fixed size and the list scrolls inside it. */ @Component({ selector: 'rdx-navigation-menu-large', imports: [...navigationMenuImports, LucideChevronDown], template: ` ` }) export class RdxNavigationMenuLargeComponent { protected readonly cn = cn; protected readonly m = demoNavigationMenu; protected readonly components = [ { title: 'Accordion', text: 'Vertically stacked, collapsible panels.' }, { title: 'Alert Dialog', text: 'A modal dialog that interrupts the user.' }, { title: 'Avatar', text: 'An image element with a text fallback.' }, { title: 'Checkbox', text: 'A control that toggles between states.' }, { title: 'Collapsible', text: 'Expand and collapse a content region.' }, { title: 'Dialog', text: 'A window overlaid on the primary window.' }, { title: 'Dropdown Menu', text: 'A menu of actions triggered by a button.' }, { title: 'Popover', text: 'Rich content floating around a trigger.' }, { title: 'Progress', text: 'Displays task completion progress.' }, { title: 'Radio Group', text: 'A set of mutually exclusive options.' }, { title: 'Select', text: 'A control for choosing from a list.' }, { title: 'Slider', text: 'Pick a value from a given range.' }, { title: 'Switch', text: 'A toggle between on and off.' }, { title: 'Tabs', text: 'Layered sections of content.' }, { title: 'Tooltip', text: 'A hint that appears on hover or focus.' } ]; } ``` ### Nested submenus Render a `rdxNavigationMenuRoot` inside content for a nested menu. The nested root detects its parent and positions its own popup inline; arrow keys navigate the nested menu independently. ```typescript import { Component } from '@angular/core'; import { LucideChevronDown, LucideChevronRight } from '@lucide/angular'; import { navigationMenuImports } from '@radix-ng/primitives/navigation-menu'; import { cn, demoNavigationMenu } from '../../storybook/styles'; @Component({ selector: 'rdx-navigation-menu-nested', imports: [...navigationMenuImports, LucideChevronDown, LucideChevronRight], template: ` ` }) export class RdxNavigationMenuNestedComponent { protected readonly cn = cn; protected readonly m = demoNavigationMenu; protected readonly groups = [ { value: 'about', label: 'About', links: ['Mission', 'Team', 'Careers'] }, { value: 'press', label: 'Press', links: ['News', 'Media kit'] } ]; } ``` ### Nested inline submenus A second level that stays in the same panel: inside the outer content, render another `rdxNavigationMenuRoot` with only a `List` and a `Viewport` (no Portal). A controlled, non-null value keeps the inline panel persistent. ```typescript import { Component, signal } from '@angular/core'; import { LucideChevronDown } from '@lucide/angular'; import { navigationMenuImports } from '@radix-ng/primitives/navigation-menu'; import { cn, demoNavigationMenu } from '../../storybook/styles'; /** * Nested inline submenus: a second level that stays in the same panel. Inside the outer Content we * render another `rdxNavigationMenuRoot` with only a `List` (the categories, left) and a `Viewport` * (the active category's links, right) — no Portal/Positioner/Popup. Its value is controlled and * kept non-null so the inline panel always shows a category (the idea behind Base UI's `defaultValue`). */ @Component({ selector: 'rdx-navigation-menu-nested-inline', imports: [...navigationMenuImports, LucideChevronDown], template: ` ` }) export class RdxNavigationMenuNestedInlineComponent { protected readonly cn = cn; protected readonly m = demoNavigationMenu; protected readonly active = signal('learn'); protected readonly inlineViewport = cn( 'relative min-h-[180px] flex-1 overflow-hidden p-2', '[&>[data-current]]:animate-navigation-menu-content-in', '[&>[data-previous]]:absolute [&>[data-previous]]:inset-0 [&>[data-previous]]:p-2 [&>[data-previous]]:animate-navigation-menu-content-out' ); protected readonly categories = [ { value: 'learn', label: 'Learn', links: [ { title: 'Tutorials', text: 'Step-by-step introductions.' }, { title: 'Guides', text: 'Patterns and best practices.' }, { title: 'Examples', text: 'Copy-paste building blocks.' } ] }, { value: 'develop', label: 'Develop', links: [ { title: 'API reference', text: 'Every input and output.' }, { title: 'CLI', text: 'Scaffold and generate.' } ] }, { value: 'resources', label: 'Resources', links: [ { title: 'Blog', text: 'Product news and deep dives.' }, { title: 'Changelog', text: 'What shipped recently.' }, { title: 'Community', text: 'Ask and share.' } ] } ]; protected onActive(value: string | null) { // Keep the inline panel persistent: ignore the "closed" (null) state. if (value) { this.active.set(value); } } } ``` ## API Reference ### Root `RdxNavigationMenuRoot` owns the open state. The menu is open whenever `value` is non-null. ### Trigger `RdxNavigationMenuTrigger` opens its item's content and exposes ARIA and state attributes. ### Content `RdxNavigationMenuContent` is a structural directive; its template is rendered into the shared viewport when its item is active. ### Link `RdxNavigationMenuLink` is a navigation link that closes the menu on selection unless prevented. ### Portal `RdxNavigationMenuPortal` moves the popup to `document.body` by default or to a configured container. ### Positioner `RdxNavigationMenuPositioner` delegates placement and collision handling to the shared Popper primitive and exposes anchor / positioner CSS variables. ### Viewport `RdxNavigationMenuViewport` renders the active item's content, animates the transition between items, and exposes `--popup-width` / `--popup-height` for the size morph. ### List, Item, Icon, Popup, Arrow, and Backdrop These parts read their behavior and state from context. `List` is the `menubar` container with roving focus, `Item` carries the `value`, `Icon` exposes the open state for a caret, and `Popup`, `Arrow`, and `Backdrop` reflect open / placement / transition state for styling. --- # Number Field #### A numeric input with stepper buttons, drag-to-scrub, locale-aware formatting and keyboard control. ```typescript import { Component } from '@angular/core'; import { LucideDynamicIcon, LucideMinus as Minus, LucidePlus as Plus } from '@lucide/angular'; import { RdxNumberFieldDecrement, RdxNumberFieldGroup, RdxNumberFieldIncrement, RdxNumberFieldInput, RdxNumberFieldRoot } from '@radix-ng/primitives/number-field'; @Component({ selector: 'number-field-default-example', imports: [ LucideDynamicIcon, RdxNumberFieldRoot, RdxNumberFieldGroup, RdxNumberFieldInput, RdxNumberFieldIncrement, RdxNumberFieldDecrement ], template: `
` }) export class NumberFieldDefaultExample { protected readonly Minus = Minus; protected readonly Plus = Plus; } ``` ## Features - ✅ Controlled or uncontrolled value, with `(onValueChange)` and `(onValueCommitted)`. - ✅ Stepper buttons with press-and-hold auto-repeat. - ✅ Keyboard control — arrow keys, `Home`/`End`, plus `Alt` (small) and `Shift` (large) steps. - ✅ Drag-to-scrub via the Scrub Area (Pointer Lock with an optional virtual cursor). - ✅ Optional mouse-wheel scrubbing while focused. - ✅ Locale-aware parsing and formatting (decimal, percent, currency, unit) via `Intl.NumberFormat`. - ✅ `min`/`max` clamping with optional `allowOutOfRange` for direct text entry, and `snapOnStep`. - ✅ Works with Angular reactive and template-driven forms. ## Preface Formatting and parsing build on [@internationalized/number](https://react-spectrum.adobe.com/internationalized/number/index.html), so numbers are read and displayed according to the chosen `locale` and `format` options. ## Import ```ts import { RdxNumberFieldRoot, RdxNumberFieldGroup, RdxNumberFieldInput, RdxNumberFieldHiddenInput, RdxNumberFieldIncrement, RdxNumberFieldDecrement, RdxNumberFieldScrubArea, RdxNumberFieldScrubAreaCursor } from '@radix-ng/primitives/number-field'; ``` ## Anatomy Import all parts and piece them together. ```html
``` ## Examples ### Default A basic field with a label, stepper buttons and a `min` of `0`. ```typescript import { Component } from '@angular/core'; import { LucideDynamicIcon, LucideMinus as Minus, LucidePlus as Plus } from '@lucide/angular'; import { RdxNumberFieldDecrement, RdxNumberFieldGroup, RdxNumberFieldIncrement, RdxNumberFieldInput, RdxNumberFieldRoot } from '@radix-ng/primitives/number-field'; @Component({ selector: 'number-field-default-example', imports: [ LucideDynamicIcon, RdxNumberFieldRoot, RdxNumberFieldGroup, RdxNumberFieldInput, RdxNumberFieldIncrement, RdxNumberFieldDecrement ], template: `
` }) export class NumberFieldDefaultExample { protected readonly Minus = Minus; protected readonly Plus = Plus; } ``` ### Decimal Fractional steps with `signDisplay` and fraction-digit formatting. ```typescript import { Component } from '@angular/core'; import { LucideDynamicIcon, LucideMinus as Minus, LucidePlus as Plus } from '@lucide/angular'; import { RdxNumberFieldDecrement, RdxNumberFieldGroup, RdxNumberFieldIncrement, RdxNumberFieldInput, RdxNumberFieldRoot } from '@radix-ng/primitives/number-field'; @Component({ selector: 'number-field-decimal-example', imports: [ LucideDynamicIcon, RdxNumberFieldRoot, RdxNumberFieldGroup, RdxNumberFieldInput, RdxNumberFieldIncrement, RdxNumberFieldDecrement ], template: `
` }) export class NumberFieldDecimalExample { protected readonly format: Intl.NumberFormatOptions = { signDisplay: 'exceptZero', minimumFractionDigits: 1, maximumFractionDigits: 2 }; protected readonly Minus = Minus; protected readonly Plus = Plus; } ``` ### Percentage `format: { style: 'percent' }` displays and parses the value as a percentage. ```typescript import { Component } from '@angular/core'; import { LucideDynamicIcon, LucideMinus as Minus, LucidePlus as Plus } from '@lucide/angular'; import { RdxNumberFieldDecrement, RdxNumberFieldGroup, RdxNumberFieldIncrement, RdxNumberFieldInput, RdxNumberFieldRoot } from '@radix-ng/primitives/number-field'; @Component({ selector: 'number-field-percentage-example', imports: [ LucideDynamicIcon, RdxNumberFieldRoot, RdxNumberFieldGroup, RdxNumberFieldInput, RdxNumberFieldIncrement, RdxNumberFieldDecrement ], template: `
` }) export class NumberFieldPercentageExample { protected readonly format: Intl.NumberFormatOptions = { style: 'percent' }; protected readonly Minus = Minus; protected readonly Plus = Plus; } ``` ### Currency `format: { style: 'currency', currency: 'EUR' }` renders a currency value. ```typescript import { Component } from '@angular/core'; import { LucideDynamicIcon, LucideMinus as Minus, LucidePlus as Plus } from '@lucide/angular'; import { RdxNumberFieldDecrement, RdxNumberFieldGroup, RdxNumberFieldIncrement, RdxNumberFieldInput, RdxNumberFieldRoot } from '@radix-ng/primitives/number-field'; @Component({ selector: 'number-field-currency-example', imports: [ LucideDynamicIcon, RdxNumberFieldRoot, RdxNumberFieldGroup, RdxNumberFieldInput, RdxNumberFieldIncrement, RdxNumberFieldDecrement ], template: `
` }) export class NumberFieldCurrencyExample { protected readonly format: Intl.NumberFormatOptions = { style: 'currency', currency: 'EUR', currencyDisplay: 'symbol' }; protected readonly Minus = Minus; protected readonly Plus = Plus; } ``` ### Scrub Area Drag the label horizontally to change the value; a virtual cursor follows the pointer. ```typescript import { Component } from '@angular/core'; import { LucideDynamicIcon, LucideMinus as Minus, LucideMoveHorizontal as Move, LucidePlus as Plus } from '@lucide/angular'; import { RdxNumberFieldDecrement, RdxNumberFieldGroup, RdxNumberFieldIncrement, RdxNumberFieldInput, RdxNumberFieldRoot, RdxNumberFieldScrubArea, RdxNumberFieldScrubAreaCursor } from '@radix-ng/primitives/number-field'; @Component({ selector: 'number-field-scrub-example', imports: [ LucideDynamicIcon, RdxNumberFieldRoot, RdxNumberFieldGroup, RdxNumberFieldInput, RdxNumberFieldIncrement, RdxNumberFieldDecrement, RdxNumberFieldScrubArea, RdxNumberFieldScrubAreaCursor ], template: `
` }) export class NumberFieldScrubExample { protected readonly Minus = Minus; protected readonly Plus = Plus; protected readonly Move = Move; } ``` ### Reactive Forms The control integrates with `formControlName`, exposing the numeric value to the form. ```typescript import { Component, OnInit } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { LucideDynamicIcon, LucideMinus as Minus, LucidePlus as Plus } from '@lucide/angular'; import { RdxNumberFieldDecrement, RdxNumberFieldGroup, RdxNumberFieldHiddenInput, RdxNumberFieldIncrement, RdxNumberFieldInput, RdxNumberFieldRoot } from '@radix-ng/primitives/number-field'; @Component({ selector: 'number-field-reactive-forms', imports: [ ReactiveFormsModule, LucideDynamicIcon, RdxNumberFieldRoot, RdxNumberFieldGroup, RdxNumberFieldInput, RdxNumberFieldHiddenInput, RdxNumberFieldIncrement, RdxNumberFieldDecrement ], template: `

Value: {{ formGroup.value.guests }}

` }) export class NumberFieldReactiveForms implements OnInit { formGroup!: FormGroup; protected readonly Minus = Minus; protected readonly Plus = Plus; ngOnInit() { this.formGroup = new FormGroup({ guests: new FormControl(2) }); } onSubmit(): void { console.log(this.formGroup.value); } } ``` ## API Reference ### Root `RdxNumberFieldRoot` groups all parts and owns the value, parsing/formatting and stepping logic. Exposes `data-disabled`, `data-readonly`, `data-required` and `data-scrubbing`. ### Group `RdxNumberFieldGroup` groups the input with the stepper buttons (`role="group"`). It reads its state from the root context and has no inputs. ### Input `RdxNumberFieldInput` the native text input that displays the formatted value and accepts typed input. It reads everything from the root context and has no inputs. ### Hidden Input `RdxNumberFieldHiddenInput` an optional hidden `input[type=number]` that mirrors the value for native form submission and browser constraint validation. Set `name` (and optionally `form`) on the root. Not needed when using Angular reactive or template-driven forms. ### Increment / Decrement `RdxNumberFieldIncrement` and `RdxNumberFieldDecrement` step the value up and down. Each accepts an optional `disabled` input and is automatically disabled when the value reaches `max`/`min`. ### Scrub Area `RdxNumberFieldScrubArea` an interactive area where the user clicks and drags to change the value. ### Scrub Area Cursor `RdxNumberFieldScrubAreaCursor` an optional custom cursor shown while scrubbing, portaled to the document body. Hidden on Safari and for touch input. It has no inputs. ## Accessibility Adheres to the [Spinbutton WAI-ARIA design pattern](https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/). ### Keyboard Interactions | Key | Description | | ------------ | -------------------------------------------- | | Arrow Up | Increase the value by `step` | | Arrow Down | Decrease the value by `step` | | Shift + Arrow | Increase/decrease by `largeStep` | | Alt + Arrow | Increase/decrease by `smallStep` | | Home | Set the value to `min` (if provided) | | End | Set the value to `max` (if provided) | --- # Pagination #### Displays data in paged format and provides navigation between pages. ```html
@for (item of list.transformedRange(); track item) { @if (item.type == 'page') { } @else {
} }
``` ## Features - ✅ Enable quick access to first, or last page. - ✅ Enable to show edges constantly, or not. ## Import Get started with importing the directives: ```typescript import { RdxPaginationModule } from '@radix-ng/primitives/pagination'; ``` ## Anatomy ```html
@for (item of list.transformedRange(); track item) { }
``` ## API Reference ### Root `RdxPaginationRootDirective` ### List `RdxPaginationListDirective` Used to show the list of pages. It also makes pagination accessible to assistive technologies. ### Item `RdxPaginationListItemDirective` Used to render the button that changes the current page. | Data Attribute | Value | |-----------------|--------------| | [data-selected] | `true` or `` | | [data-type] | "page" | ### Ellipsis `RdxPaginationEllipsisDirective` Placeholder element when the list is long, and only a small amount of `siblingCount` was set and `showEdges` was set to `true`. | Data Attribute | Value | |----------------|------------| | [data-type] | `ellipsis` | ## Accessibility ### Keyboard Interactions | Key | Description | |---------|----------------------------------------------------------------------------| | `Tab` | Moves focus to the next focusable element. | | `Space` | When focus is on a any trigger, trigger selected page or arrow navigation. | | `Enter` | When focus is on a any trigger, trigger selected page or arrow navigation. | ## Examples ### With ellipsis You can add `rdxPaginationEllipsis` as a visual cue for more previous and after items. ```html
@for (item of list.transformedRange(); track item) { @if (item.type == 'page') { } @else {
} }
``` ### With first/last button You can add `rdxPaginationFirst` to allow user to navigate to first page, or `rdxPaginationLast` to navigate to last page. ```html
...
``` --- # Popover #### An accessible popup anchored to a button. Popover composes the shared Popper, Portal, Presence, Dismissable Layer, and Focus Scope primitives. It remains headless: styles and native CSS animations belong to the consumer. ```typescript import { Component } from '@angular/core'; import { LucidePlus, LucideX } from '@lucide/angular'; import { popoverImports } from '@radix-ng/primitives/popover'; import { cn, demoButton, demoInput, demoPopover } from '../../storybook/styles'; @Component({ selector: 'rdx-popover-default', imports: [...popoverImports, LucidePlus, LucideX], template: `

Dimensions

Set the width and height for the element.

` }) export class RdxPopoverDefaultComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly input = demoInput; protected readonly p = demoPopover; } ``` ## Features - ✅ Opens and closes from a native button trigger. - ✅ Supports uncontrolled state, `defaultOpen`, and Angular two-way binding with `[(open)]`. - ✅ Controls the active trigger externally with `[(triggerId)]` and `defaultTriggerId`. - ✅ Positions content with the shared Floating UI-based Popper primitive. - ✅ Supports a custom positioning anchor independently from the trigger. - ✅ Connects detached roots and multiple triggers through a shared handle. - ✅ Opens on hover with configurable per-trigger open and close delays. - ✅ Keeps hover-opened content interactive with a polygon grace area, including nested popup content. - ✅ Exposes pressed state on triggers for press interactions. - ✅ Animates content changes between triggers with an optional viewport. - ✅ Exposes transition lifecycle attributes and `onOpenChangeComplete` for CSS animations. - ✅ Supports non-modal, modal, and focus-trapping-only behavior. - ✅ Closes on Escape, outside pointer interaction, or an explicit close button. - ✅ Restores focus through the shared Focus Scope behavior. - ✅ Exposes state, transition, placement, and anchor measurement attributes and CSS variables for styling. - ✅ Keeps portal content mounted while CSS exit keyframes finish. - ✅ Links the popup to optional title and description parts for accessible labeling. ## Import ```typescript import { createRdxPopoverHandle, RdxPopoverArrow, RdxPopoverBackdrop, RdxPopoverClose, RdxPopoverDescription, RdxPopoverPopup, RdxPopoverPortal, RdxPopoverPortalPresence, RdxPopoverPositioner, RdxPopoverRoot, RdxPopoverTitle, RdxPopoverTrigger, RdxPopoverViewport } from '@radix-ng/primitives/popover'; ``` Or import all parts through the module: ```typescript import { RdxPopoverModule } from '@radix-ng/primitives/popover'; ``` ## Anatomy Apply the parts to your own markup. `rdxPopoverPortalPresence` manages mounting and waits for exit keyframes on the first DOM element inside its template. ```html

Notifications

You are all caught up.

``` ## Examples ### Default A form-like popup with an arrow, accessible title and description, and a close button. ```typescript import { Component } from '@angular/core'; import { LucidePlus, LucideX } from '@lucide/angular'; import { popoverImports } from '@radix-ng/primitives/popover'; import { cn, demoButton, demoInput, demoPopover } from '../../storybook/styles'; @Component({ selector: 'rdx-popover-default', imports: [...popoverImports, LucidePlus, LucideX], template: `

Dimensions

Set the width and height for the element.

` }) export class RdxPopoverDefaultComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly input = demoInput; protected readonly p = demoPopover; } ``` ### Controlled Bind `[(open)]` when application state should open or close the popover programmatically. This example also includes the optional backdrop part. ```typescript import { Component, signal } from '@angular/core'; import { LucideX } from '@lucide/angular'; import { popoverImports } from '@radix-ng/primitives/popover'; import { cn, demoButton, demoPopover } from '../../storybook/styles'; @Component({ selector: 'rdx-popover-controlled', imports: [...popoverImports, LucideX], template: `

State: {{ open() ? 'open' : 'closed' }}

Controlled state

The root uses Angular two-way binding with a signal.

` }) export class RdxPopoverControlledComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly p = demoPopover; protected readonly open = signal(false); } ``` ### Controlled mode with multiple triggers Bind both `[(open)]` and `[(triggerId)]` when application state should choose the active anchor. `onOpenChange` reports the trigger element, trigger id, source event, and change reason. ```typescript import { Component, signal } from '@angular/core'; import { LucideX } from '@lucide/angular'; import { popoverImports, RdxPopoverOpenChange } from '@radix-ng/primitives/popover'; import { cn, demoButton, demoPopover } from '../../storybook/styles'; @Component({ selector: 'rdx-popover-controlled-multiple', imports: [...popoverImports, LucideX], template: `
@for (item of items; track item.id) { }

{{ activeItem()?.label }}

The externally controlled trigger id is {{ triggerId() }}.

State: {{ open() ? 'open' : 'closed' }} · Trigger: {{ triggerId() ?? 'none' }} · Reason: {{ lastReason() }}

` }) export class RdxPopoverControlledMultipleComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly p = demoPopover; protected readonly open = signal(false); protected readonly triggerId = signal(null); protected readonly lastReason = signal('none'); protected readonly items = [ { id: 'notifications', label: 'Notifications' }, { id: 'activity', label: 'Activity' }, { id: 'profile', label: 'Profile' } ]; protected activeItem() { return this.items.find((item) => item.id === this.triggerId()); } protected openProgrammatically() { this.triggerId.set('activity'); this.open.set(true); } protected handleOpenChange(change: RdxPopoverOpenChange) { this.lastReason.set(change.reason); } } ``` ### Positioning Configure `side`, `sideOffset`, `align`, and collision behavior on `rdxPopoverPositioner`. The shared Popper primitive updates `data-side` and `data-align` after collision handling. ```typescript import { Component, signal } from '@angular/core'; import { LucideX } from '@lucide/angular'; import { popoverImports } from '@radix-ng/primitives/popover'; import { Side } from '@radix-ng/primitives/popper'; import { cn, demoButton, demoPopover } from '../../storybook/styles'; @Component({ selector: 'rdx-popover-positioning', imports: [...popoverImports, LucideX], template: `
@for (side of sides; track side) { }

Positioned popup

The positioner delegates collision handling to the shared Popper primitive.

` }) export class RdxPopoverPositioningComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly p = demoPopover; protected readonly sides: Side[] = ['top', 'right', 'bottom', 'left']; protected readonly selectedSide = signal('bottom'); } ``` ### Animation For presence-managed content, apply native CSS keyframe animation utilities to the portal element. The closed animation keeps the portal mounted until `animationend`. ```typescript import { Component } from '@angular/core'; import { LucideX } from '@lucide/angular'; import { popoverImports } from '@radix-ng/primitives/popover'; import { cn, demoButton, demoPopover } from '../../storybook/styles'; @Component({ selector: 'rdx-popover-animated', imports: [...popoverImports, LucideX], template: `

Native CSS keyframes

Presence keeps this portal mounted until the exit animation finishes.

` }) export class RdxPopoverAnimatedComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly p = demoPopover; } ``` ### Modal behavior Set `modal` on `rdxPopoverRoot` to block outside interaction, or use `"trap-focus"` to keep focus inside while leaving document scrolling and outside pointer interactions available. ```typescript import { Component, signal } from '@angular/core'; import { LucideX } from '@lucide/angular'; import { popoverImports, RdxPopoverModal } from '@radix-ng/primitives/popover'; import { cn, demoButton, demoInput, demoPopover } from '../../storybook/styles'; @Component({ selector: 'rdx-popover-modal', imports: [...popoverImports, LucideX], template: `
@for (option of options; track option.label) { }

{{ description() }}

@if (modal() === true) {
}

Modal behavior

Switch modes, use Tab to move between controls, then try the outside button.

` }) export class RdxPopoverModalComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly input = demoInput; protected readonly p = demoPopover; protected readonly modal = signal(true); protected readonly outsideClicks = signal(0); protected readonly options: Array<{ label: string; value: RdxPopoverModal }> = [ { label: 'Non-modal', value: false }, { label: 'Modal', value: true }, { label: 'Trap focus', value: 'trap-focus' } ]; protected description() { switch (this.modal()) { case true: return 'Modal: outside pointer interactions and document scrolling are blocked. Focus is trapped because the popup contains a close button.'; case 'trap-focus': return 'Trap focus: keyboard focus stays inside, while document scrolling and outside pointer interactions remain available.'; default: return 'Non-modal: outside pointer interactions and document scrolling remain available.'; } } } ``` ### Custom anchor Pass `[anchor]` to `rdxPopoverPositioner` when the popup should open from a trigger but position itself against a different element. ```typescript import { Component } from '@angular/core'; import { LucideX } from '@lucide/angular'; import { popoverImports } from '@radix-ng/primitives/popover'; import { cn, demoButton, demoPopover } from '../../storybook/styles'; @Component({ selector: 'rdx-popover-custom-anchor', imports: [...popoverImports, LucideX], template: `
Popup anchor

Custom anchor

The trigger controls open state, but the positioner is anchored to the dashed box.

` }) export class RdxPopoverCustomAnchorComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly p = demoPopover; } ``` ### Detached handles Create a shared handle when triggers live outside the root or multiple triggers should control the same popup. The handle also supports imperative `open(id)`, `toggle(id)`, and `close()` calls. ```typescript import { Component } from '@angular/core'; import { LucideX } from '@lucide/angular'; import { createRdxPopoverHandle, popoverImports } from '@radix-ng/primitives/popover'; import { cn, demoButton, demoPopover } from '../../storybook/styles'; @Component({ selector: 'rdx-popover-detached', imports: [...popoverImports, LucideX], template: `

Detached handles

Both triggers live outside the root. Open another trigger to move this popup without closing it first.

` }) export class RdxPopoverDetachedComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly p = demoPopover; protected readonly popover = createRdxPopoverHandle(); } ``` ### Opening on hover Add `openOnHover` to a trigger when pointer users should be able to open its popup without clicking. Use `delay` and `closeDelay` on the same trigger to configure the timing. ```typescript import { Component } from '@angular/core'; import { LucideX } from '@lucide/angular'; import { popoverImports } from '@radix-ng/primitives/popover'; import { cn, demoButton, demoPopover } from '../../storybook/styles'; @Component({ selector: 'rdx-popover-hover', imports: [...popoverImports, LucideX], template: `

Hover popover

Move the pointer into this popup. It remains interactive after leaving the trigger.

` }) export class RdxPopoverHoverComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly p = demoPopover; } ``` ### Animating between triggers Wrap one direct content element with `rdxPopoverViewport` to animate content changes when a popup moves between triggers. The viewport exposes `data-activation-direction` and retains a `data-previous` snapshot until its CSS transition or animation completes. ```typescript import { Component } from '@angular/core'; import { popoverImports } from '@radix-ng/primitives/popover'; import { cn, demoButton, demoPopover } from '../../storybook/styles'; @Component({ selector: 'rdx-popover-viewport', imports: [...popoverImports], template: `
@for (item of items; track item.id) { }

{{ root.payload()?.label }}

{{ root.payload()?.description }}

` }) export class RdxPopoverViewportComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly p = demoPopover; protected readonly items = [ { id: 'notifications', label: 'Notifications', description: 'You are all caught up.' }, { id: 'activity', label: 'Activity', description: 'Nothing interesting happened recently.' }, { id: 'profile', label: 'Profile', description: 'Manage your profile settings and account preferences.' } ]; } ``` ## API Reference ### Root `RdxPopoverRoot` owns the open state and optional modal behavior. ### Trigger `RdxPopoverTrigger` toggles the popover and exposes ARIA attributes. ### Portal `RdxPopoverPortal` moves content to `document.body` by default or to a configured container. ### Positioner `RdxPopoverPositioner` delegates placement and collision handling to the shared Popper primitive. Its optional `anchor` input overrides the trigger only for positioning. ### Popup `RdxPopoverPopup` owns dialog semantics, dismissal events, and focus lifecycle events. ### Viewport `RdxPopoverViewport` coordinates direction-aware content transitions between multiple triggers. ### Arrow, Backdrop, Title, Description, and Close These parts read their behavior and state from context and do not expose additional inputs or outputs. --- # Preview Card #### A popup that appears when a link is hovered or focused, showing a visual preview. ```typescript import { Component } from '@angular/core'; import { previewCardImports } from '@radix-ng/primitives/preview-card'; import { cn, demoFocusRing, demoPopover } from '../../storybook/styles'; @Component({ selector: 'rdx-preview-card-default', imports: [...previewCardImports], template: `

The principles of good typography remain in the digital age.

Typography is the art and science of arranging type to make written language clear and effective.

` }) export class RdxPreviewCardDefaultComponent { protected readonly cn = cn; protected readonly p = demoPopover; protected readonly link = cn('font-medium text-primary underline underline-offset-4', demoFocusRing); } ``` ## Features - ✅ Aligns with Base UI `PreviewCard` anatomy and interaction semantics. - ✅ Uses a virtual root via `ng-container[rdxPreviewCardRoot]`, so layout is not affected. - ✅ Opens from hover or focus with per-trigger `delay` and `closeDelay`. - ✅ Supports detached triggers, multiple triggers, controlled state, and controlled trigger ids. - ✅ Supports portaled content, custom positioning, arrows, and viewport content transitions. - ✅ Exposes state, transition, placement, and anchor measurement attributes and CSS variables for styling. - ✅ Keeps hover-opened content interactive with a polygon grace area, including nested popup content. ## Import ## Anatomy Apply the parts to your own markup. The root is virtual; the portal presence wrapper keeps the content mounted while CSS exit animations finish. ```html Preview link
Preview content
``` ## Examples ### Controlled Multiple Control both the open state and the active trigger id. ```typescript import { Component } from '@angular/core'; import { previewCardImports } from '@radix-ng/primitives/preview-card'; import { cn, demoButton, demoFocusRing, demoPopover } from '../../storybook/styles'; @Component({ selector: 'rdx-preview-card-controlled-multiple', imports: [...previewCardImports], template: `

Discover typography , design , or art .

{{ root.payload() }}

` }) export class RdxPreviewCardControlledMultipleComponent { open = false; triggerId: 'typography' | 'design' | 'art' | null = null; protected readonly cn = cn; protected readonly b = demoButton; protected readonly p = demoPopover; protected readonly link = cn('font-medium text-primary underline underline-offset-4', demoFocusRing); protected readonly cards = { typography: 'Typography arranges type to make written language clear and effective.', design: 'Design shapes the concept and structure of an object, process, or system.', art: 'Art communicates ideas and emotion through creative work.' } as const; openFrom(id: 'typography' | 'design' | 'art') { this.triggerId = id; this.open = true; } } ``` ### Detached Associate triggers outside the root through a shared handle. ```typescript import { Component } from '@angular/core'; import { createRdxPreviewCardHandle, previewCardImports } from '@radix-ng/primitives/preview-card'; import { cn, demoFocusRing, demoPopover } from '../../storybook/styles'; @Component({ selector: 'rdx-preview-card-detached', imports: [...previewCardImports], template: `

Detached triggers can live outside the root: typography and design .

{{ root.payload() }}

` }) export class RdxPreviewCardDetachedComponent { protected readonly previewCard = createRdxPreviewCardHandle(); protected readonly p = demoPopover; protected readonly link = cn('font-medium text-primary underline underline-offset-4', demoFocusRing); } ``` ### Positioning Configure side, offsets, collision behavior, and arrow padding on the positioner. ```typescript import { Component, signal } from '@angular/core'; import { Side } from '@radix-ng/primitives/popper'; import { previewCardImports } from '@radix-ng/primitives/preview-card'; import { cn, demoButton, demoFocusRing, demoPopover } from '../../storybook/styles'; @Component({ selector: 'rdx-preview-card-positioning', imports: [...previewCardImports], template: `
@for (option of sides; track option) { }
Hover the positioned preview card

The positioner exposes placement attributes and CSS variables for styling.

` }) export class RdxPreviewCardPositioningComponent { protected readonly sides: Side[] = ['top', 'right', 'bottom', 'left']; protected readonly side = signal('bottom'); protected readonly cn = cn; protected readonly b = demoButton; protected readonly p = demoPopover; protected readonly link = cn('font-medium text-primary underline underline-offset-4', demoFocusRing); } ``` ### Viewport Animate content changes when different triggers render different payloads. ```typescript import { Component } from '@angular/core'; import { previewCardImports } from '@radix-ng/primitives/preview-card'; import { cn, demoFocusRing, demoPopover } from '../../storybook/styles'; @Component({ selector: 'rdx-preview-card-viewport', imports: [...previewCardImports], template: `

Compare typography , design , and art .

{{ root.payload() }}

` }) export class RdxPreviewCardViewportComponent { protected readonly cn = cn; protected readonly p = demoPopover; protected readonly link = cn('font-medium text-primary underline underline-offset-4', demoFocusRing); } ``` ## API Reference ### Root Groups all parts of the preview card. Does not render its own HTML element. ### Trigger A link or element that opens the preview card. ### Positioner Positions the popup against the active trigger or a custom anchor. ### Popup The floating preview surface. It exposes dismiss events and transition attributes. --- # Progress #### Displays task completion with accessible label, value, track, and indicator parts. ```typescript import { Component, computed, DestroyRef, inject, signal } from '@angular/core'; import { cn } from '../../storybook/styles'; import { RdxProgressIndicatorDirective } from '../src/progress-indicator.directive'; import { RdxProgressLabelDirective } from '../src/progress-label.directive'; import { RdxProgressRootDirective } from '../src/progress-root.directive'; import { RdxProgressTrackDirective } from '../src/progress-track.directive'; import { RdxProgressValueDirective } from '../src/progress-value.directive'; const progressSteps = [12, 28, 44, 60, 76, 92, 100] as const; @Component({ selector: 'progress-linear', imports: [ RdxProgressRootDirective, RdxProgressLabelDirective, RdxProgressValueDirective, RdxProgressTrackDirective, RdxProgressIndicatorDirective ], template: `
Upload progress
` }) export class ProgressLinearComponent { private readonly destroyRef = inject(DestroyRef); protected readonly progress = signal<(typeof progressSteps)[number]>(progressSteps[0]); protected readonly indicatorClass = computed(() => cn( 'h-full rounded-full bg-primary transition-all duration-700 ease-out', this.widthClass(), this.progress() === 100 && 'bg-primary/80' ) ); private readonly widthClass = computed(() => { switch (this.progress()) { case 12: return 'w-[12%]'; case 28: return 'w-[28%]'; case 44: return 'w-[44%]'; case 60: return 'w-[60%]'; case 76: return 'w-[76%]'; case 92: return 'w-[92%]'; case 100: return 'w-full'; } }); constructor() { let stepIndex = 0; const interval = window.setInterval(() => { stepIndex = (stepIndex + 1) % progressSteps.length; this.progress.set(progressSteps[stepIndex]); }, 900); this.destroyRef.onDestroy(() => window.clearInterval(interval)); } } ``` ## Features - ✅ Supports determinate and indeterminate progress. - ✅ Supports custom `min` and `max` ranges. - ✅ Provides Label and Value parts for accessible naming and value text. - ✅ Exposes state through `data-state`, `data-complete`, `data-progressing`, and `data-indeterminate`. ## Import ```typescript import { RdxProgressRootDirective, RdxProgressLabelDirective, RdxProgressValueDirective, RdxProgressTrackDirective, RdxProgressIndicatorDirective } from '@radix-ng/primitives/progress'; ``` ## Anatomy ```html
Upload progress
``` ## Examples ### Default An animated labelled progress bar with formatted value text. ```typescript import { Component, computed, DestroyRef, inject, signal } from '@angular/core'; import { cn } from '../../storybook/styles'; import { RdxProgressIndicatorDirective } from '../src/progress-indicator.directive'; import { RdxProgressLabelDirective } from '../src/progress-label.directive'; import { RdxProgressRootDirective } from '../src/progress-root.directive'; import { RdxProgressTrackDirective } from '../src/progress-track.directive'; import { RdxProgressValueDirective } from '../src/progress-value.directive'; const progressSteps = [12, 28, 44, 60, 76, 92, 100] as const; @Component({ selector: 'progress-linear', imports: [ RdxProgressRootDirective, RdxProgressLabelDirective, RdxProgressValueDirective, RdxProgressTrackDirective, RdxProgressIndicatorDirective ], template: `
Upload progress
` }) export class ProgressLinearComponent { private readonly destroyRef = inject(DestroyRef); protected readonly progress = signal<(typeof progressSteps)[number]>(progressSteps[0]); protected readonly indicatorClass = computed(() => cn( 'h-full rounded-full bg-primary transition-all duration-700 ease-out', this.widthClass(), this.progress() === 100 && 'bg-primary/80' ) ); private readonly widthClass = computed(() => { switch (this.progress()) { case 12: return 'w-[12%]'; case 28: return 'w-[28%]'; case 44: return 'w-[44%]'; case 60: return 'w-[60%]'; case 76: return 'w-[76%]'; case 92: return 'w-[92%]'; case 100: return 'w-full'; } }); constructor() { let stepIndex = 0; const interval = window.setInterval(() => { stepIndex = (stepIndex + 1) % progressSteps.length; this.progress.set(progressSteps[stepIndex]); }, 900); this.destroyRef.onDestroy(() => window.clearInterval(interval)); } } ``` ### Indeterminate Use `null` when progress is active but the current value is unknown. ```html
Preparing upload
``` ### Custom range Use `min`, `max`, and `valueLabel` when progress is not a simple 0-100 percentage. ```html
Transfer
``` ### Circular The same primitive state can drive an SVG circular progress indicator. ```typescript import { Component, computed, signal } from '@angular/core'; import { RdxProgressIndicatorDirective } from '../src/progress-indicator.directive'; import { RdxProgressLabelDirective } from '../src/progress-label.directive'; import { RdxProgressRootDirective } from '../src/progress-root.directive'; import { RdxProgressTrackDirective } from '../src/progress-track.directive'; import { RdxProgressValueDirective } from '../src/progress-value.directive'; @Component({ selector: 'progress-circular', imports: [ RdxProgressRootDirective, RdxProgressLabelDirective, RdxProgressValueDirective, RdxProgressTrackDirective, RdxProgressIndicatorDirective ], template: `
Storage used
` }) export class ProgressCircularComponent { private readonly radius = 44; private readonly circumference = 2 * Math.PI * this.radius; protected readonly progress = signal(72); protected readonly dashArray = computed( () => `${(this.progress() / 100) * this.circumference} ${this.circumference}` ); } ``` ## API Reference ### Root `RdxProgressRootDirective` ### Label `RdxProgressLabelDirective` Gives the progressbar its accessible name through `aria-labelledby`. ### Value `RdxProgressValueDirective` Displays the formatted value text and provides `aria-describedby` text for the progressbar. ### Track `RdxProgressTrackDirective` Contains the visual indicator and mirrors root state attributes. ### Indicator `RdxProgressIndicatorDirective` Displays the visual progress fill and exposes `data-percent` for styling determinate progress. ## Accessibility The root uses `role="progressbar"`, is labelled by `rdxProgressLabel`, and is described by `rdxProgressValue`. In indeterminate state, `aria-valuemin`, `aria-valuemax`, `aria-valuenow`, and `aria-valuetext` are omitted because the current value is unknown. --- # Radio Group #### A set of checkable buttons—known as radio buttons—where no more than one of the buttons can be checked at a time. ```typescript import { Component } from '@angular/core'; import { RdxLabelDirective } from '@radix-ng/primitives/label'; import { RdxRadioGroupDirective, RdxRadioIndicatorDirective, RdxRadioItemDirective } from '@radix-ng/primitives/radio'; import { demoRadio } from '../../storybook/styles'; @Component({ selector: 'radio-default-example', template: `
`, imports: [RdxLabelDirective, RdxRadioItemDirective, RdxRadioIndicatorDirective, RdxRadioGroupDirective] }) export class RadioDefaultComponent { protected readonly r = demoRadio; } ``` ## Features - ✅ Full keyboard navigation. - ✅ Supports horizontal/vertical orientation. - ✅ Can be controlled or uncontrolled. ## Import Get started with importing the directives: ```typescript import { RdxRadioGroupDirective, RdxRadioIndicatorDirective, RdxRadioItemDirective } from '@radix-ng/primitives/radio'; ``` ## Anatomy ```html
``` `RdxRadioItemDirective` creates the hidden native radio input next to the item, so form submission, native validation, and labels work without rendering an `` inside the button. Use `nativeButton` only when you render the item as a standalone ` @if (submittedRoom) {

Submitted room: {{ submittedRoom }}

} `, imports: [FormsModule, RdxLabelDirective, RdxRadioItemDirective, RdxRadioIndicatorDirective, RdxRadioGroupDirective] }) export class RadioGroupComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly r = demoRadio; hotelRoom: string | undefined; submittedRoom: string | undefined; rooms = ['Default', 'Comfortable']; submit(): void { this.submittedRoom = this.hotelRoom; } } ``` ## API Reference ### RadioGroup `RdxRadioGroupDirective` Owns the shared radio state and form metadata (`name`, `form`, `required`, `disabled`, and `readonly`). ### RadioGroupItem `RdxRadioItemDirective` ### RadioIndicator `RdxRadioIndicatorDirective` Renders only when its radio item is checked and exposes the same state attributes for styling. It has no public inputs. ## Accessibility Adheres to the [Radio Group WAI-ARIA design pattern](https://www.w3.org/WAI/ARIA/apg/patterns/radiobutton) and uses [roving tabindex](https://www.w3.org/TR/wai-aria-practices-1.2/examples/radio/radio.html) to manage focus movement among radio items. ### Keyboard Interactions | Key | Description | |--------------|------------------------------------------------------------------------------------| | `Tab` | Moves focus to either the checked radio item or the first radio item in the group. | | `Space` | When focus is on an unchecked radio item, checks it. | | `ArrowDown` | Moves focus and checks the next radio item in the group. | | `ArrowRight` | Moves focus and checks the next radio item in the group. | | `ArrowUp` | Moves focus to the previous radio item in the group. | | `ArrowLeft` | Moves focus to the previous radio item in the group. | --- # Scroll Area #### A native scroll container with a custom, cross-browser scrollbar. Headless scroll area modeled on [Base UI](https://base-ui.com/react/components/scroll-area). It keeps the browser's native scrolling — momentum, keyboard, and accessibility — while hiding the platform scrollbar and letting you style your own. It carries no styles; the scrollbars in the examples are plain Tailwind utilities driven by `data-*` attributes and CSS variables. ```html
Tags
@for (tag of tags; track tag) {
{{ tag }}
}
``` ## Features - ✅ Native scrolling preserved — keyboard, touch, momentum, and `scroll` semantics all work. - ✅ Custom scrollbar and thumb with cross-browser consistency (native scrollbar hidden). - ✅ Draggable thumb, click-to-page on the track, and wheel support on the scrollbar. - ✅ Independent vertical and horizontal scrollbars, plus a `Corner` where they meet. - ✅ Rich styling state: `data-scrolling`, `data-hovering`, `data-has-overflow-x/y`, and per-edge `data-overflow-*-start/end`. - ✅ Edge-distance CSS variables (`--scroll-area-overflow-*`) for masks and fade effects. - ✅ RTL aware and SSR safe. ## Import ```typescript import { RdxScrollAreaRoot, RdxScrollAreaViewport, RdxScrollAreaContent, RdxScrollAreaScrollbar, RdxScrollAreaThumb, RdxScrollAreaCorner } from '@radix-ng/primitives/scroll-area'; ``` Or import the module: ```typescript import { RdxScrollAreaModule } from '@radix-ng/primitives/scroll-area'; ``` ## Anatomy Assemble the scroll area from its parts. The `Viewport` is the actual scrollable element and must wrap the `Content`; each `Scrollbar` hosts a `Thumb`, and the optional `Corner` fills the intersection when both scrollbars are present. ```html
``` ## Examples The hero above shows the default vertical setup: a single scrollbar over a tall list, where the thumb is sized from the viewport-to-content ratio and the track stays hidden until the content is measured. ### Horizontal A horizontal scrollbar for a row of items that overflows on the x-axis. ```html
@for (item of items; track item) {
{{ item }}
}
``` ### Both scrollbars When content overflows on both axes, render two `Scrollbar` parts and a `Corner` to fill the gap where they meet. ```typescript import { Component } from '@angular/core'; import { RdxScrollAreaContent, RdxScrollAreaCorner, RdxScrollAreaRoot, RdxScrollAreaScrollbar, RdxScrollAreaThumb, RdxScrollAreaViewport } from '@radix-ng/primitives/scroll-area'; const html = String.raw; /** * A scroll area that overflows on both axes. The `Corner` part fills the gap where * the vertical and horizontal scrollbars meet. */ @Component({ selector: 'scroll-area-both-example', imports: [ RdxScrollAreaRoot, RdxScrollAreaViewport, RdxScrollAreaContent, RdxScrollAreaScrollbar, RdxScrollAreaThumb, RdxScrollAreaCorner ], template: html`
@for (col of columns; track col) {
@for (row of rows; track row) {
{{ col * rows.length + row + 1 }}
}
}
` }) export class ScrollAreaBothExample { readonly columns = Array.from({ length: 10 }, (_, i) => i); readonly rows = Array.from({ length: 20 }, (_, i) => i); } ``` ### Gradient scroll fade Feed the viewport's `--scroll-area-overflow-y-start` / `--scroll-area-overflow-y-end` variables (the pixel distance to each edge) into a `mask-image` gradient. Each fade disappears as you reach the matching edge. ```typescript import { Component } from '@angular/core'; import { RdxScrollAreaContent, RdxScrollAreaRoot, RdxScrollAreaScrollbar, RdxScrollAreaThumb, RdxScrollAreaViewport } from '@radix-ng/primitives/scroll-area'; const html = String.raw; /** * Fades the content near the scroll edges by feeding the viewport's * `--scroll-area-overflow-y-start` / `--scroll-area-overflow-y-end` CSS variables * (the distance in px to each edge) into a `mask-image` gradient. The fade * disappears once you reach an edge because the matching variable becomes `0px`. */ @Component({ selector: 'scroll-area-gradient-example', imports: [ RdxScrollAreaRoot, RdxScrollAreaViewport, RdxScrollAreaContent, RdxScrollAreaScrollbar, RdxScrollAreaThumb ], template: html`
@for (paragraph of paragraphs; track $index) {

{{ paragraph }}

}
` }) export class ScrollAreaGradientExample { readonly paragraphs = [ 'Scroll areas keep the native scroll behavior — momentum, keyboard, and accessibility — while letting you style a custom scrollbar.', 'The viewport exposes the distance to each edge as a CSS variable, so effects like this gradient fade react to the exact scroll position.', 'Because the variables report pixels-from-edge, the top fade vanishes when you reach the top and the bottom fade vanishes at the very end.', 'The scrollbar and thumb are headless: every visual decision here is a Tailwind utility, driven only by the data attributes and CSS variables.', 'Try scrolling to the middle — both gradients are visible. Scroll to either end and watch the corresponding fade disappear.', 'Everything you see is composed from Root, Viewport, Content, Scrollbar, and Thumb. No styles ship inside the primitive itself.' ]; } ``` ### Combining with Tabs Stack `rdxTabsList` and `rdxScrollAreaViewport` on the same element so the tab list _is_ the scrollable viewport (the Angular equivalent of Base UI's `render` prop). Keeping the scroll state on that element lets a horizontal `mask-image` fade the tabs at whichever edge still has more to reveal. ```typescript import { Component } from '@angular/core'; import { RdxScrollAreaRoot, RdxScrollAreaViewport } from '@radix-ng/primitives/scroll-area'; import { RdxTabsIndicator, RdxTabsList, RdxTabsPanel, RdxTabsRoot, RdxTabsTab } from '@radix-ng/primitives/tabs'; const html = String.raw; /** * Combining Scroll Area with Tabs. The Angular equivalent of Base UI's `render` prop is stacking * both directives on a single element: `rdxTabsList` + `rdxScrollAreaViewport` make the tab list * itself the scrollable viewport. Because the scroll state (and the `--scroll-area-overflow-x-*` * variables) live on that same element, a horizontal `mask-image` gradient fades the tabs at * whichever edge still has more tabs to reveal. */ @Component({ selector: 'scroll-area-tabs-example', imports: [ RdxScrollAreaRoot, RdxScrollAreaViewport, RdxTabsRoot, RdxTabsList, RdxTabsTab, RdxTabsPanel, RdxTabsIndicator ], template: html`
@for (tab of tabs; track tab.value) { }
@for (tab of tabs; track tab.value) {
{{ tab.content }}
}
` }) export class ScrollAreaTabsExample { readonly tabs = [ { value: 'overview', label: 'Overview', content: 'A high-level summary of your workspace.' }, { value: 'activity', label: 'Activity', content: 'Everything that happened recently.' }, { value: 'settings', label: 'Settings', content: 'Preferences for this workspace.' }, { value: 'members', label: 'Members', content: 'People with access to the workspace.' }, { value: 'integrations', label: 'Integrations', content: 'Connect third-party services.' }, { value: 'billing', label: 'Billing', content: 'Plan, invoices, and payment methods.' }, { value: 'security', label: 'Security', content: 'Authentication and audit logs.' }, { value: 'notifications', label: 'Notifications', content: 'Choose what you get notified about.' } ]; } ``` ## API Reference ### Root Groups all parts of the scroll area and owns the shared scroll/overflow state. Exposes `data-scrolling`, `data-has-overflow-x/y`, and `data-overflow-x/y-start/end`, plus the `--scroll-area-corner-width/height` CSS variables. ### Viewport The actual scrollable container (`overflow: scroll` with the native scrollbar hidden). Wrap your `Content` in it. Sets the `--scroll-area-overflow-*` edge-distance variables. Reads everything from context — no inputs. ### Content A wrapper around the scrollable content that observes size changes to keep the thumb in sync. Reads everything from context — no inputs. ### Scrollbar A vertical or horizontal scrollbar track. Hosts the `Thumb` and exposes `data-orientation`, `data-hovering`, `data-scrolling`, and the overflow attributes, plus `--scroll-area-thumb-width/height`. ### Thumb The draggable indicator inside a `Scrollbar`. Exposes `data-orientation`. Reads everything from context — no inputs. ### Corner A small box at the intersection of the two scrollbars; sized automatically and hidden when either scrollbar is hidden. Reads everything from context — no inputs. --- # Select #### A control that presents a list of options for the user to pick from, triggered by a button. Select is headless — it ships no styles and exposes state via `data-*` attributes so you can style it with any approach. Two positioning modes are available: **Popper** (Floating UI, anchored below the trigger) and **Item-aligned** (the popup overlaps the trigger, aligned to the selected item, matching native ``. Use `rdxSelectItemAlignedPosition` and `rdxSelectItemAlignedPositionContent` instead of the Popper wrappers. ```html
Apple
``` ## Examples ### Default A grouped list (Fruits / Vegetables) with Popper positioning. Click the trigger to open the popup. ```typescript import { Component, input } from '@angular/core'; import { LucideCheck, LucideChevronDown } from '@lucide/angular'; import { Align } from '@radix-ng/primitives/popper'; import { RdxSelectContent } from '../src/select-content'; import { RdxSelectGroup } from '../src/select-group'; import { RdxSelectItem } from '../src/select-item'; import { RdxSelectItemIndicator } from '../src/select-item-indicator'; import { RdxSelectItemText } from '../src/select-item-text'; import { RdxSelectLabel } from '../src/select-label'; import { RdxSelectPopperPositionContent } from '../src/select-popper-position-content'; import { RdxSelectPopperPositionWrapper } from '../src/select-popper-position-wrapper'; import { RdxSelectPortal } from '../src/select-portal'; import { RdxSelectPortalPresence } from '../src/select-portal-presence'; import { RdxSelectRoot } from '../src/select-root'; import { RdxSelectTrigger } from '../src/select-trigger'; import { RdxSelectValue } from '../src/select-value'; import { RdxSelectViewport } from '../src/select-viewport'; @Component({ selector: 'select-default', imports: [ RdxSelectRoot, RdxSelectPortal, RdxSelectTrigger, RdxSelectValue, RdxSelectPortalPresence, RdxSelectContent, RdxSelectViewport, LucideChevronDown, LucideCheck, RdxSelectItem, RdxSelectLabel, RdxSelectGroup, RdxSelectPopperPositionWrapper, RdxSelectPopperPositionContent, RdxSelectItemText, RdxSelectItemIndicator ], template: `
Fruits
@for (option of options; track option) {
{{ option }}
}
Vegetables
@for (vegetable of vegetables; track vegetable) {
{{ vegetable }}
}
` }) export class SelectDefault { readonly sideOffset = input(5); readonly align = input('start'); readonly options = ['Apple', 'Banana', 'Blueberry', 'Grapes', 'Pineapple']; readonly vegetables = ['Aubergine', 'Broccoli', 'Carrot', 'Courgette', 'Leek']; } ``` ### With scroll buttons When the list is taller than the available viewport, `rdxSelectScrollUpButton` and `rdxSelectScrollDownButton` appear automatically at the top and bottom of the popup. ```typescript import { Component } from '@angular/core'; import { LucideCheck, LucideChevronDown, LucideChevronUp } from '@lucide/angular'; import { RdxSelectContent } from '../src/select-content'; import { RdxSelectGroup } from '../src/select-group'; import { RdxSelectItem } from '../src/select-item'; import { RdxSelectItemIndicator } from '../src/select-item-indicator'; import { RdxSelectItemText } from '../src/select-item-text'; import { RdxSelectLabel } from '../src/select-label'; import { RdxSelectPopperPositionContent } from '../src/select-popper-position-content'; import { RdxSelectPopperPositionWrapper } from '../src/select-popper-position-wrapper'; import { RdxSelectPortal } from '../src/select-portal'; import { RdxSelectPortalPresence } from '../src/select-portal-presence'; import { RdxSelectRoot } from '../src/select-root'; import { RdxSelectScrollDownButton } from '../src/select-scroll-down-button'; import { RdxSelectScrollUpButton } from '../src/select-scroll-up-button'; import { RdxSelectTrigger } from '../src/select-trigger'; import { RdxSelectValue } from '../src/select-value'; import { RdxSelectViewport } from '../src/select-viewport'; @Component({ selector: 'select-with-scroll', imports: [ RdxSelectRoot, RdxSelectPortal, RdxSelectTrigger, RdxSelectValue, RdxSelectPortalPresence, RdxSelectContent, RdxSelectViewport, LucideChevronDown, LucideChevronUp, LucideCheck, RdxSelectItem, RdxSelectLabel, RdxSelectGroup, RdxSelectPopperPositionWrapper, RdxSelectPopperPositionContent, RdxSelectItemText, RdxSelectItemIndicator, RdxSelectScrollUpButton, RdxSelectScrollDownButton ], template: `
Fruits
@for (option of options; track option) {
{{ option }}
}
Vegetables
@for (vegetable of vegetables; track vegetable) {
{{ vegetable }}
}
` }) export class SelectWithScroll { readonly options = ['Apple', 'Banana', 'Blueberry', 'Grapes', 'Pineapple']; readonly vegetables = [ 'Aubergine', 'Broccoli', 'Carrot', 'Courgette', 'Leek', 'Aubergine 2', 'Broccoli 2', 'Carrot 2', 'Courgette 2', 'Leek 2' ]; } ``` ### Item-aligned positioning The popup opens aligned to the selected item, mirroring native `
` }) export class SliderDefaultExample {} ``` ## Features - ✅ Can be controlled or uncontrolled. - ✅ Single value or multiple thumbs for a range. - ✅ Configurable thumb collision behavior (`push`, `swap`, `none`). - ✅ Minimum distance between thumbs. - ✅ Press or drag anywhere on the control to update the value. - ✅ Horizontal and vertical orientation, with RTL support. - ✅ Value formatting with `Intl.NumberFormat`. - ✅ Full keyboard navigation, including large steps. - ✅ Works with Angular forms via a hidden native range input per thumb. ## Import ```ts import { RdxSliderRoot, RdxSliderControl, RdxSliderTrack, RdxSliderIndicator, RdxSliderThumb, RdxSliderThumbInput, RdxSliderValue } from '@radix-ng/primitives/slider'; ``` Or import the whole module: ```ts import { RdxSliderModule } from '@radix-ng/primitives/slider'; ``` ## Anatomy The slider is assembled from directive-based parts — there are no separate horizontal/vertical components. The same parts drive both orientations, switched through the `orientation` input on the root. ```html
``` Each thumb owns a nested `input[rdxSliderThumbInput]`. It is visually hidden but remains the focusable element that powers keyboard interaction, accessibility and form submission. ## Examples ### Range Pass an array value and render one `rdxSliderThumb` per value, each with an explicit `index`. Use `minStepsBetweenValues` to keep the thumbs apart, and `thumbCollisionBehavior` to control what happens when they meet. ```typescript import { Component } from '@angular/core'; import { RdxSliderControl, RdxSliderIndicator, RdxSliderRoot, RdxSliderThumb, RdxSliderThumbInput, RdxSliderTrack } from '@radix-ng/primitives/slider'; @Component({ selector: 'slider-range-example', imports: [RdxSliderRoot, RdxSliderControl, RdxSliderTrack, RdxSliderIndicator, RdxSliderThumb, RdxSliderThumbInput], template: `
` }) export class SliderRangeExample {} ``` ### Vertical Set `orientation="vertical"` on the root. The control and track lay out along the vertical axis; no other changes are required. ```typescript import { Component } from '@angular/core'; import { RdxSliderControl, RdxSliderIndicator, RdxSliderRoot, RdxSliderThumb, RdxSliderThumbInput, RdxSliderTrack } from '@radix-ng/primitives/slider'; @Component({ selector: 'slider-vertical-example', imports: [RdxSliderRoot, RdxSliderControl, RdxSliderTrack, RdxSliderIndicator, RdxSliderThumb, RdxSliderThumbInput], template: `
` }) export class SliderVerticalExample {} ``` ### Value Display the formatted value with `rdxSliderValue`. Formatting honours the root's `format` (`Intl.NumberFormatOptions`) and `locale`. ```typescript import { Component } from '@angular/core'; import { RdxSliderControl, RdxSliderIndicator, RdxSliderRoot, RdxSliderThumb, RdxSliderThumbInput, RdxSliderTrack, RdxSliderValue } from '@radix-ng/primitives/slider'; @Component({ selector: 'slider-value-example', imports: [ RdxSliderRoot, RdxSliderControl, RdxSliderTrack, RdxSliderIndicator, RdxSliderThumb, RdxSliderThumbInput, RdxSliderValue ], template: `
Budget
` }) export class SliderValueExample { readonly format: Intl.NumberFormatOptions = { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }; } ``` ### Disabled ```typescript import { Component } from '@angular/core'; import { RdxSliderControl, RdxSliderIndicator, RdxSliderRoot, RdxSliderThumb, RdxSliderThumbInput, RdxSliderTrack } from '@radix-ng/primitives/slider'; @Component({ selector: 'slider-disabled-example', imports: [RdxSliderRoot, RdxSliderControl, RdxSliderTrack, RdxSliderIndicator, RdxSliderThumb, RdxSliderThumbInput], template: `
` }) export class SliderDisabledExample {} ``` ### Reactive forms The root composes a `ControlValueAccessor`, so it binds directly to `formControl` / `formControlName` and `[(ngModel)]`. ```typescript import { Component } from '@angular/core'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { RdxSliderControl, RdxSliderIndicator, RdxSliderRoot, RdxSliderThumb, RdxSliderThumbInput, RdxSliderTrack } from '@radix-ng/primitives/slider'; @Component({ selector: 'slider-forms-example', imports: [ ReactiveFormsModule, RdxSliderRoot, RdxSliderControl, RdxSliderTrack, RdxSliderIndicator, RdxSliderThumb, RdxSliderThumbInput ], template: `

Value: {{ volume.value }}

` }) export class SliderFormsExample { readonly volume = new FormControl(30); } ``` ## API Reference ### Root `RdxSliderRoot` — groups the parts and owns the value, state and thumb registration. | Data attribute | Value | | -------------------- | ------------------------------ | | `[data-orientation]` | `"horizontal" \| "vertical"` | | `[data-disabled]` | Present when disabled. | | `[data-dragging]` | Present while a thumb is dragged. | ### Control `RdxSliderControl` — the interactive area; reads everything from context, no inputs. | Data attribute | Value | | -------------------- | ------------------------------ | | `[data-orientation]` | `"horizontal" \| "vertical"` | | `[data-disabled]` | Present when disabled. | | `[data-dragging]` | Present while a thumb is dragged. | ### Track `RdxSliderTrack` — the rail; reads everything from context, no inputs. | Data attribute | Value | | -------------------- | ------------------------------ | | `[data-orientation]` | `"horizontal" \| "vertical"` | | `[data-disabled]` | Present when disabled. | | `[data-dragging]` | Present while a thumb is dragged. | ### Indicator `RdxSliderIndicator` — the filled range; reads everything from context, no inputs. | Data attribute | Value | | -------------------- | ------------------------------ | | `[data-orientation]` | `"horizontal" \| "vertical"` | | `[data-disabled]` | Present when disabled. | | `[data-dragging]` | Present while a thumb is dragged. | ### Thumb `RdxSliderThumb` — a draggable handle; wrap an `input[rdxSliderThumbInput]` inside it. | Data attribute | Value | | -------------------- | ------------------------------ | | `[data-index]` | Numeric index of the thumb. | | `[data-orientation]` | `"horizontal" \| "vertical"` | | `[data-disabled]` | Present when the thumb is disabled. | | `[data-dragging]` | Present while a thumb is dragged. | ### Thumb Input `RdxSliderThumbInput` — the nested native `input[type=range]` that drives keyboard, a11y and forms. | Data attribute | Value | | -------------- | --------------------------- | | `[data-index]` | Numeric index of the thumb. | ### Value `RdxSliderValue` — displays the formatted value(s). ## Accessibility Adheres to the [Slider WAI-ARIA design pattern](https://www.w3.org/WAI/ARIA/apg/patterns/slider-multithumb). --- # Stepper #### A set of steps that are used to indicate progress through a multi-step process. ```html
@for (item of steps; track $index) {
@if (item.step !== steps[steps.length - 1].step) {
}

{{ item.title }}

{{ item.description }}

}
``` ## Features - ✅ Can be controlled or uncontrolled. - ✅ Supports horizontal/vertical orientation. - ✅ Supports linear/non-linear activation. - ✅ Full keyboard navigation. ## Anatomy Import all parts and piece them together. ```html

``` ## API Reference ### Root `RdxStepperRootDirective` ### Item `RdxStepperItemDirective` The step item component. ## Examples ### Vertical ```html
@for (item of steps; track $index) {
@if (item.step !== steps[steps.length - 1].step) {
}

{{ item.title }}

{{ item.description }}

}
``` ### Navigation ```html ``` --- # Switch #### A control that allows the user to toggle between checked and not checked. ```typescript import { Component } from '@angular/core'; import { RdxLabelDirective } from '@radix-ng/primitives/label'; import { RdxSwitchRoot, RdxSwitchThumb } from '@radix-ng/primitives/switch'; @Component({ selector: 'switch-default-example', imports: [RdxLabelDirective, RdxSwitchRoot, RdxSwitchThumb], template: ` ` }) export class SwitchDefaultExample {} ``` ## Features - ✅ Can be controlled or uncontrolled. - ✅ Full keyboard navigation. - ✅ Supports disabled, read-only and required states. - ✅ Hidden native input for form submission and screen readers. ## Import ```typescript import { RdxSwitchRoot, RdxSwitchThumb, RdxSwitchInput } from '@radix-ng/primitives/switch'; ``` The API follows [Base UI Switch](https://base-ui.com/react/components/switch): a `Root` with a `Thumb`, plus an optional hidden `Input` for native form submission. ## Anatomy ```html ``` The `[rdxSwitchInput]` is optional — include it when the switch must submit a value with a native form. ## Examples ### Preselection Set `defaultChecked` (uncontrolled) or `[checked]` (controlled) to start in the on state. ```typescript import { Component, signal } from '@angular/core'; import { RdxLabelDirective } from '@radix-ng/primitives/label'; import { RdxSwitchInput, RdxSwitchRoot, RdxSwitchThumb } from '@radix-ng/primitives/switch'; @Component({ selector: 'switch-preselection-example', imports: [RdxLabelDirective, RdxSwitchRoot, RdxSwitchInput, RdxSwitchThumb], template: ` ` }) export class SwitchPreselectionExample { readonly checked = signal(true); } ``` ### Disabled When `disabled` is present the switch cannot be focused or toggled. ```typescript import { Component } from '@angular/core'; import { RdxLabelDirective } from '@radix-ng/primitives/label'; import { RdxSwitchInput, RdxSwitchRoot, RdxSwitchThumb } from '@radix-ng/primitives/switch'; @Component({ selector: 'switch-disabled-example', imports: [RdxLabelDirective, RdxSwitchRoot, RdxSwitchInput, RdxSwitchThumb], template: ` ` }) export class SwitchDisabledExample {} ``` ### Read-only A `readonly` switch is focusable and announced, but cannot be toggled. ```typescript import { Component } from '@angular/core'; import { RdxLabelDirective } from '@radix-ng/primitives/label'; import { RdxSwitchInput, RdxSwitchRoot, RdxSwitchThumb } from '@radix-ng/primitives/switch'; @Component({ selector: 'switch-readonly-example', imports: [RdxLabelDirective, RdxSwitchRoot, RdxSwitchInput, RdxSwitchThumb], template: ` ` }) export class SwitchReadonlyExample {} ``` ### Reactive Forms Bind the switch to a form control via `formControlName`. ```typescript import { Component, OnInit } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { RdxLabelDirective } from '@radix-ng/primitives/label'; import { RdxSwitchInput, RdxSwitchRoot, RdxSwitchThumb } from '@radix-ng/primitives/switch'; @Component({ selector: 'switch-reactive-forms', imports: [ReactiveFormsModule, RdxLabelDirective, RdxSwitchRoot, RdxSwitchInput, RdxSwitchThumb], template: `

` }) export class SwitchReactiveForms implements OnInit { formGroup!: FormGroup; ngOnInit() { this.formGroup = new FormGroup({ policy: new FormControl(true) }); } onSubmit(): void { console.log(this.formGroup.value); } setValue() { this.formGroup.setValue({ policy: false }); } } ``` ## API Reference ### Root `RdxSwitchRoot` | Data attribute | Value | | ------------------ | ------------------------------ | | `[data-checked]` | Present when the switch is on. | | `[data-unchecked]` | Present when the switch is off.| | `[data-disabled]` | Present when disabled. | | `[data-readonly]` | Present when read-only. | | `[data-required]` | Present when required. | ### Thumb `RdxSwitchThumb` — reads everything from context; no inputs. Exposes `[data-checked]`, `[data-unchecked]`, `[data-disabled]`, `[data-readonly]`. ### Input `RdxSwitchInput` — the hidden native checkbox; reads everything from context. Carries `name`/`value` for form submission. ## Accessibility Adheres to the [`switch` role requirements](https://www.w3.org/WAI/ARIA/apg/patterns/switch). ### Keyboard Interactions | Key | Description | | ------- | ------------------------------ | | `Space` | Toggles the component's state. | | `Enter` | Toggles the component's state. | --- # Tabs #### A set of layered sections of content—known as tab panels—that are displayed one at a time. ```html
Make changes to your account here. Click save when you're done.
Change your password here. After saving, you'll be logged out.
``` ## Features - ✅ Can be controlled or uncontrolled. - ✅ Supports horizontal and vertical orientation. - ✅ Supports automatic (on focus) and manual activation. - ✅ Full keyboard navigation with roving focus. - ✅ Optional active-tab indicator driven by CSS variables. - ✅ Panels can stay mounted (and animate) or unmount when inactive. ## Import ```typescript import { RdxTabsRoot, RdxTabsList, RdxTabsTab, RdxTabsPanel, RdxTabsPanelPresence, RdxTabsIndicator } from '@radix-ng/primitives/tabs'; ``` The aligned API follows [Base UI Tabs](https://base-ui.com/react/components/tabs): `Root` → `List` → `Tab`, plus a `Panel` per value and an optional `Indicator`. ## Anatomy ```html
``` ## Examples ### Activate on focus Set `activateOnFocus` on the list to activate a tab as soon as it receives keyboard focus (automatic activation). By default activation is manual — tabs activate on click or `Enter` / `Space`. ```html
Tabs activate as soon as they receive keyboard focus.
Change your password here. After saving, you'll be logged out.
Invite teammates and manage their roles.
``` ### Vertical Set `orientation="vertical"` on the root to lay the tabs out vertically and switch arrow-key navigation to `ArrowUp` / `ArrowDown`. ```html
Make changes to your account here. Click save when you're done.
Change your password here. After saving, you'll be logged out.
Invite teammates and manage their roles.
``` ### Disabled A tab with the `disabled` attribute cannot be focused or activated. ```html
The Password tab is disabled and cannot be focused or activated.
Change your password here. After saving, you'll be logged out.
Invite teammates and manage their roles.
``` ### Indicator `RdxTabsIndicator` exposes the active tab's geometry as CSS variables, so a single element can animate to follow the selected tab. ```typescript import { Component } from '@angular/core'; import { RdxTabsIndicator, RdxTabsList, RdxTabsPanel, RdxTabsRoot, RdxTabsTab } from '@radix-ng/primitives/tabs'; @Component({ selector: 'tabs-indicator-example', imports: [RdxTabsRoot, RdxTabsList, RdxTabsTab, RdxTabsPanel, RdxTabsIndicator], template: `
A high-level summary of your workspace and recent highlights.
Everything that happened recently, in reverse chronological order.
Configure preferences, members, and integrations for this workspace.
` }) export class TabsIndicatorExample {} ``` ### Animated panels Panels stay mounted by default, so you can cross-fade them with the `data-starting-style`, `data-ending-style` and `data-hidden` attributes — the panel keeps itself visible until the transition finishes. ```typescript import { Component } from '@angular/core'; import { RdxTabsList, RdxTabsPanel, RdxTabsRoot, RdxTabsTab } from '@radix-ng/primitives/tabs'; @Component({ selector: 'tabs-animated-example', imports: [RdxTabsRoot, RdxTabsList, RdxTabsTab, RdxTabsPanel], // Panels stay mounted and cross-fade using the transition-status data attributes: // `data-starting-style` (enter), `data-ending-style` (exit) and `data-hidden` drive the opacity, // while the panel keeps itself visible until the transition finishes. template: `
Make changes to your account here. Click save when you're done.
Change your password here. After saving, you'll be logged out.
` }) export class TabsAnimatedExample {} ``` ### Mounting & unmounting By default an inactive `rdxTabsPanel` stays in the DOM and is toggled with the `hidden` attribute. To unmount the contents while inactive (Base UI's default `keepMounted: false`), nest a `*rdxTabsPanelPresence` inside the panel — it mounts the contents only while the tab is active and waits for any exit `@keyframes` to finish before removing them. Set `keepMounted` on the panel to keep the contents mounted regardless. ```html
Panel 1
Panel 2
``` ### Unmount with `@keyframes` With `*rdxTabsPanelPresence`, the presence directive waits for the contents' exit **animation** (`@keyframes`, detected via `animationend`) before removing them. Mark the panel as a `group` so the inner element can react to the parent's `data-hidden` and run the exit keyframes — the contents leave the DOM once the animation finishes. ```typescript import { Component } from '@angular/core'; import { RdxTabsList, RdxTabsPanel, RdxTabsPanelPresence, RdxTabsRoot, RdxTabsTab } from '@radix-ng/primitives/tabs'; @Component({ selector: 'tabs-keyframes-example', imports: [RdxTabsRoot, RdxTabsList, RdxTabsTab, RdxTabsPanel, RdxTabsPanelPresence], // The panel contents are mounted only while the tab is active (`*rdxTabsPanelPresence`). // On enter the inner element plays `tabs-panel-in`; when the tab is left, the parent panel gets // `data-hidden`, so `group-data-[hidden]:animate-tabs-panel-out` runs the exit `@keyframes` and // the presence directive waits for `animationend` before unmounting. template: `
Make changes to your account here. Click save when you're done.
Change your password here. After saving, you'll be logged out.
` }) export class TabsKeyframesExample {} ``` ## API Reference ### TabsRoot `RdxTabsRoot` — groups the tabs and their panels. | Data attribute | Value | | --------------------------- | ----------------------------------------------- | | `[data-orientation]` | `"horizontal" \| "vertical"` | | `[data-activation-direction]` | `"left" \| "right" \| "up" \| "down" \| "none"` | ### TabsList `RdxTabsList` — groups the tab buttons and owns roving keyboard focus. | Data attribute | Value | | ----------------------------- | ----------------------------------------------- | | `[data-orientation]` | `"horizontal" \| "vertical"` | | `[data-activation-direction]` | `"left" \| "right" \| "up" \| "down" \| "none"` | ### TabsTab `RdxTabsTab` — an interactive button that activates its panel. | Data attribute | Value | | ----------------------------- | ----------------------------------------------- | | `[data-active]` | Present when the tab is active. | | `[data-disabled]` | Present when the tab is disabled. | | `[data-orientation]` | `"horizontal" \| "vertical"` | | `[data-activation-direction]` | `"left" \| "right" \| "up" \| "down" \| "none"` | ### TabsPanel `RdxTabsPanel` — content shown when its tab is active. | Data attribute | Value | | ----------------------------- | ----------------------------------------------- | | `[data-hidden]` | Present when the panel is inactive. | | `[data-index]` | Numeric index of the panel. | | `[data-starting-style]` | Present while the enter transition runs. | | `[data-ending-style]` | Present while the exit transition runs. | | `[data-orientation]` | `"horizontal" \| "vertical"` | | `[data-activation-direction]` | `"left" \| "right" \| "up" \| "down" \| "none"` | ### TabsPanelPresence `*rdxTabsPanelPresence` — a structural directive placed inside an `rdxTabsPanel` that mounts the panel contents only while active and unmounts them after the exit animation. It has no inputs; mount state is read from the parent panel. ### TabsIndicator `RdxTabsIndicator` — reads everything from context; it has no inputs. It exposes the following CSS variables on its host element: | CSS variable | Description | | ---------------------- | ------------------------------------ | | `--active-tab-top` | Distance from the top of the list. | | `--active-tab-right` | Distance from the right of the list. | | `--active-tab-bottom` | Distance from the bottom of the list.| | `--active-tab-left` | Distance from the left of the list. | | `--active-tab-width` | Width of the active tab. | | `--active-tab-height` | Height of the active tab. | ## Accessibility Adheres to the [Tabs WAI-ARIA design pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs). ### Keyboard Interactions | Key | Description | | ------------ | --------------------------------------------------------------------------------------------------- | | `Tab` | Moves focus onto the active tab. From a tab, moves focus to the active panel. | | `ArrowDown` | (Vertical) Moves focus to the next tab. | | `ArrowRight` | (Horizontal) Moves focus to the next tab. | | `ArrowUp` | (Vertical) Moves focus to the previous tab. | | `ArrowLeft` | (Horizontal) Moves focus to the previous tab. | | `Home` | Moves focus to the first tab. | | `End` | Moves focus to the last tab. | | `Enter` / `Space` | Activates the focused tab (always, and the only way to activate when `activateOnFocus` is off). | --- # Time Field #### A segmented time input that lets users enter a time hour-by-hour with full keyboard control and localization. ```typescript import { Component } from '@angular/core'; import { RdxTimeFieldInputDirective, RdxTimeFieldRootDirective } from '@radix-ng/primitives/time-field'; import { RdxVisuallyHiddenInputDirective } from '@radix-ng/primitives/visually-hidden'; @Component({ selector: 'time-field-default-example', imports: [RdxTimeFieldRootDirective, RdxTimeFieldInputDirective, RdxVisuallyHiddenInputDirective], template: `
@for (item of root.segmentContents(); track $index) { @if (item.part === 'literal') { {{ item.value }} } @else {
{{ item.value }}
} }
` }) export class TimeFieldDefaultExample {} ``` ## Features - ✅ Editable, individually focusable segments (hour, minute, second, day period). - ✅ Full keyboard navigation — arrow keys move between and increment/decrement segments. - ✅ Controlled or uncontrolled value via the `value` model. - ✅ 12- and 24-hour cycles with an automatic day-period segment. - ✅ Configurable `granularity` (`hour` / `minute` / `second`). - ✅ `minValue` / `maxValue` range validation exposed through `data-invalid`. - ✅ Locale-aware formatting (segment order, separators, numbering system). - ✅ Headless and accessible — state is published via `data-*` attributes for you to style. > Time Field is a Radix NG addition — it has no Base UI counterpart — but it follows the same > headless, signals-first, `data-*`-driven conventions as the rest of the library. ## Preface The component builds on the [@internationalized/date](https://react-spectrum.adobe.com/internationalized/date/index.html) package, which solves a lot of the problems that come with working with dates and times in JavaScript. Values are represented as `Time` / `CalendarDateTime` objects from that package, so you'll need to install it to use the time-related primitives. ## Installation Install the date package. ```bash npm install @internationalized/date ``` Install the component from your command line. ```bash npm install @radix-ng/primitives ``` ## Import ```ts import { RdxTimeFieldRootDirective, RdxTimeFieldInputDirective } from '@radix-ng/primitives/time-field'; import { RdxVisuallyHiddenInputDirective } from '@radix-ng/primitives/visually-hidden'; ``` ## Anatomy Assemble the field from the root, an input segment rendered per `segmentContents()` entry, and an optional visually-hidden input for native form participation. ```html
@for (item of root.segmentContents(); track $index) {
{{ item.value }}
}
``` ## Examples ### Default A basic field rendering hour and minute segments. ```typescript import { Component } from '@angular/core'; import { RdxTimeFieldInputDirective, RdxTimeFieldRootDirective } from '@radix-ng/primitives/time-field'; import { RdxVisuallyHiddenInputDirective } from '@radix-ng/primitives/visually-hidden'; @Component({ selector: 'time-field-default-example', imports: [RdxTimeFieldRootDirective, RdxTimeFieldInputDirective, RdxVisuallyHiddenInputDirective], template: `
@for (item of root.segmentContents(); track $index) { @if (item.part === 'literal') { {{ item.value }} } @else {
{{ item.value }}
} }
` }) export class TimeFieldDefaultExample {} ``` ### Hour cycle `hourCycle` forces a 12-hour (with a day-period segment) or 24-hour clock regardless of the locale default. ```typescript import { Component } from '@angular/core'; import { RdxTimeFieldInputDirective, RdxTimeFieldRootDirective } from '@radix-ng/primitives/time-field'; import { RdxVisuallyHiddenInputDirective } from '@radix-ng/primitives/visually-hidden'; @Component({ selector: 'time-field-hour-cycle-example', imports: [RdxTimeFieldRootDirective, RdxTimeFieldInputDirective, RdxVisuallyHiddenInputDirective], template: `
@for (item of h12.segmentContents(); track $index) { @if (item.part === 'literal') { {{ item.value }} } @else {
{{ item.value }}
} }
@for (item of h24.segmentContents(); track $index) { @if (item.part === 'literal') { {{ item.value }} } @else {
{{ item.value }}
} }
` }) export class TimeFieldHourCycleExample {} ``` ### Granularity `granularity` controls the smallest editable segment — `hour`, `minute`, or `second`. ```typescript import { Component } from '@angular/core'; import { Granularity } from '@radix-ng/primitives/core'; import { RdxTimeFieldInputDirective, RdxTimeFieldRootDirective } from '@radix-ng/primitives/time-field'; import { RdxVisuallyHiddenInputDirective } from '@radix-ng/primitives/visually-hidden'; @Component({ selector: 'time-field-granularity-example', imports: [RdxTimeFieldRootDirective, RdxTimeFieldInputDirective, RdxVisuallyHiddenInputDirective], template: `
@for (granularity of granularities; track granularity) {
@for (item of root.segmentContents(); track $index) { @if (item.part === 'literal') { {{ item.value }} } @else {
{{ item.value }}
} }
}
` }) export class TimeFieldGranularityExample { protected readonly granularities: Granularity[] = ['hour', 'minute', 'second']; } ``` ### Disabled A disabled field is inert and exposes `data-disabled` for styling. ```typescript import { Component } from '@angular/core'; import { Time } from '@internationalized/date'; import { RdxTimeFieldInputDirective, RdxTimeFieldRootDirective } from '@radix-ng/primitives/time-field'; import { RdxVisuallyHiddenInputDirective } from '@radix-ng/primitives/visually-hidden'; @Component({ selector: 'time-field-disabled-example', imports: [RdxTimeFieldRootDirective, RdxTimeFieldInputDirective, RdxVisuallyHiddenInputDirective], template: `
@for (item of root.segmentContents(); track $index) { @if (item.part === 'literal') { {{ item.value }} } @else {
{{ item.value }}
} }
` }) export class TimeFieldDisabledExample { protected readonly value = new Time(12, 30); } ``` ### Readonly A readonly field can be focused and navigated but not edited. ```typescript import { Component } from '@angular/core'; import { Time } from '@internationalized/date'; import { RdxTimeFieldInputDirective, RdxTimeFieldRootDirective } from '@radix-ng/primitives/time-field'; import { RdxVisuallyHiddenInputDirective } from '@radix-ng/primitives/visually-hidden'; @Component({ selector: 'time-field-readonly-example', imports: [RdxTimeFieldRootDirective, RdxTimeFieldInputDirective, RdxVisuallyHiddenInputDirective], template: `
@for (item of root.segmentContents(); track $index) { @if (item.part === 'literal') { {{ item.value }} } @else {
{{ item.value }}
} }
` }) export class TimeFieldReadonlyExample { protected readonly value = new Time(9, 15, 0); } ``` ### Validation `minValue` / `maxValue` mark out-of-range values with `data-invalid` on the root and each segment. ```typescript import { Component } from '@angular/core'; import { Time } from '@internationalized/date'; import { RdxTimeFieldInputDirective, RdxTimeFieldRootDirective } from '@radix-ng/primitives/time-field'; import { RdxVisuallyHiddenInputDirective } from '@radix-ng/primitives/visually-hidden'; @Component({ selector: 'time-field-validation-example', imports: [RdxTimeFieldRootDirective, RdxTimeFieldInputDirective, RdxVisuallyHiddenInputDirective], template: `
@for (item of root.segmentContents(); track $index) { @if (item.part === 'literal') { {{ item.value }} } @else {
{{ item.value }}
} }
@if (root.isInvalid()) {

Please pick a time between 09:00 and 17:00.

}
` }) export class TimeFieldValidationExample { protected readonly value = new Time(18, 30); protected readonly minValue = new Time(9, 0); protected readonly maxValue = new Time(17, 0); } ``` ### Localization `locale` drives segment order, separators, and the numbering system. ```typescript import { Component } from '@angular/core'; import { RdxTimeFieldInputDirective, RdxTimeFieldRootDirective } from '@radix-ng/primitives/time-field'; import { RdxVisuallyHiddenInputDirective } from '@radix-ng/primitives/visually-hidden'; interface LocaleExample { readonly id: string; readonly label: string; readonly locale: string; } @Component({ selector: 'time-field-localization-example', imports: [RdxTimeFieldRootDirective, RdxTimeFieldInputDirective, RdxVisuallyHiddenInputDirective], template: `
@for (example of locales; track example.id) {
@for (item of root.segmentContents(); track $index) { @if (item.part === 'literal') { {{ item.value }} } @else {
{{ item.value }}
} }
}
` }) export class TimeFieldLocalizationExample { protected readonly locales: LocaleExample[] = [ { id: 'locale-en', label: 'English (en)', locale: 'en' }, { id: 'locale-ja', label: 'Japanese (ja)', locale: 'ja' }, { id: 'locale-fa', label: 'Persian (fa-IR)', locale: 'fa-IR' }, { id: 'locale-zh', label: 'Taiwan (zh-TW)', locale: 'zh-TW' } ]; } ``` ## Accessibility Adheres to the WAI-ARIA practices for composite time entry. The root is a `role="group"`; give it an accessible name with `aria-labelledby` (or `aria-label`). Each editable segment is a focusable `contenteditable` element exposing `aria-valuetext` so screen readers announce the current value. ### Keyboard Interactions | Key | Description | | ------------------ | ------------------------------------------------- | | Arrow Left / Right | Move focus to the previous / next segment | | Arrow Up / Down | Increment / decrement the focused segment | | 0–9 | Type a value directly into the focused segment | | Backspace | Clear the focused segment | ## API Reference ### Root `RdxTimeFieldRootDirective` contains all the parts of a time field and owns the value, placeholder, granularity, and validation state. Exposes `data-disabled`, `data-readonly`, and `data-invalid`. ### Input `RdxTimeFieldInputDirective` renders a single segment from `segmentContents()`. Pass the segment `part`; everything else is read from the root context. --- # Toast #### A succinct, low-priority message that appears temporarily, stacks, and can be swiped away. ```typescript import { Component, inject } from '@angular/core'; import { provideRdxToastManager, RdxToastManager, toastImports } from '@radix-ng/primitives/toast'; import { cn, demoButton, demoToast } from '../../storybook/styles'; @Component({ selector: 'toast-default-example', imports: [...toastImports], providers: [provideRdxToastManager()], template: `
@for (toast of manager.toasts(); track toast.id) {

{{ toast.title }}

@if (toast.description) {

{{ toast.description }}

}
}
` }) export class ToastDefaultExample { protected readonly manager = inject(RdxToastManager); protected readonly cn = cn; protected readonly b = demoButton; protected readonly t = demoToast; private count = 0; notify(): void { this.count++; this.manager.add({ title: `Notification ${this.count}`, description: 'Your changes have been saved.' }); } } ``` ## Features - ✅ Imperative API — push toasts from anywhere with `add` / `update` / `close` / `promise`. - ✅ Headless and unstyled — state is exposed through `data-*` attributes and CSS variables. - ✅ Stacking with a hover/focus expand, driven by measured heights (`--toast-offset-y`). - ✅ Swipe-to-dismiss with rubber-banding and flick detection in any allowed direction. - ✅ Auto-dismiss timers that pause while the stack is hovered, focused, or being swiped. - ✅ Promise toasts with `loading` / `success` / `error` copy. - ✅ Accessible — announces through `role="status"` / `role="alert"` by priority. ## Import ```ts import { provideRdxToastManager, RdxToastManager, toastImports } from '@radix-ng/primitives/toast'; ``` `toastImports` bundles every part directive; `provideRdxToastManager()` (or the `[rdxToastProvider]` directive) supplies the queue, and `RdxToastManager` is injected to push toasts. ## Anatomy Provide the manager once, render the viewport, and iterate the queue. Each item from `manager.toasts()` is bound to a `rdxToastRoot`. ```html

``` ```ts const manager = inject(RdxToastManager); manager.add({ title: 'Saved', description: 'Your changes are live.' }); ``` An anchored toast is wrapped in `rdxToastPositioner` instead of joining the stack: ```html

``` ## Examples ### Default Push a single toast; it auto-dismisses after the timeout. ```typescript import { Component, inject } from '@angular/core'; import { provideRdxToastManager, RdxToastManager, toastImports } from '@radix-ng/primitives/toast'; import { cn, demoButton, demoToast } from '../../storybook/styles'; @Component({ selector: 'toast-default-example', imports: [...toastImports], providers: [provideRdxToastManager()], template: `
@for (toast of manager.toasts(); track toast.id) {

{{ toast.title }}

@if (toast.description) {

{{ toast.description }}

}
}
` }) export class ToastDefaultExample { protected readonly manager = inject(RdxToastManager); protected readonly cn = cn; protected readonly b = demoButton; protected readonly t = demoToast; private count = 0; notify(): void { this.count++; this.manager.add({ title: `Notification ${this.count}`, description: 'Your changes have been saved.' }); } } ``` ### Stacking Add several toasts to see the collapsed stack, then hover or focus it to expand. Auto-dismiss pauses while the stack is hovered or focused. ```typescript import { Component, inject } from '@angular/core'; import { provideRdxToastManager, RdxToastManager, toastImports } from '@radix-ng/primitives/toast'; import { cn, demoButton, demoToast } from '../../storybook/styles'; /** * Push several toasts to see the collapsed stack, then hover (or focus) the stack to expand it — * `data-expanded` lays each toast out by its measured `--toast-offset-y`. Auto-dismiss pauses * while the stack is hovered or focused. */ @Component({ selector: 'toast-stacking-example', imports: [...toastImports], providers: [provideRdxToastManager()], template: `
@for (toast of manager.toasts(); track toast.id) {

{{ toast.title }}

{{ toast.description }}

}
` }) export class ToastStackingExample { protected readonly manager = inject(RdxToastManager); protected readonly cn = cn; protected readonly b = demoButton; protected readonly t = demoToast; private count = 0; add(): void { this.count++; this.manager.add({ title: `Message ${this.count}`, description: 'Hover the stack to expand it.', timeout: 0 }); } } ``` ### Swipe to dismiss Drag a toast toward an allowed `swipeDirection` to dismiss it; release early to snap back. ```typescript import { Component, inject } from '@angular/core'; import { provideRdxToastManager, RdxToastManager, toastImports } from '@radix-ng/primitives/toast'; import { cn, demoButton, demoToast } from '../../storybook/styles'; /** * Toasts can be dismissed by swiping. `swipeDirection` lists the allowed directions; the gesture * follows whichever the pointer drags toward most and dismisses past a threshold (or on a flick). * The live offset is exposed as `--toast-swipe-movement-x/y` and applied to the content. */ @Component({ selector: 'toast-swipe-example', imports: [...toastImports], providers: [provideRdxToastManager()], template: `
@for (toast of manager.toasts(); track toast.id) {

Swipe me away

Drag right or down to dismiss. Release early to snap back.

}
` }) export class ToastSwipeExample { protected readonly manager = inject(RdxToastManager); protected readonly cn = cn; protected readonly b = demoButton; protected readonly t = demoToast; add(): void { this.manager.add({ title: 'Swipe me away', timeout: 0 }); } } ``` ### Promise Drive one toast through a promise: `loading`, then `success` or `error`. ```typescript import { Component, inject } from '@angular/core'; import { provideRdxToastManager, RdxToastManager, toastImports } from '@radix-ng/primitives/toast'; import { cn, demoButton, demoToast } from '../../storybook/styles'; /** * `manager.promise()` drives a single toast through a promise's lifecycle: it shows the `loading` * copy, then swaps to `success` or `error` when the promise settles. The loading toast skips * auto-dismiss; the resolved one picks the timeout back up. */ @Component({ selector: 'toast-promise-example', imports: [...toastImports], providers: [provideRdxToastManager()], template: `
@for (toast of manager.toasts(); track toast.id) {

{{ toast.title }}

@if (toast.description) {

{{ toast.description }}

}
@if (!toast.loading) { }
}
` }) export class ToastPromiseExample { protected readonly manager = inject(RdxToastManager); protected readonly cn = cn; protected readonly b = demoButton; protected readonly t = demoToast; run(succeed: boolean): void { const request = new Promise((resolve, reject) => { setTimeout(() => (succeed ? resolve('report.pdf') : reject(new Error('Network error'))), 1800); }); // Swallow the rejection here; the toast already reflects it. this.manager .promise(request, { loading: { title: 'Saving…', description: 'Uploading your file.' }, success: (file) => ({ title: 'Saved', description: `${file} is ready.`, type: 'success' }), error: (err) => ({ title: 'Failed', description: (err as Error).message, type: 'error' }) }) .catch(() => undefined); } } ``` ### Types Branch on a free-form `type` to render an icon, and raise `priority` to `high` for assertive errors. ```typescript import { Component, inject } from '@angular/core'; import { LucideCircleCheck, LucideCircleX, LucideInfo } from '@lucide/angular'; import { provideRdxToastManager, RdxToastManager, toastImports } from '@radix-ng/primitives/toast'; import { cn, demoButton, demoToast } from '../../storybook/styles'; /** * `type` is a free-form category surfaced back on the toast object — branch on it in the template * (here via an icon) and style with `[data-type]` on the root. `priority: 'high'` switches the * announcement role to `alert` (assertive) for errors. */ @Component({ selector: 'toast-types-example', imports: [...toastImports, LucideCircleCheck, LucideCircleX, LucideInfo], providers: [provideRdxToastManager()], template: `
@for (toast of manager.toasts(); track toast.id) {
@switch (toast.type) { @case ('success') { } @case ('error') { } @default { } }

{{ toast.title }}

{{ toast.description }}

}
` }) export class ToastTypesExample { protected readonly manager = inject(RdxToastManager); protected readonly cn = cn; protected readonly b = demoButton; protected readonly t = demoToast; success(): void { this.manager.add({ type: 'success', title: 'Saved', description: 'Your changes are live.' }); } error(): void { this.manager.add({ type: 'error', title: 'Something went wrong', description: 'Please try again.', priority: 'high' }); } info(): void { this.manager.add({ type: 'info', title: 'Heads up', description: 'A new version is available.' }); } } ``` ### Custom position Placement is the consumer's CSS — here the viewport is anchored top-center and the stack grows down. ```typescript import { Component, inject } from '@angular/core'; import { provideRdxToastManager, RdxToastManager, toastImports } from '@radix-ng/primitives/toast'; import { cn, demoButton, demoToast } from '../../storybook/styles'; /** * Placement is entirely the consumer's CSS — the primitive only positions nothing. Here the viewport * is anchored top-center and the stack grows downward (the stacking variables are styled with * flipped signs and `origin-top`). Swipe up to dismiss. */ @Component({ selector: 'toast-custom-position-example', imports: [...toastImports], providers: [provideRdxToastManager()], template: `
@for (toast of manager.toasts(); track toast.id) {

{{ toast.title }}

{{ toast.description }}

}
` }) export class ToastCustomPositionExample { protected readonly manager = inject(RdxToastManager); protected readonly cn = cn; protected readonly b = demoButton; protected readonly t = demoToast; private count = 0; add(): void { this.count++; this.manager.add({ title: `Top toast ${this.count}`, description: 'Anchored at the top center.' }); } } ``` ### Undo action `rdxToastAction` adds an in-toast action button; this one undoes the change and dismisses the toast. ```typescript import { Component, inject } from '@angular/core'; import { provideRdxToastManager, RdxToastManager, RdxToastObject, toastImports } from '@radix-ng/primitives/toast'; import { cn, demoButton, demoToast } from '../../storybook/styles'; /** * `rdxToastAction` renders an in-toast action button. The label and handler are passed through the * toast's `actionProps`; clicking runs the handler and dismisses the toast. */ @Component({ selector: 'toast-undo-action-example', imports: [...toastImports], providers: [provideRdxToastManager()], template: `
Archived: {{ archived }}
@for (toast of manager.toasts(); track toast.id) {

{{ toast.title }}

@if (toast.actionProps; as action) { }
}
` }) export class ToastUndoActionExample { protected readonly manager = inject(RdxToastManager); protected readonly cn = cn; protected readonly b = demoButton; protected readonly t = demoToast; protected archived = 0; archive(): void { this.archived++; this.manager.add({ title: 'Item archived', actionProps: { label: 'Undo', onClick: () => this.archived-- } }); } runAction(toast: RdxToastObject, event: MouseEvent): void { toast.actionProps?.onClick?.(event); this.manager.close(toast.id); } } ``` ### Custom data Attach a typed `data` payload and read it back in the template for rich, app-specific toasts. ```typescript import { Component, inject } from '@angular/core'; import { provideRdxToastManager, RdxToastManager, toastImports } from '@radix-ng/primitives/toast'; import { cn, demoButton, demoToast } from '../../storybook/styles'; interface MentionData { user: string; initials: string; message: string; } /** * Toasts carry an arbitrary, typed `data` payload that templates can read back — useful for rich, * app-specific content beyond title/description. Here each toast renders an avatar and a mention. */ @Component({ selector: 'toast-custom-data-example', imports: [...toastImports], providers: [provideRdxToastManager()], template: `
@for (toast of manager.toasts(); track toast.id) {
@let data = $any(toast.data);

{{ data.user }} mentioned you

“{{ data.message }}”

}
` }) export class ToastCustomDataExample { protected readonly manager = inject(RdxToastManager); protected readonly cn = cn; protected readonly b = demoButton; protected readonly t = demoToast; private readonly people: MentionData[] = [ { user: 'Ada Lovelace', initials: 'AL', message: 'Can you review the toast PR?' }, { user: 'Alan Turing', initials: 'AT', message: 'Shipped the stacking fix 🎉' }, { user: 'Grace Hopper', initials: 'GH', message: 'Found a bug in the timer logic.' } ]; private next = 0; mention(): void { const person = this.people[this.next % this.people.length]; this.next++; this.manager.add({ title: `${person.user} mentioned you`, data: person }); } } ``` ### Deduplicated Pass a fixed `id` to upsert a single toast in place instead of stacking duplicates. ```typescript import { Component, inject } from '@angular/core'; import { provideRdxToastManager, RdxToastManager, toastImports } from '@radix-ng/primitives/toast'; import { cn, demoButton, demoToast } from '../../storybook/styles'; /** * Passing a fixed `id` upserts instead of stacking — repeated calls update the same toast in place * rather than piling up duplicates. Bumping `updateKey` replays the enter animation, so a rapid * second click visibly pulses the existing toast; its auto-dismiss timer restarts each time. */ @Component({ selector: 'toast-deduplicated-example', imports: [...toastImports], providers: [provideRdxToastManager()], template: `
@for (toast of manager.toasts(); track toast.id) {

{{ toast.title }}

{{ toast.description }}

}
` }) export class ToastDeduplicatedExample { protected readonly manager = inject(RdxToastManager); protected readonly cn = cn; protected readonly b = demoButton; protected readonly t = demoToast; private times = 0; copy(): void { this.times++; this.manager.add({ id: 'clipboard', title: 'Link copied', description: this.times === 1 ? 'Copied to clipboard.' : `Copied ${this.times} times.`, updateKey: this.times }); } } ``` ### Varying heights Measured heights feed `--toast-offset-y`, so the expanded stack lines up even with differing heights. ```typescript import { Component, inject } from '@angular/core'; import { provideRdxToastManager, RdxToastManager, toastImports } from '@radix-ng/primitives/toast'; import { cn, demoButton, demoToast } from '../../storybook/styles'; /** * Each toast's height is measured and shared as `--toast-offset-y`, so the expanded layout lines up * even when toasts differ in height. Add a few (the descriptions vary in length), then hover to * expand and watch them stack without overlap. */ @Component({ selector: 'toast-varying-heights-example', imports: [...toastImports], providers: [provideRdxToastManager()], template: `
@for (toast of manager.toasts(); track toast.id) {

{{ toast.title }}

{{ toast.description }}

}
` }) export class ToastVaryingHeightsExample { protected readonly manager = inject(RdxToastManager); protected readonly cn = cn; protected readonly b = demoButton; protected readonly t = demoToast; private readonly bodies = [ 'Short and sweet.', 'A medium-length message that wraps onto a second line to add some height.', 'A longer notification that spans several lines so the stacking offsets clearly have to account for differing toast heights when the stack is expanded.' ]; private next = 0; add(): void { const description = this.bodies[this.next % this.bodies.length]; this.next++; this.manager.add({ title: `Toast ${this.next}`, description, timeout: 0 }); } } ``` ### Anchored Pin a toast to an element with `rdxToastPositioner` (powered by popper) instead of joining the stack. ```typescript import { Component, inject } from '@angular/core'; import { provideRdxToastManager, RdxToastManager, toastImports } from '@radix-ng/primitives/toast'; import { cn, demoButton, demoToast } from '../../storybook/styles'; /** * An anchored toast is positioned against an element with `rdxToastPositioner` (powered by popper) * instead of joining the stack, with a `rdxToastArrow` pointing back at the anchor. Pass the anchor * through the toast's `positionerProps`. */ @Component({ selector: 'toast-anchored-example', imports: [...toastImports], providers: [provideRdxToastManager()], template: `
@for (toast of manager.toasts(); track toast.id) { @if (toast.positionerProps; as positioner) {

{{ toast.title }}

{{ toast.description }}

} }
` }) export class ToastAnchoredExample { protected readonly manager = inject(RdxToastManager); protected readonly cn = cn; protected readonly b = demoButton; protected readonly t = demoToast; show(event: MouseEvent): void { this.manager.add({ id: 'anchored', title: 'Anchored toast', description: 'Pinned to the button, with an arrow.', timeout: 0, positionerProps: { anchor: event.currentTarget as HTMLElement, side: 'top' } }); } } ``` ## Data attributes | Attribute | Part | Present when | | ---------------------- | ---- | ------------------------------------------------------- | | `data-state` | Root | `open` while visible, `closed` once dismissal begins | | `data-front` | Root | this is the frontmost toast (`--toast-index` is `0`) | | `data-expanded` | Root | the viewport is hovered or focused | | `data-type` | Root | the toast has a `type` | | `data-swiping` | Root | a swipe gesture is active | | `data-swipe-direction` | Root | the active swipe direction (`up`/`down`/`left`/`right`) | | `data-swipe-dismiss` | Root | a release committed to dismissal | The root also writes CSS variables for styling: `--toast-index`, `--toast-height`, `--toast-offset-y`, and `--toast-swipe-movement-x` / `--toast-swipe-movement-y`. ## API Reference ### RdxToastProvider Hosts the `RdxToastManager` for its subtree (`[rdxToastProvider]`). ### RdxToastRoot A single toast. Bind the toast model from the viewport's `@for`. ### RdxToastPositioner Anchors a toast to an element via popper. Forwards the popper positioning inputs (`anchor`, `side`, `sideOffset`, `align`, `alignOffset`, `arrowPadding`, `avoidCollisions`, `collisionPadding`, …). ### Other parts `RdxToastPortal`, `RdxToastViewport`, `RdxToastContent`, `RdxToastTitle`, `RdxToastDescription`, `RdxToastClose` and `RdxToastAction` take no inputs of their own — they read from the toast root context and the manager. `RdxToastPortal` forwards a `container` input to the underlying portal. --- # Toggle #### A two-state button that can be either on or off. ```html ``` ## Features - ✅ Can be controlled or uncontrolled. - ✅ Full keyboard navigation. - ✅ Works standalone or as an item of a Toggle Group. ## Import ```typescript import { RdxToggle } from '@radix-ng/primitives/toggle'; ``` The API follows [Base UI Toggle](https://base-ui.com/react/components/toggle): a single `Toggle` part used either on its own or inside a `[rdxToggleGroup]`. ## Anatomy ```html ``` When placed inside a `[rdxToggleGroup]`, give each toggle a `value` — its pressed state is then derived from the group's value and it joins the group's roving focus. ## Examples ### Pressed by default Use `defaultPressed` for an uncontrolled toggle that starts pressed. ```html ``` ### Controlled Bind `[(pressed)]` (or `[pressed]` + `(onPressedChange)`) to control the state. ```html
pressed: {{ pressed }}
``` ### Disabled When `disabled` is present the toggle cannot be activated. ```html ``` ## API Reference ### Toggle `RdxToggle` | Data attribute | Value | | ----------------- | ------------------------------ | | `[data-pressed]` | Present when the toggle is on. | | `[data-disabled]` | Present when disabled. | ## Accessibility ### Keyboard Interactions | Key | Description | | ------- | --------------------------------- | | `Space` | Activates/deactivates the toggle. | | `Enter` | Activates/deactivates the toggle. | --- # Toggle Group #### A set of two-state buttons that can be toggled on or off. ```html
``` ## Features - ✅ Full keyboard navigation with roving focus. - ✅ Supports horizontal and vertical orientation. - ✅ Single or multiple pressed items. - ✅ Can be controlled or uncontrolled. ## Import ```typescript import { RdxToggleGroup } from '@radix-ng/primitives/toggle-group'; import { RdxToggle } from '@radix-ng/primitives/toggle'; ``` The API follows [Base UI Toggle Group](https://base-ui.com/react/components/toggle-group): a `ToggleGroup` whose children are plain `Toggle` items. The group `value` is always an array. ## Anatomy ```html
``` ## Examples ### Multiple Set `multiple` to allow more than one item to be pressed at the same time. ```html
``` ### Disabled Disable a single `[rdxToggle]` with `disabled`, or the whole group with `disabled` on the root. ```html
``` ```html
``` ### Two-way binding Bind `[(value)]` to read and write the pressed values. ```html ``` ## API Reference ### Root `RdxToggleGroup` | Data attribute | Value | | -------------------- | ------------------------------ | | `[data-orientation]` | `"horizontal" \| "vertical"` | | `[data-disabled]` | Present when the group is disabled. | | `[data-multiple]` | Present when multiple selection is enabled. | ### Item `RdxToggle` — see the [Toggle](?path=/docs/primitives-toggle--docs) page. Each item needs a `value`. ## Accessibility Uses [roving tabindex](https://www.w3.org/WAI/ARIA/apg/patterns/) to manage focus among items. ### Keyboard Interactions | Key | Description | | ------------ | -------------------------------------------------- | | `Tab` | Moves focus to the pressed item or the first item. | | `Space` | Activates/deactivates the focused item. | | `Enter` | Activates/deactivates the focused item. | | `ArrowDown` | Moves focus to the next item. | | `ArrowRight` | Moves focus to the next item. | | `ArrowUp` | Moves focus to the previous item. | | `ArrowLeft` | Moves focus to the previous item. | | `Home` | Moves focus to the first item. | | `End` | Moves focus to the last item. | --- # Toolbar #### A container for grouping a set of controls, such as buttons, toggle groups or menus. ```html ``` ## Features - ✅ Full keyboard navigation with roving focus. - ✅ Horizontal and vertical orientation. - ✅ Disabling the toolbar or a group cascades to its items. - ✅ Composes with Toggle Group, Menu, Tooltip, NumberField and more. ## Import ```typescript import { RdxToolbarRoot, RdxToolbarButton, RdxToolbarLink, RdxToolbarInput, RdxToolbarGroup, RdxToolbarSeparator } from '@radix-ng/primitives/toolbar'; ``` The API follows [Base UI Toolbar](https://base-ui.com/react/components/toolbar): a `Root` owning roving focus over `Button`, `Link`, `Input`, `Group` and `Separator` parts. ## Anatomy ```html
``` Stack a toolbar part on another primitive's trigger/input to compose it — e.g. `
``` ### Disabled A disabled `[rdxToolbarButton]` stays focusable by default (`focusableWhenDisabled`) so keyboard and screen-reader users can still reach it. ```html
``` ### Using with Menu Render a `[rdxMenuTrigger]` on a toolbar button to open a menu from the toolbar. ```typescript import { Component } from '@angular/core'; import { LucideChevronDown } from '@lucide/angular'; import { RdxMenuModule } from '@radix-ng/primitives/menu'; import { toolbarImports } from '@radix-ng/primitives/toolbar'; import { cn, demoMenu } from '../../storybook/styles'; @Component({ selector: 'toolbar-with-menu', imports: [...toolbarImports, RdxMenuModule, LucideChevronDown], template: `
@if (menu.open()) {
}
` }) export class ToolbarWithMenuExample { protected readonly cn = cn; protected readonly m = demoMenu; } ``` ### Using with Tooltip Wrap toolbar buttons with a tooltip by stacking `[rdxTooltipTrigger]`. ```typescript import { Component } from '@angular/core'; import { LucideBold, LucideItalic } from '@lucide/angular'; import { toolbarImports } from '@radix-ng/primitives/toolbar'; import { tooltipImports } from '@radix-ng/primitives/tooltip'; import { demoTooltip } from '../../storybook/styles'; const itemClass = 'text-foreground hover:bg-muted focus-visible:ring-ring inline-flex h-8 w-8 items-center justify-center rounded-md outline-none transition-colors focus-visible:ring-2'; @Component({ selector: 'toolbar-with-tooltip', imports: [...toolbarImports, ...tooltipImports, LucideBold, LucideItalic], template: `
Bold
Italic
` }) export class ToolbarWithTooltipExample { protected readonly t = demoTooltip; } ``` ### Using with NumberField Stack `[rdxToolbarInput]` on a `[rdxNumberFieldInput]` to embed a number field. The input keeps native text editing — the arrow keys move the caret and only move toolbar focus once the caret reaches the matching edge. ```typescript import { Component } from '@angular/core'; import { LucideMinus, LucidePlus } from '@lucide/angular'; import { RdxNumberFieldModule } from '@radix-ng/primitives/number-field'; import { toolbarImports } from '@radix-ng/primitives/toolbar'; const stepClass = 'text-foreground hover:bg-muted focus-visible:ring-ring inline-flex h-8 w-8 items-center justify-center rounded-md outline-none transition-colors focus-visible:ring-2 disabled:opacity-50'; @Component({ selector: 'toolbar-with-number-field', imports: [...toolbarImports, RdxNumberFieldModule, LucideMinus, LucidePlus], template: `
` }) export class ToolbarWithNumberFieldExample {} ``` ## API Reference ### ToolbarRoot `RdxToolbarRoot` | Data attribute | Value | | -------------------- | ---------------------------- | | `[data-orientation]` | `"horizontal" \| "vertical"` | | `[data-disabled]` | Present when disabled. | ### ToolbarButton `RdxToolbarButton` | Data attribute | Value | | -------------------- | ------------------------------------------- | | `[data-orientation]` | `"horizontal" \| "vertical"` | | `[data-disabled]` | Present when disabled. | | `[data-focusable]` | Present when focusable while disabled. | ### ToolbarInput `RdxToolbarInput` | Data attribute | Value | | -------------------- | ------------------------------------------- | | `[data-orientation]` | `"horizontal" \| "vertical"` | | `[data-disabled]` | Present when disabled. | | `[data-focusable]` | Present when focusable while disabled. | ### ToolbarGroup `RdxToolbarGroup` | Data attribute | Value | | -------------------- | ---------------------------- | | `[data-orientation]` | `"horizontal" \| "vertical"` | | `[data-disabled]` | Present when disabled. | ### ToolbarLink `RdxToolbarLink` — an `` toolbar item; reads everything from context. Exposes `[data-orientation]`. ### ToolbarSeparator `RdxToolbarSeparator` — composes the Separator primitive; accepts `orientation`. ## Accessibility Adheres to the [Toolbar WAI-ARIA design pattern](https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/). ### Keyboard Interactions | Key | Description | | ------------ | -------------------------------------------------- | | `Tab` | Moves focus into/out of the toolbar (single stop). | | `ArrowRight` | (Horizontal) Moves focus to the next item. | | `ArrowLeft` | (Horizontal) Moves focus to the previous item. | | `ArrowDown` | (Vertical) Moves focus to the next item. | | `ArrowUp` | (Vertical) Moves focus to the previous item. | | `Home` | Moves focus to the first item. | | `End` | Moves focus to the last item. | --- # Tooltip #### Displays contextual information when a trigger is hovered or focused. Tooltip composes the shared Popper, Portal, Presence, and Dismissable Layer primitives. It remains headless: styles and native CSS animations belong to the consumer. The API follows Base UI. ```typescript import { Component } from '@angular/core'; import { LucidePlus } from '@lucide/angular'; import { tooltipImports } from '@radix-ng/primitives/tooltip'; import { cn, demoButton, demoTooltip } from '../../storybook/styles'; @Component({ selector: 'rdx-tooltip-default', imports: [...tooltipImports, LucidePlus], template: `
Add to library
` }) export class RdxTooltipDefaultComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly t = demoTooltip; } ``` ## Features - ✅ Opens on hover and keyboard focus, closes on blur, pointer leave, click, or Escape. - ✅ Configurable open `delay`, `closeDelay`, and an instant-open `timeout` window. - ✅ A `Provider` shares delays and the instant-open window across a group of tooltips. - ✅ Supports uncontrolled state, `defaultOpen`, and Angular two-way binding with `[(open)]`. - ✅ Supports multiple and detached triggers through a `Handle`. - ✅ Can follow the cursor with `trackCursorAxis`. - ✅ Positions content with the shared Floating UI-based Popper, with an optional custom `anchor`. - ✅ Exposes `data-open`, `data-closed`, `data-side`, `data-align`, and `data-instant` for styling. - ✅ Keeps portal content mounted while CSS exit keyframes finish. ## Import ```typescript import { RdxTooltip, RdxTooltipArrow, RdxTooltipPopup, RdxTooltipPortal, RdxTooltipPortalPresence, RdxTooltipPositioner, RdxTooltipProvider, RdxTooltipTrigger } from '@radix-ng/primitives/tooltip'; ``` Or import all parts through the array: ```typescript import { tooltipImports } from '@radix-ng/primitives/tooltip'; ``` ## Anatomy Apply the parts to your own markup. `rdxTooltipPortalPresence` manages mounting and waits for exit keyframes on the first DOM element inside its template. ```html
Add to library
``` `rdxTooltipPositioner` owns positioning and exposes `data-side` / `data-align` plus the `--anchor-*`, `--available-*`, and `--transform-origin` CSS variables. `rdxTooltipPopup` carries `role="tooltip"`. ## Examples ### Default A tooltip anchored to an icon button, with an arrow. ```typescript import { Component } from '@angular/core'; import { LucidePlus } from '@lucide/angular'; import { tooltipImports } from '@radix-ng/primitives/tooltip'; import { cn, demoButton, demoTooltip } from '../../storybook/styles'; @Component({ selector: 'rdx-tooltip-default', imports: [...tooltipImports, LucidePlus], template: `
Add to library
` }) export class RdxTooltipDefaultComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly t = demoTooltip; } ``` ### Provider Wrap a group of tooltips with `rdxTooltipProvider` to share the open `delay`, `closeDelay`, and the instant-open `timeout` window — once one tooltip opens, adjacent ones open instantly until `timeout` ms after the last one closes. ```typescript import { Component } from '@angular/core'; import { LucideDynamicIcon } from '@lucide/angular'; import { tooltipImports } from '@radix-ng/primitives/tooltip'; import { cn, demoButton, demoTooltip } from '../../storybook/styles'; @Component({ selector: 'rdx-tooltip-provider', imports: [...tooltipImports, LucideDynamicIcon], template: `
@for (action of actions; track action.name) {
{{ action.name }}
}
` }) export class RdxTooltipProviderComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly t = demoTooltip; protected readonly actions = [ { name: 'Bold', icon: 'bold' }, { name: 'Italic', icon: 'italic' }, { name: 'Add', icon: 'plus' } ]; } ``` ### Per-trigger delay Set `delay` (and `closeDelay`) on `rdxTooltipTrigger` to override the root, provider, and global values for that trigger only. ```typescript import { Component } from '@angular/core'; import { tooltipImports } from '@radix-ng/primitives/tooltip'; import { cn, demoButton, demoTooltip } from '../../storybook/styles'; @Component({ selector: 'rdx-tooltip-delay', imports: [...tooltipImports], template: `
@for (item of triggers; track item.delay) {
Opened after {{ item.delay }} ms
}
` }) export class RdxTooltipDelayComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly t = demoTooltip; protected readonly triggers = [ { label: 'Instant', delay: 0 }, { label: 'Default', delay: 600 }, { label: 'Slow', delay: 1200 } ]; } ``` ### Disabled trigger Set `disabled` on `rdxTooltipTrigger` (or `disabled` on `rdxTooltip` for all triggers). A disabled trigger never opens the tooltip and reflects `data-trigger-disabled`. ```typescript import { Component } from '@angular/core'; import { tooltipImports } from '@radix-ng/primitives/tooltip'; import { cn, demoButton, demoTooltip } from '../../storybook/styles'; @Component({ selector: 'rdx-tooltip-disabled', imports: [...tooltipImports], template: `
@for (item of triggers; track item.label) {
{{ item.label }} tooltip
}
` }) export class RdxTooltipDisabledComponent { protected readonly cn = cn; protected readonly b = demoButton; protected readonly t = demoTooltip; protected readonly triggers = [ { label: 'Enabled', disabled: false }, { label: 'Disabled', disabled: true } ]; } ``` ### Tracking the cursor Set `trackCursorAxis` on the root to `'x'`, `'y'`, or `'both'` to make the popup follow the pointer along that axis. ```typescript import { Component } from '@angular/core'; import { tooltipImports } from '@radix-ng/primitives/tooltip'; import { demoTooltip } from '../../storybook/styles'; @Component({ selector: 'rdx-tooltip-track-cursor', imports: [...tooltipImports], template: `
Following the cursor
` }) export class RdxTooltipTrackCursorComponent { protected readonly t = demoTooltip; } ``` ### Moving anchors Tooltip uses `updatePositionStrategy="always"` so it can follow moving triggers — keep that strategy when the trigger moves continuously, such as a slider thumb being dragged. ```typescript import { Component, ElementRef, signal, viewChild } from '@angular/core'; import { RdxSliderControl, RdxSliderIndicator, RdxSliderRoot, RdxSliderThumb, RdxSliderThumbInput, RdxSliderTrack } from '@radix-ng/primitives/slider'; import { tooltipImports } from '@radix-ng/primitives/tooltip'; import { demoTooltip } from '../../storybook/styles'; @Component({ selector: 'rdx-tooltip-slider', imports: [ ...tooltipImports, RdxSliderRoot, RdxSliderControl, RdxSliderTrack, RdxSliderIndicator, RdxSliderThumb, RdxSliderThumbInput ], template: `
Volume
` }) export class RdxTooltipSliderComponent { protected readonly t = demoTooltip; readonly tooltipContentRef = viewChild.required>('tooltipContent'); readonly showTooltipState = signal(false); handlePointerDown = () => { this.showTooltipState.set(true); const handlePointerUp = () => { this.showTooltipState.set(false); document.removeEventListener('pointerup', handlePointerUp); }; document.addEventListener('pointerup', handlePointerUp); return; }; } ``` ## API Reference ### Root `RdxTooltip` owns the open state, delays, cursor tracking, and detached-trigger association. ### Provider `RdxTooltipProvider` shares delays and the instant-open window across a group of tooltips. ### Trigger `RdxTooltipTrigger` anchors the tooltip and exposes its open state through `data-popup-open`. ### Portal `RdxTooltipPortal` moves content to `document.body` by default or to a configured container. ### Positioner `RdxTooltipPositioner` delegates placement and collision handling to the shared Popper primitive. Its optional `anchor` input overrides the trigger only for positioning. ### Popup and Arrow `RdxTooltipPopup` and `RdxTooltipArrow` read their behavior and state from context and do not expose additional inputs or outputs. The arrow also reflects `data-uncentered` when it cannot be centered on the anchor (because the popup was shifted to avoid a collision). --- # Styling guide for demos Radix NG primitives are **headless** — directives carry no styles. The styling you see in Storybook lives entirely in the demos. This page is the single source of truth for how those demos look, so examples stay consistent instead of each story reinventing its own classes. Visual reference: [coss.com](https://coss.com/ui) (Base UI + Tailwind). The Storybook theme in `apps/radix-storybook/.storybook/tailwind.css` is already built on this token set. ## Rules 1. **Use Tailwind v4 utilities** directly in templates. No story-local CSS files, no `styleUrl` / `styles`, no inline `