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
Note
Available since UX Toolkit 3.0.
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 aria-[orientation=vertical]:flex-col ' ~ attributes.render('class'))|tailwind_merge }}"
{{ attributes.defaults({
'data-slot': 'resizable-panel-group',
'data-controller': 'resizable',
'data-resizable-orientation-value': orientation,
'data-orientation': orientation,
'aria-orientation': orientation,
}) }}
>
{%- block content %}{% endblock -%}
</div>
templates/components/Resizable/Handle.html.twig
{# @prop withHandle bool Show a visible grip handle. Defaults to `false` #}
{%- props withHandle = false -%}
<div
class="{{ ('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:[&>div]:rotate-90 ' ~ 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 h-6 w-1 shrink-0 rounded-lg bg-border"></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
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
Component Resizable
| Prop | Type | Description |
|---|---|---|
orientation |
'horizontal'|'vertical' |
Layout direction. Defaults to horizontal |
| Block | Description |
|---|---|
content |
One or more Resizable:Panel separated by Resizable:Handle |
Component Resizable:Handle
| Prop | Type | Description |
|---|---|---|
withHandle |
bool |
Show a visible grip handle. Defaults to false |
Component 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 |