View as Markdown

Resizable

Accessible resizable panel groups and layouts with keyboard support.

Loading...
<twig:Resizable orientation="horizontal" class="max-w-sm rounded-lg border h-[200px]">
    <twig:Resizable:Panel size="50">
        <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 size="50">
        <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

php bin/console ux:install resizable --kit shadcn

Install the following Composer dependencies:

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

Copy the following file(s) into your app:

import { Controller } from &#039;@hotwired/stimulus&#039;;

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

    connect() {
        this.handleTargets.forEach((handle) =&gt; {
            handle.addEventListener(&#039;pointerdown&#039;, (event) =&gt; this._onPointerDown(event, handle));
            handle.addEventListener(&#039;keydown&#039;, (event) =&gt; this._onKeyDown(event, handle));
        });
    }

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

        const isVertical = this.orientationValue === &#039;vertical&#039;;
        const isRtl = !isVertical &amp;&amp; getComputedStyle(this.element).direction === &#039;rtl&#039;;
        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) =&gt; {
            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 = () =&gt; {
            window.removeEventListener(&#039;pointermove&#039;, onMove);
            window.removeEventListener(&#039;pointerup&#039;, onUp);
        };
        window.addEventListener(&#039;pointermove&#039;, onMove);
        window.addEventListener(&#039;pointerup&#039;, onUp);
    }

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

        let direction = 0;
        if (isVertical) {
            if (event.key === &#039;ArrowUp&#039;) direction = -1;
            else if (event.key === &#039;ArrowDown&#039;) direction = 1;
        } else {
            if (event.key === &#039;ArrowLeft&#039;) direction = isRtl ? 1 : -1;
            else if (event.key === &#039;ArrowRight&#039;) 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 &lt;= 0 || index &gt;= children.length - 1) return null;
        return { prev: children[index - 1], next: children[index + 1] };
    }
}
{# @prop orientation &#039;horizontal&#039;|&#039;vertical&#039; Layout direction. #}
{# @block content One or more `Resizable:Panel` separated by `Resizable:Handle`. #}
{%- props orientation = &#039;horizontal&#039; -%}
&lt;div
    class=&quot;{{ (&#039;group/resizable flex h-full w-full aria-[orientation=vertical]:flex-col &#039; ~ attributes.render(&#039;class&#039;))|tailwind_merge }}&quot;
    {{ attributes.defaults({
        &#039;data-slot&#039;: &#039;resizable-panel-group&#039;,
        &#039;data-controller&#039;: &#039;resizable&#039;,
        &#039;data-resizable-orientation-value&#039;: orientation,
        &#039;data-orientation&#039;: orientation,
        &#039;aria-orientation&#039;: orientation,
    }) }}
&gt;
    {%- block content %}{% endblock -%}
&lt;/div&gt;
{# @prop withHandle bool Show a visible grip handle. #}
{%- props withHandle = false -%}
&lt;div
    class=&quot;{{ (&#039;relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 ltr:after:left-1/2 rtl:after:start-1/2 after:w-1 after:-translate-x-1/2 rtl:after:translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden 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 ltr:group-data-[orientation=vertical]/resizable:after:left-0 rtl:group-data-[orientation=vertical]/resizable:after:start-0 group-data-[orientation=vertical]/resizable:after:h-1 group-data-[orientation=vertical]/resizable:after:w-full group-data-[orientation=vertical]/resizable:after:translate-x-0 group-data-[orientation=vertical]/resizable:after:-translate-y-1/2 rtl:group-data-[orientation=vertical]/resizable:after:-translate-x-0 group-data-[orientation=vertical]/resizable:[&amp;&gt;div]:rotate-90 &#039; ~ attributes.render(&#039;class&#039;))|tailwind_merge }}&quot;
    tabindex=&quot;0&quot;
    role=&quot;separator&quot;
    {{ attributes.defaults({
        &#039;data-slot&#039;: &#039;resizable-handle&#039;,
        &#039;data-resizable-target&#039;: &#039;handle&#039;,
    }) }}
&gt;
    {% if withHandle %}
        &lt;div class=&quot;z-10 flex h-6 w-1 shrink-0 rounded-lg bg-border&quot;&gt;&lt;/div&gt;
    {% endif %}
&lt;/div&gt;
{# @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 -%}
&lt;div
    class=&quot;{{ (&#039;overflow-auto &#039; ~ attributes.render(&#039;class&#039;))|tailwind_merge }}&quot;
    style=&quot;flex: {{ size ?? 1 }} 1 0%;&quot;
    {{ attributes.defaults({
        &#039;data-slot&#039;: &#039;resizable-panel&#039;,
        &#039;data-resizable-target&#039;: &#039;panel&#039;,
    }) }}
&gt;
    {%- block content %}{% endblock -%}
&lt;/div&gt;

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

Use orientation="vertical" for vertical resizing.

Loading...
<twig:Resizable orientation="vertical" class="min-h-[200px] max-w-sm rounded-lg border h-[200px]">
    <twig:Resizable:Panel size="25">
        <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 size="75">
        <div class="flex h-full items-center justify-center p-6">
            <span class="font-semibold">Content</span>
        </div>
    </twig:Resizable:Panel>
</twig:Resizable>

Handle

Use the withHandle prop on Resizable:Handle to show a visible handle.

Loading...
<twig:Resizable orientation="horizontal" class="min-h-[200px] max-w-md rounded-lg border md:min-w-[450px] h-[200px]">
    <twig:Resizable:Panel size="25">
        <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 size="75">
        <div class="flex h-full items-center justify-center p-6">
            <span class="font-semibold">Content</span>
        </div>
    </twig:Resizable:Panel>
</twig:Resizable>

RTL

To enable RTL support, set the dir="rtl" attribute on the root element.

Loading...
<div class="flex flex-col gap-8">
    {# Arabic #}
    <twig:Resizable dir="rtl" orientation="horizontal" class="max-w-sm rounded-lg border h-[200px]">
        <twig:Resizable:Panel size="50">
            <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="50">
            <twig:Resizable orientation="vertical" dir="rtl">
                <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>

    {# Hebrew #}
    <twig:Resizable dir="rtl" orientation="horizontal" class="max-w-sm rounded-lg border h-[200px]">
        <twig:Resizable:Panel size="50">
            <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="50">
            <twig:Resizable orientation="vertical" dir="rtl">
                <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>

API Reference

<twig:Resizable>

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

<twig:Resizable:Handle>

Prop Type Default
withHandle 
bool false

<twig:Resizable:Panel>

Prop Type Default
size 
int|null null
Block Description
content The panel content.