Resizable
Panels that share space, resized by dragging or with the keyboard.
- v1.0.0
- WAI-ARIA: Window Splitter
- SSR
- Zoneless
- 2.8 kB gzip
import { Component } from '@angular/core';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/ui/resizable/resizable';
/**
* A sidebar beside two stacked panes. Each handle is a separator that the
* arrow keys or a pointer move.
*/
@Component({
selector: 'example-resizable-demo',
imports: [ResizableHandle, ResizablePanel, ResizablePanelGroup],
template: `
<div
ndResizablePanelGroup
orientation="horizontal"
class="max-w-md rounded-lg border md:min-w-[450px]"
>
<div ndResizablePanel [defaultSize]="50">
<div class="flex h-[200px] items-center justify-center p-6">
<span class="font-semibold">One</span>
</div>
</div>
<div ndResizableHandle aria-label="Resize one"></div>
<div ndResizablePanel [defaultSize]="50">
<div ndResizablePanelGroup orientation="vertical">
<div ndResizablePanel [defaultSize]="25">
<div class="flex h-full items-center justify-center p-6">
<span class="font-semibold">Two</span>
</div>
</div>
<div ndResizableHandle aria-label="Resize two"></div>
<div ndResizablePanel [defaultSize]="75">
<div class="flex h-full items-center justify-center p-6">
<span class="font-semibold">Three</span>
</div>
</div>
</div>
</div>
</div>
`,
})
export class ResizableDemoExample {}
Resizable panels share a row or column, with a handle between each pair
that a pointer drags or the arrow keys move: a window splitter, such as a
sidebar next to an editor. Each panel can have a default, minimum and
maximum size, in percent of the group, and [(sizes)] reads or restores
the layout.
Installation
npx @ndop/cli add resizablepnpm dlx @ndop/cli add resizableyarn dlx @ndop/cli add resizablebunx @ndop/cli add resizableInstall the dependencies
npm install @ndop/primitives @ndop/corepnpm add @ndop/primitives @ndop/coreyarn add @ndop/primitives @ndop/corebun add @ndop/primitives @ndop/coreCopy the code into your project
At the paths your
ndop.jsonaliases point to; these use the defaults.src/app/ui/resizable/resizable.tsimport { computed, Directive, input } from '@angular/core'; import { NdResizableHandle, NdResizablePanel, NdResizablePanelGroup, } from '@ndop/primitives/resizable'; import { cn } from '@/lib/utils'; /** * A group of resizable panels (`@ndop/primitives/resizable`), side by side * or stacked, with a handle between each pair. * * @example * ```html * <div ndResizablePanelGroup orientation="horizontal"> * <div ndResizablePanel [defaultSize]="30">Sidebar</div> * <div ndResizableHandle aria-label="Resize sidebar"></div> * <div ndResizablePanel>Content</div> * </div> * ``` */ @Directive({ selector: '[ndResizablePanelGroup]', hostDirectives: [ { directive: NdResizablePanelGroup, inputs: ['orientation', 'sizes', 'keyboardStep'], outputs: ['sizesChange'], }, ], host: { 'data-slot': 'resizable-panel-group', '[class]': 'classes()' }, }) export class ResizablePanelGroup { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn('flex h-full w-full data-[orientation=vertical]:flex-col', this.class()), ); } /** * One panel; its size is in percent of the group. * * @example * ```html * <div ndResizablePanel [defaultSize]="30" [minSize]="20">…</div> * ``` */ @Directive({ selector: '[ndResizablePanel]', hostDirectives: [ { directive: NdResizablePanel, inputs: ['defaultSize', 'minSize', 'maxSize'], }, ], host: { 'data-slot': 'resizable-panel' }, }) export class ResizablePanel {} /** * The line between two panels, which arrow keys and dragging move. Put a * {@link ResizableHandleGrip} in it to show it can be dragged. Name it * with `aria-label`. * * @example * ```html * <div ndResizableHandle aria-label="Resize sidebar"></div> * ``` */ @Directive({ selector: '[ndResizableHandle]', hostDirectives: [{ directive: NdResizableHandle, inputs: ['disabled'] }], host: { 'data-slot': 'resizable-handle', '[class]': 'classes()' }, }) export class ResizableHandle { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:start-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90', this.class(), ), ); } /** * A grip in a handle, showing it can be dragged. It's decorative. * * @example * ```html * <div ndResizableHandleGrip><svg ndIcon="grip-vertical"></svg></div> * ``` */ @Directive({ selector: '[ndResizableHandleGrip]', host: { 'data-slot': 'resizable-handle-grip', 'aria-hidden': 'true', '[class]': 'classes()', }, }) export class ResizableHandleGrip { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'z-10 flex h-4 w-3 items-center justify-center rounded-xs border bg-border', '[&>svg]:size-2.5', this.class(), ), ); }src/app/lib/utils.ts/** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core';Update the import paths to match your project
The files import each other through the
@/uiand@/libaliases.
Usage
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/ui/resizable/resizable';<div
ndResizablePanelGroup
orientation="horizontal"
class="max-w-md rounded-lg border md:min-w-[450px]"
>
<div ndResizablePanel [defaultSize]="50">
<div class="flex h-[200px] items-center justify-center p-6">
<span class="font-semibold">One</span>
</div>
</div>
<div ndResizableHandle aria-label="Resize one"></div>
<div ndResizablePanel [defaultSize]="50">
<div ndResizablePanelGroup orientation="vertical">
<div ndResizablePanel [defaultSize]="25">
<div class="flex h-full items-center justify-center p-6">
<span class="font-semibold">Two</span>
</div>
</div>
<div ndResizableHandle aria-label="Resize two"></div>
<div ndResizablePanel [defaultSize]="75">
<div class="flex h-full items-center justify-center p-6">
<span class="font-semibold">Three</span>
</div>
</div>
</div>
</div>
</div>Examples
Resizable handle
A handle with a grip, showing it can be dragged.
import { Component } from '@angular/core';
import { NdIcon } from '@ndop/core';
import {
ResizableHandle,
ResizableHandleGrip,
ResizablePanel,
ResizablePanelGroup,
} from '@/ui/resizable/resizable';
/** A handle with a grip, showing it can be dragged. */
@Component({
selector: 'example-resizable-handle',
imports: [
NdIcon,
ResizableHandle,
ResizableHandleGrip,
ResizablePanel,
ResizablePanelGroup,
],
template: `
<div
ndResizablePanelGroup
orientation="horizontal"
class="min-h-[200px] max-w-md rounded-lg border md:min-w-[450px]"
>
<div ndResizablePanel [defaultSize]="25" [minSize]="15">
<div class="flex h-full items-center justify-center p-6">
<span class="font-semibold">Sidebar</span>
</div>
</div>
<div ndResizableHandle aria-label="Resize sidebar">
<div ndResizableHandleGrip><svg ndIcon="grip-vertical"></svg></div>
</div>
<div ndResizablePanel [defaultSize]="75">
<div class="flex h-full items-center justify-center p-6">
<span class="font-semibold">Content</span>
</div>
</div>
</div>
`,
})
export class ResizableHandleExample {}
Resizable vertical
Two panes stacked, resized up and down.
import { Component, signal } from '@angular/core';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/ui/resizable/resizable';
/**
* Two panes stacked, resized up and down; the sizes are bound, to save
* and restore the layout.
*/
@Component({
selector: 'example-resizable-vertical',
imports: [ResizableHandle, ResizablePanel, ResizablePanelGroup],
template: `
<div
ndResizablePanelGroup
orientation="vertical"
[(sizes)]="sizes"
class="min-h-[200px] max-w-md rounded-lg border md:min-w-[450px]"
>
<div ndResizablePanel [defaultSize]="25" [minSize]="10">
<div class="flex h-full items-center justify-center p-6">
<span class="font-semibold">Header</span>
</div>
</div>
<div ndResizableHandle aria-label="Resize header"></div>
<div ndResizablePanel [defaultSize]="75">
<div class="flex h-full items-center justify-center p-6">
<span class="font-semibold">Content</span>
</div>
</div>
</div>
`,
})
export class ResizableVerticalExample {
protected readonly sizes = signal<readonly number[]>([]);
}
Accessibility
Implements the Window Splitter pattern of the WAI-ARIA Authoring Practices.
Keyboard
Keys the component handles.
| Key | Action |
|---|---|
| Tab | Moves focus to the next handle, in document order. |
| ArrowLeftorArrowRight | In a horizontal group, moves the focused handle toward that side by keyboardStep. |
| ArrowUporArrowDown | In a vertical group, moves the focused handle up or down by keyboardStep. |
| Home | Shrinks the panel before the handle to its minSize, as far as the panel after it allows. |
| End | Grows the panel before the handle to its maxSize, as far as the panel after it allows. |
Focus
- Handles are in the tab order unless disabled; panels aren't.
- Dragging a handle with the pointer doesn't move focus.
Right-to-left
In a horizontal group in a right-to-left layout, the panels run from right to left, so ArrowLeft grows the panel before the handle and ArrowRight shrinks it, and dragging follows the pointer. Direction comes from the nearest dir attribute.
API reference
ResizablePanelGroup
[ndResizablePanelGroup]A group of resizable panels (@ndop/primitives/resizable), side by side
or stacked, with a handle between each pair.
| ResizablePanelGroup binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[orientation]Whether the panels sit side by side or stacked. | 'horizontal' | 'vertical' | 'horizontal' |
[keyboardStep]How far an arrow key moves a handle, in percent. | number | 10 |
[(sizes)]The panels' sizes in percent, in document order. Two-way bindable with
[(sizes)]; when unset, each panel's defaultSize applies. | readonly number[] | [] |
| Data attribute | Values |
|---|---|
data-slot | resizable-panel-group |
data-orientation | — |
ResizablePanel
[ndResizablePanel]One panel; its size is in percent of the group.
Manages id.
| ResizablePanel binding | Type | Default |
|---|---|---|
[defaultSize]Initial size in percent; without one, it shares the space left. | number | undefined | undefined |
[minSize]Smallest size in percent. | number | 0 |
[maxSize]Largest size in percent. | number | 100 |
| Data attribute | Values |
|---|---|
data-slot | resizable-panel |
data-size | — |
ResizableHandle
[ndResizableHandle]role separatorThe line between two panels, which arrow keys and dragging move. Put a
ResizableHandleGrip in it to show it can be dragged. Name it
with aria-label.
Manages aria-orientation, aria-valuenow, aria-valuemin, aria-valuemax, aria-controls, tabindex.
| ResizableHandle binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[disabled]Stops it moving and takes it out of the tab order. | boolean | false |
| Data attribute | Values |
|---|---|
data-slot | resizable-handle |
data-dragging | — |
data-disabled | — |
ResizableHandleGrip
[ndResizableHandleGrip]A grip in a handle, showing it can be dragged. It's decorative.
| ResizableHandleGrip binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | resizable-handle-grip |
Agent notes
Do
- Name each handle with
aria-label, such as "Resize sidebar". - Set a
minSizethat keeps each panel usable. - Show a grip with
ResizableHandleGripwhen the handle is hard to see.
Don't
- Don't nest so many groups that the handles are hard to tell apart.
- Don't put the only way to reach content in a panel that can shrink to nothing.