Pointer-resizable panel group with size constraints
One
Two
Three
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
...import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
export default function ResizableDemoExample() {
return (
<ResizablePanelGroup direction="horizontal" class="max-w-md">
<ResizablePanel defaultSize={50} class="flex items-center justify-center"><strong>One</strong></ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={50}>
<ResizablePanelGroup direction="vertical">
<ResizablePanel defaultSize={25} class="flex items-center justify-center"><strong>Two</strong></ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={75} class="flex items-center justify-center"><strong>Three</strong></ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
)
}
Installation
npx @fictjs/shadcn@latest add resizableCopy the Fict shadcn component into your project.
Source
components/ui/resizable.tsx
src/components/ui/resizable.tsx
import { cn } from '@/lib/cn'
type GenericProps = {
class?: string
children?: unknown
[key: string]: unknown
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, value))
}
function readPanelLimit(panel: HTMLElement, name: 'minSize' | 'maxSize', fallback: number): number {
const value = Number(panel.dataset[name])
return Number.isFinite(value) ? value : fallback
}
function beginResize(event: PointerEvent): void {
const handle = event.currentTarget as HTMLElement
const group = handle.closest('[data-slot="resizable-panel-group"]') as HTMLElement | null
const previous = handle.previousElementSibling as HTMLElement | null
const next = handle.nextElementSibling as HTMLElement | null
if (!group || !previous || !next) return
const horizontal = group.dataset.direction !== 'vertical'
const groupRect = group.getBoundingClientRect()
const groupSize = horizontal ? groupRect.width : groupRect.height
if (groupSize <= 0) return
const previousRect = previous.getBoundingClientRect()
const nextRect = next.getBoundingClientRect()
const previousStart = ((horizontal ? previousRect.width : previousRect.height) / groupSize) * 100
const nextStart = ((horizontal ? nextRect.width : nextRect.height) / groupSize) * 100
const pairSize = previousStart + nextStart
const start = horizontal ? event.clientX : event.clientY
const minPrevious = readPanelLimit(previous, 'minSize', 5)
const maxPrevious = readPanelLimit(previous, 'maxSize', 95)
const minNext = readPanelLimit(next, 'minSize', 5)
const maxNext = readPanelLimit(next, 'maxSize', 95)
const onPointerMove = (moveEvent: PointerEvent) => {
const position = horizontal ? moveEvent.clientX : moveEvent.clientY
const delta = ((position - start) / groupSize) * 100
const lowerBound = Math.max(minPrevious, pairSize - maxNext)
const upperBound = Math.min(maxPrevious, pairSize - minNext)
const previousSize = clamp(previousStart + delta, lowerBound, upperBound)
const nextSize = pairSize - previousSize
previous.style.flexBasis = String(previousSize) + '%'
next.style.flexBasis = String(nextSize) + '%'
handle.setAttribute('aria-valuenow', String(Math.round(previousSize)))
}
const stopResize = () => {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', stopResize)
window.removeEventListener('pointercancel', stopResize)
handle.removeAttribute('data-resizing')
}
event.preventDefault()
handle.setAttribute('data-resizing', 'true')
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', stopResize, { once: true })
window.addEventListener('pointercancel', stopResize, { once: true })
}
export function ResizablePanelGroup(props: GenericProps) {
const { class: className, direction = 'horizontal', style, ...rest } = props
return (
<div
data-slot='resizable-panel-group'
data-direction={direction}
class={cn('flex h-full w-full rounded-lg border', direction === 'vertical' && 'flex-col', className)}
style={{
display: 'flex',
flexDirection: direction === 'vertical' ? 'column' : 'row',
...(typeof style === 'object' && style ? style : {}),
}}
{...rest}
/>
)
}
export function ResizablePanel(props: GenericProps) {
const { class: className, defaultSize = 50, minSize = 5, maxSize = 95, style, ...rest } = props
return (
<div
data-slot='resizable-panel'
data-min-size={minSize}
data-max-size={maxSize}
class={cn('overflow-auto p-4', className)}
style={{
flexBasis: String(defaultSize) + '%',
flexGrow: 0,
flexShrink: 0,
...(typeof style === 'object' && style ? style : {}),
}}
{...rest}
/>
)
}
export function ResizableHandle(props: GenericProps) {
const { class: className, onPointerDown, ...rest } = props
return (
<div
role='separator'
tabIndex={0}
aria-valuemin={0}
aria-valuemax={100}
data-slot='resizable-handle'
class={cn('relative bg-border after:absolute after:inset-0 after:m-auto after:h-10 after:w-1 after:rounded-full after:bg-muted-foreground/30', className)}
onPointerDown={(event: PointerEvent) => {
;(onPointerDown as ((event: PointerEvent) => void) | undefined)?.(event)
if (!event.defaultPrevented) beginResize(event)
}}
{...rest}
/>
)
}
Usage
Examples
Vertical
Header
Content
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
...import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
export default function ResizableVerticalExample() {
return (
<ResizablePanelGroup direction="vertical" class="min-h-[200px] max-w-md">
<ResizablePanel defaultSize={25} class="flex items-center justify-center"><strong>Header</strong></ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={75} class="flex items-center justify-center"><strong>Content</strong></ResizablePanel>
</ResizablePanelGroup>
)
}
Handle
Sidebar
Content
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
...import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
export default function ResizableHandleExample() {
return (
<ResizablePanelGroup direction="horizontal" class="max-w-md">
<ResizablePanel defaultSize={25} class="flex items-center justify-center"><strong>Sidebar</strong></ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={75} class="flex items-center justify-center"><strong>Content</strong></ResizablePanel>
</ResizablePanelGroup>
)
}
Rtl
واحد
اثنان
ثلاثة
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
...import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
const translations = {
ar: { dir: 'rtl', one: 'واحد', two: 'اثنان', three: 'ثلاثة' },
he: { dir: 'rtl', one: 'אחד', two: 'שניים', three: 'שלושה' },
en: { dir: 'ltr', one: 'One', two: 'Two', three: 'Three' },
} as const
export default function ResizableRtlExample() {
let language = $state<keyof typeof translations>('ar')
const text = () => translations[language]
return (
<div class="grid gap-4">
<select value={language} onChange={event => { language = event.currentTarget.value as keyof typeof translations }}>
<option value="ar">Arabic (العربية)</option><option value="he">Hebrew (עברית)</option><option value="en">English</option>
</select>
<ResizablePanelGroup dir={text().dir} direction="horizontal" class="max-w-md">
<ResizablePanel defaultSize={50} class="flex items-center justify-center"><strong>{text().one}</strong></ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={50}>
<ResizablePanelGroup direction="vertical">
<ResizablePanel defaultSize={25} class="flex items-center justify-center"><strong>{text().two}</strong></ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={75} class="flex items-center justify-center"><strong>{text().three}</strong></ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
</div>
)
}