Skip to content
Brok UI

Loading…

No results

Node Resizer

Open source

Edge and corner drag handles that resize an atomic editor node (image or embed), with keyboard support and optional aspect-ratio lock.

Version
v1.1.2
Stability
stable
License
MIT
Related
Resizable
Editor Media
Table Handle

Preview

Hover the image and drag either edge; arrow keys resize a focused handle.

previews.components.node-resizer.default.blade.php 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>

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/node-resizer.blade.php
  • js resources/js/ui/node-resizer.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: 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.

node-resizer.md
# 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

node-resizer node-resizer-handle

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

Rich authoring and editing

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
root node-resizer-handle
Theming hooks
node-resizer node-resizer-handle

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<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.

livewire-form.blade.php Blade
<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.

resources/views/components/ui/node-resizer.blade.php Blade
@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>
resources/js/ui/node-resizer.js JS
/**
 * 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