Resizable

A split-pane layout with draggable handles to redistribute space between panels.

Loading...
<twig:Resizable orientation="horizontal" class="max-w-sm rounded-lg border h-[200px]">
    <twig:Resizable:Panel>
        <div class="flex h-full items-center justify-center p-6">
            <span class="font-semibold">One</span>
        </div>
    </twig:Resizable:Panel>
    <twig:Resizable:Handle withHandle />
    <twig:Resizable:Panel>
        <twig:Resizable orientation="vertical">
            <twig:Resizable:Panel size="25">
                <div class="flex h-full items-center justify-center p-6">
                    <span class="font-semibold">Two</span>
                </div>
            </twig:Resizable:Panel>
            <twig:Resizable:Handle withHandle />
            <twig:Resizable:Panel size="75">
                <div class="flex h-full items-center justify-center p-6">
                    <span class="font-semibold">Three</span>
                </div>
            </twig:Resizable:Panel>
        </twig:Resizable>
    </twig:Resizable:Panel>
</twig:Resizable>

Installation

bin/console ux:install resizable --kit shadcn

That's it!

Install the following Composer dependencies:

composer require tales-from-a-dev/twig-tailwind-extra:^1.0.0

Copy the following file(s) into your Symfony app:

assets/controllers/resizable_controller.js
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
    static targets = ['panel', 'handle'];
    static values = { orientation: { type: String, default: 'horizontal' } };

    connect() {
        this.handleTargets.forEach((handle) => {
            handle.addEventListener('pointerdown', (event) => this._onPointerDown(event, handle));
            handle.addEventListener('keydown', (event) => this._onKeyDown(event, handle));
        });
    }

    _onPointerDown(event, handle) {
        if (event.button !== 0 && event.pointerType === 'mouse') return;
        event.preventDefault();
        handle.setPointerCapture?.(event.pointerId);

        const isVertical = this.orientationValue === 'vertical';
        const isRtl = !isVertical && getComputedStyle(this.element).direction === 'rtl';
        const siblings = this._neighborPanels(handle);
        if (!siblings) return;
        const { prev, next } = siblings;

        const startCoord = isVertical ? event.clientY : event.clientX;
        const prevStart = isVertical ? prev.getBoundingClientRect().height : prev.getBoundingClientRect().width;
        const nextStart = isVertical ? next.getBoundingClientRect().height : next.getBoundingClientRect().width;
        const total = prevStart + nextStart;
        const min = 20;

        const onMove = (e) => {
            const cur = isVertical ? e.clientY : e.clientX;
            let delta = cur - startCoord;
            if (isRtl) delta = -delta;
            let newPrev = Math.max(min, Math.min(total - min, prevStart + delta));
            let newNext = total - newPrev;
            prev.style.flex = `${newPrev} 1 0%`;
            next.style.flex = `${newNext} 1 0%`;
        };
        const onUp = () => {
            window.removeEventListener('pointermove', onMove);
            window.removeEventListener('pointerup', onUp);
        };
        window.addEventListener('pointermove', onMove);
        window.addEventListener('pointerup', onUp);
    }

    _onKeyDown(event, handle) {
        const isVertical = this.orientationValue === 'vertical';
        const isRtl = !isVertical && getComputedStyle(this.element).direction === 'rtl';
        const step = event.shiftKey ? 40 : 8;

        let direction = 0;
        if (isVertical) {
            if (event.key === 'ArrowUp') direction = -1;
            else if (event.key === 'ArrowDown') direction = 1;
        } else {
            if (event.key === 'ArrowLeft') direction = isRtl ? 1 : -1;
            else if (event.key === 'ArrowRight') direction = isRtl ? -1 : 1;
        }
        if (direction === 0) return;
        event.preventDefault();

        const siblings = this._neighborPanels(handle);
        if (!siblings) return;
        const { prev, next } = siblings;

        const prevStart = isVertical ? prev.getBoundingClientRect().height : prev.getBoundingClientRect().width;
        const nextStart = isVertical ? next.getBoundingClientRect().height : next.getBoundingClientRect().width;
        const total = prevStart + nextStart;
        const min = 20;
        let newPrev = Math.max(min, Math.min(total - min, prevStart + direction * step));
        let newNext = total - newPrev;
        prev.style.flex = `${newPrev} 1 0%`;
        next.style.flex = `${newNext} 1 0%`;
    }

    _neighborPanels(handle) {
        const children = Array.from(this.element.children);
        const index = children.indexOf(handle);
        if (index <= 0 || index >= children.length - 1) return null;
        return { prev: children[index - 1], next: children[index + 1] };
    }
}
templates/components/Resizable.html.twig
{# @prop orientation 'horizontal'|'vertical' Layout direction. Defaults to `horizontal` #}
{# @block content One or more `Resizable:Panel` separated by `Resizable:Handle` #}
{%- props orientation = 'horizontal' -%}
<div
    class="{{ ('group/resizable flex h-full w-full ' ~ (orientation == 'vertical' ? 'flex-col ' : '') ~ attributes.render('class'))|tailwind_merge }}"
    {{ attributes.defaults({
        'data-slot': 'resizable',
        'data-controller': 'resizable',
        'data-resizable-orientation-value': orientation,
        'data-orientation': orientation,
    }) }}
>
    {%- block content %}{% endblock -%}
</div>
templates/components/Resizable/Handle.html.twig
{# @prop withHandle bool Show a visible grip icon on the handle. Defaults to `false` #}
{%- props withHandle = false -%}
<div
    class="{{ ('relative flex items-center justify-center bg-border focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring group-data-[orientation=horizontal]/resizable:w-px group-data-[orientation=horizontal]/resizable:h-full group-data-[orientation=horizontal]/resizable:cursor-col-resize group-data-[orientation=vertical]/resizable:h-px group-data-[orientation=vertical]/resizable:w-full group-data-[orientation=vertical]/resizable:cursor-row-resize ' ~ attributes.render('class'))|tailwind_merge }}"
    tabindex="0"
    role="separator"
    {{ attributes.defaults({
        'data-slot': 'resizable-handle',
        'data-resizable-target': 'handle',
    }) }}
>
    {% if withHandle %}
        <div class="z-10 flex items-center justify-center rounded-sm border bg-border group-data-[orientation=horizontal]/resizable:h-4 group-data-[orientation=horizontal]/resizable:w-3 group-data-[orientation=vertical]/resizable:h-3 group-data-[orientation=vertical]/resizable:w-4">
            <twig:ux:icon name="lucide:grip-vertical" class="size-2.5 group-data-[orientation=vertical]/resizable:rotate-90" aria-hidden="true" />
        </div>
    {% endif %}
</div>
templates/components/Resizable/Panel.html.twig
{# @prop size int|null Initial size as a flex-grow value (proportional). If omitted, panels share space equally. #}
{# @block content The panel content #}
{%- props size = null -%}
<div
    class="{{ ('overflow-auto ' ~ attributes.render('class'))|tailwind_merge }}"
    style="flex: {{ size ?? 1 }} 1 0%;"
    {{ attributes.defaults({
        'data-slot': 'resizable-panel',
        'data-resizable-target': 'panel',
    }) }}
>
    {%- block content %}{% endblock -%}
</div>

Happy coding!

Usage

<twig:Resizable class="h-40 w-64 p-4">
    <p>Drag the bottom-right corner to resize me in any direction.</p>
</twig:Resizable>

Examples

Vertical

Loading...
<twig:Resizable orientation="vertical" class="max-w-md rounded-lg border min-h-[200px] h-[200px]">
    <twig:Resizable:Panel>
        <div class="flex h-full items-center justify-center p-6">
            <span class="font-semibold">Header</span>
        </div>
    </twig:Resizable:Panel>
    <twig:Resizable:Handle />
    <twig:Resizable:Panel>
        <div class="flex h-full items-center justify-center p-6">
            <span class="font-semibold">Content</span>
        </div>
    </twig:Resizable:Panel>
</twig:Resizable>

Handle

Loading...
<twig:Resizable orientation="horizontal" class="max-w-md rounded-lg border h-[200px]">
    <twig:Resizable:Panel>
        <div class="flex h-full items-center justify-center p-6">
            <span class="font-semibold">Sidebar</span>
        </div>
    </twig:Resizable:Panel>
    <twig:Resizable:Handle withHandle />
    <twig:Resizable:Panel>
        <div class="flex h-full items-center justify-center p-6">
            <span class="font-semibold">Content</span>
        </div>
    </twig:Resizable:Panel>
</twig:Resizable>

RTL

Loading...
<div class="flex flex-col items-center gap-6">
    {# Arabic #}
    <div dir="rtl" class="w-full max-w-xl">
        <twig:Resizable orientation="horizontal" class="rounded-lg border h-[200px]">
            <twig:Resizable:Panel>
                <div class="flex h-full items-center justify-center p-6">
                    <span class="font-semibold">واحد</span>
                </div>
            </twig:Resizable:Panel>
            <twig:Resizable:Handle withHandle />
            <twig:Resizable:Panel>
                <twig:Resizable orientation="vertical">
                    <twig:Resizable:Panel size="25">
                        <div class="flex h-full items-center justify-center p-6">
                            <span class="font-semibold">اثنان</span>
                        </div>
                    </twig:Resizable:Panel>
                    <twig:Resizable:Handle withHandle />
                    <twig:Resizable:Panel size="75">
                        <div class="flex h-full items-center justify-center p-6">
                            <span class="font-semibold">ثلاثة</span>
                        </div>
                    </twig:Resizable:Panel>
                </twig:Resizable>
            </twig:Resizable:Panel>
        </twig:Resizable>
    </div>

    {# Hebrew #}
    <div dir="rtl" class="w-full max-w-xl">
        <twig:Resizable orientation="horizontal" class="rounded-lg border h-[200px]">
            <twig:Resizable:Panel>
                <div class="flex h-full items-center justify-center p-6">
                    <span class="font-semibold">אחד</span>
                </div>
            </twig:Resizable:Panel>
            <twig:Resizable:Handle withHandle />
            <twig:Resizable:Panel>
                <twig:Resizable orientation="vertical">
                    <twig:Resizable:Panel size="25">
                        <div class="flex h-full items-center justify-center p-6">
                            <span class="font-semibold">שניים</span>
                        </div>
                    </twig:Resizable:Panel>
                    <twig:Resizable:Handle withHandle />
                    <twig:Resizable:Panel size="75">
                        <div class="flex h-full items-center justify-center p-6">
                            <span class="font-semibold">שלושה</span>
                        </div>
                    </twig:Resizable:Panel>
                </twig:Resizable>
            </twig:Resizable:Panel>
        </twig:Resizable>
    </div>
</div>

API Reference

Resizable

Prop Type Description
orientation 'horizontal'|'vertical' Layout direction. Defaults to horizontal
Block Description
content One or more Resizable:Panel separated by Resizable:Handle

Resizable:Handle

Prop Type Description
withHandle bool Show a visible grip icon on the handle. Defaults to false

Resizable:Panel

Prop Type Description
size int|null Initial size as a flex-grow value (proportional). If omitted, panels share space equally.
Block Description
content The panel content