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 |