Node Resizer
Edge and corner drag handles that resize an atomic editor node (image or embed), with keyboard support and optional aspect-ratio lock.
Preview
Hover the image and drag either edge; arrow keys resize a focused handle.
<div class="w-full flex flex-col items-center gap-3 p-6">
<x-ui.node-resizer aspectRatio="16/9" :width="360" :min="160" :max="640" ariaLabel="Resizable image" data-demo="hover">
<img src="https://picsum.photos/seed/resizer/960/540" alt="" class="block size-full rounded-lg object-cover" loading="lazy" />
</x-ui.node-resizer>
<p class="text-xs text-muted-foreground">{{ __('Hover the image and drag either edge; arrow keys resize a focused handle.') }}</p>
</div>
Installation
php artisan ui:add node-resizer
Note
This component ships an Alpine behavior module at
resources/js/ui/node-resizer.js. Import it once from your bundle so it registers on alpine:init:
import './node-resizer.js';
Registry contract
php artisan ui:add node-resizer
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/node-resizer.blade.php -
resources/js/ui/node-resizer.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejs
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Node Resizer (`node-resizer`)
Edge and corner drag handles that resize an atomic editor node (image or embed), with keyboard support and optional aspect-ratio lock.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add node-resizer
```
## Usage
```blade
<div class="w-full flex flex-col items-center gap-3 p-6">
<x-ui.node-resizer aspectRatio="16/9" :width="360" :min="160" :max="640" ariaLabel="Resizable image" data-demo="hover">
<img src="https://picsum.photos/seed/resizer/960/540" alt="" class="block size-full rounded-lg object-cover" loading="lazy" />
</x-ui.node-resizer>
<p class="text-xs text-muted-foreground">{{ __('Hover the image and drag either edge; arrow keys resize a focused handle.') }}</p>
</div>
```
## Props
- `width` (mixed|null, default `null`) — Initial pixel width; null keeps the node's intrinsic width.
- `min` (int, default `80`) — Minimum pixel width the node can be resized to.
- `max` (mixed|null, default `null`) — Maximum pixel width the node can be resized to; null leaves it unbounded.
- `aspectRatio` (mixed|null, default `null`) — Locks height to width using this ratio, such as "16/9" or a number, while resizing.
- `ariaLabel` (string, default `Resizable media`) — Base accessible label for the resize handles.
## Use when
- Use when users need rich structured authoring, annotation, or collaborative editing controls.
- Wrapping an atomic editor node, such as an image, video, figure, or embed, with drag handles to resize its width.
## Avoid when
- Do not use rich editor affordances for simple one-line or plain-text tasks.
- A general split-pane layout needs resizing rather than a single editor node; use resizable instead.
## Anti-patterns
- Rich editors for one-line or easily validated input
## Rules
- Use the `<brok:node-resizer>` tag (or `<x-ui.node-resizer>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/node-resizer
- Registry JSON (files, props, contract): https://brokui.dev/r/open/node-resizer.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| width | mixed | null | null | Initial pixel width; null keeps the node's intrinsic width. |
| min | int | 80 | Minimum pixel width the node can be resized to. |
| max | mixed | null | null | Maximum pixel width the node can be resized to; null leaves it unbounded. |
| aspectRatio | mixed | null | null | Locks height to width using this ratio, such as "16/9" or a number, while resizing. |
| ariaLabel | string | Resizable media | Base accessible label for the resize handles. |
Slots
default—
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Drag handles on the start and end edges resize the wrapped node's width, optionally locked to aspectRatio to keep height in proportion; width is clamped between min and max.
- A live size read-out, width by height or just width, appears while dragging, and geometry updates only ever set inline width and height, never colour.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Create or revise structured rich content.
Use when
- Use when users need rich structured authoring, annotation, or collaborative editing controls.
- Wrapping an atomic editor node, such as an image, video, figure, or embed, with drag handles to resize its width.
Avoid when
- Do not use rich editor affordances for simple one-line or plain-text tasks.
- A general split-pane layout needs resizing rather than a single editor node; use resizable instead.
Use instead
- Textarea or plain field for simple content
Anti-patterns
- Rich editors for one-line or easily validated input
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- native
- Focus
managed
- Each handle is role="slider" with aria-valuenow/min/max reflecting the current width and supports arrow-key resizing from the keyboard.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="node-resizer-{{ $record->id }}">
<div class="w-full flex flex-col items-center gap-3 p-6">
<x-ui.node-resizer aspectRatio="16/9" :width="360" :min="160" :max="640" ariaLabel="Resizable image" data-demo="hover">
<img src="https://picsum.photos/seed/resizer/960/540" alt="" class="block size-full rounded-lg object-cover" loading="lazy" />
</x-ui.node-resizer>
<p class="text-xs text-muted-foreground">{{ __('Hover the image and drag either edge; arrow keys resize a focused handle.') }}</p>
</div>
</div>
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:node-resizer
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'width' => null, // initial px width (null = intrinsic)
'min' => 80, // minimum px width
'max' => null, // maximum px width (null = unbounded)
'aspectRatio' => null, // '16/9' or a number — locks height when set
'ariaLabel' => 'Resizable media',
])
{{--
Node Resizer: wraps an atomic editor node (image / video / figure / embed) and
exposes drag handles to resize its width — optionally locked to an aspect ratio.
This is the ProseKit-style "Resizable" for editor nodes; it is NOT the
split-pane layout primitive (that is <x-ui.resizable>).
Handles live on the END edge (trailing edge) using logical positioning
(end-0 / -me-2) so the whole thing mirrors under dir="rtl"; the drag
direction is inverted in JS for RTL too. Each handle is a role="slider" with
arrow-key support for keyboard resizing.
The inline :style binding only ever sets geometry (width/height in px) — never
colour — so it stays within the token rules.
--}}
<div
x-data="uiNodeResizer({ width: @js($width), min: @js($min), max: @js($max), aspectRatio: @js($aspectRatio) })"
data-slot="node-resizer"
{{ $attributes->merge(['class' => 'group relative inline-block max-w-full align-top']) }}
:style="styleString()"
:data-dragging="dragging"
>
{{-- The wrapped atomic node (image / embed / figure). --}}
@if (trim($slot) === '')
{{-- Standalone placeholder so the component renders something on its own. --}}
<div class="flex aspect-video w-full items-center justify-center rounded-lg border border-dashed border-border bg-muted text-muted-foreground">
<span class="flex items-center gap-2 text-sm">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
{{ __('Image') }}
</span>
</div>
@else
{{ $slot }}
@endif
{{-- Selection frame: a ring around the node while it is hovered, focused
or being resized, so the handles have an edge to belong to. --}}
<span
aria-hidden="true"
class="pointer-events-none absolute inset-0 rounded-lg ring-2 ring-primary/0 transition-[box-shadow] duration-150 group-hover:ring-primary/60 group-focus-within:ring-primary/60 group-data-[dragging=true]:ring-primary motion-reduce:transition-none"
></span>
{{-- Size read-out while dragging. --}}
<span
x-show="dragging"
x-cloak
aria-hidden="true"
class="pointer-events-none absolute bottom-2 end-2 rounded-sm bg-foreground/80 px-2 py-0.5 text-2xs font-medium tabular-nums text-background"
x-text="height != null ? `${Math.round(width)} × ${Math.round(height)}` : `${Math.round(width)} px`"
></span>
{{--
Resize handles — pills on the start and end edges, revealed on hover /
keyboard focus within the group. Width is reflected through
aria-valuenow/min/max so assistive tech reads the slider value.
--}}
@foreach (['start', 'end'] as $edge)
<button
type="button"
data-slot="node-resizer-handle"
data-edge="{{ $edge }}"
role="slider"
tabindex="0"
aria-orientation="horizontal"
aria-label="{{ $ariaLabel }} — {{ $edge === 'start' ? __('resize from the start edge') : __('resize from the end edge') }}"
:aria-valuenow="Math.round(width)"
:aria-valuemin="min"
:aria-valuemax="max ?? 4096"
@pointerdown.prevent="startDrag($event, '{{ $edge }}')"
@keydown="onKey($event)"
class="absolute top-1/2 {{ $edge === 'start' ? 'start-0 -ms-2' : 'end-0 -me-2' }} z-10 h-10 w-4 -translate-y-1/2 cursor-ew-resize rounded-full border-2 border-background bg-foreground opacity-0 shadow-sm transition-[opacity,scale] duration-150 group-hover:opacity-100 group-focus-within:opacity-100 group-data-[dragging=true]:opacity-100 hover:scale-110 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none"
></button>
@endforeach
</div>
/**
* Node Resizer behaviour layer (uiNodeResizer).
*
* Drag-to-resize controller for an atomic editor node (image / video / embed).
* Pointer-driven width resizing from the trailing (end) edge, with optional
* aspect-ratio locked height and full keyboard operation (the handles are
* role="slider" — arrow keys nudge the width).
*
* Listener discipline mirrors uiEditor: the global pointermove / pointerup
* listeners are attached to `window` ONLY while a drag is in progress and are
* torn down on pointerup. destroy() defensively removes any that linger so an
* unmount mid-drag can never leak.
*
* RTL: when the document direction is RTL the end edge is on the left, so the
* horizontal delta is inverted to keep the drag feeling natural.
*
* Emits a bubbling `node-resize` event ({ width, height }) on every committed
* change (pointer move/up and keyboard nudge) so an editor document model can
* persist the new dimensions.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiNodeResizer', (config = {}) => ({
width: config.width ?? null,
min: config.min ?? 80,
max: config.max ?? null,
aspectRatio: parseAspectRatio(config.aspectRatio),
dragging: false,
// Per-drag scratch state.
_startX: 0,
_startW: 0,
_onMove: null,
_onUp: null,
init() {
// Resolve an intrinsic width when none was provided, once the node
// (image / embed) has laid out.
if (this.width == null) {
this.$nextTick(() => {
const rect = this.$el.getBoundingClientRect();
this.width = Math.round(rect.width) || this.min;
});
}
},
destroy() {
// Defensive teardown — never leak window listeners on unmount.
this._teardown();
},
/** Inline geometry only (px). Never sets colour. */
styleString() {
const parts = [];
if (this.width != null) {
parts.push(`width: ${Math.round(this.width)}px`);
}
const h = this.height;
if (h != null) {
parts.push(`height: ${Math.round(h)}px`);
}
return parts.join('; ');
},
/** Locked height derived from the current width and aspect ratio. */
get height() {
if (!this.aspectRatio || this.width == null) {
return null;
}
return this.width / this.aspectRatio;
},
clamp(v, lo, hi) {
return Math.min(Math.max(v, lo), hi);
},
/** Drag direction multiplier — inverted under RTL (end edge is on the left). */
_direction() {
const dir = (this.$el.closest('[dir]')?.dir || document.dir || 'ltr').toLowerCase();
return dir === 'rtl' ? -1 : 1;
},
/** pointerdown on a handle — begin a drag. */
startDrag(e, edge = 'end') {
if (this.width == null) {
this.width = Math.round(this.$el.getBoundingClientRect().width) || this.min;
}
this._startX = e.clientX;
this._startW = this.width;
// A start-edge handle grows the node when dragged away from it.
this._edge = edge === 'start' ? -1 : 1;
this.dragging = true;
// Bind once so the same refs can be removed later.
this._onMove = (ev) => this._onPointerMove(ev);
this._onUp = (ev) => this._onPointerUp(ev);
window.addEventListener('pointermove', this._onMove);
window.addEventListener('pointerup', this._onUp);
},
_onPointerMove(e) {
if (!this.dragging) {
return;
}
const delta = (e.clientX - this._startX) * this._direction() * (this._edge ?? 1);
const hi = this.max ?? Infinity;
this.width = this.clamp(this._startW + delta, this.min, hi);
},
_onPointerUp() {
if (!this.dragging) {
return;
}
this.dragging = false;
this._teardown();
this._emit();
},
/** Arrow-key resizing for the role="slider" handles. */
onKey(e) {
const key = e.key;
if (key !== 'ArrowLeft' && key !== 'ArrowRight') {
return;
}
e.preventDefault();
if (this.width == null) {
this.width = Math.round(this.$el.getBoundingClientRect().width) || this.min;
}
const step = (e.shiftKey ? 48 : 16) * this._direction();
// ArrowRight grows, ArrowLeft shrinks (relative to reading direction).
const sign = key === 'ArrowRight' ? 1 : -1;
const hi = this.max ?? Infinity;
this.width = this.clamp(this.width + sign * step, this.min, hi);
this._emit();
},
_emit() {
this.$dispatch('node-resize', {
width: Math.round(this.width),
height: this.height != null ? Math.round(this.height) : null,
});
},
/** Remove any window listeners still attached. */
_teardown() {
if (this._onMove) {
window.removeEventListener('pointermove', this._onMove);
this._onMove = null;
}
if (this._onUp) {
window.removeEventListener('pointerup', this._onUp);
this._onUp = null;
}
},
}));
});
/** Parse '16/9', '16:9', or a number into a numeric ratio (w / h). */
function parseAspectRatio(value) {
if (value == null || value === '') {
return null;
}
if (typeof value === 'number') {
return value || null;
}
const match = String(value).split(/[/:]/);
if (match.length === 2) {
const w = parseFloat(match[0]);
const h = parseFloat(match[1]);
if (w > 0 && h > 0) {
return w / h;
}
return null;
}
const n = parseFloat(value);
return n > 0 ? n : null;
}
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.1.2 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0