Resizable
resizableuiDrag-to-resize panel layout — horizontal or vertical splits with persistent sizes. Use for IDE-style sidebars, split views, and any layout the user should be able to reshape.
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/resizable.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/resizable.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/resizable.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/resizable.jsonManifest:
https://uipkge.dev/r/svelte/resizable.jsonInstalls to:src/lib/components/ui/resizable/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
withHandleRender a grip indicator on the divider. | boolean | false | optional |
disabledWhen `true`, this handle cannot be dragged. | boolean | — | optional |
children | Snippet | — | optional |
ref | HTMLDivElement | null | — | optional |
npm dependencies
Files installed (4)
src/lib/components/ui/resizable/ResizableHandle.svelte5.6 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' import type { ResizableDirection, ResizableGroupContext } from './ResizablePanelGroup.svelte' export interface ResizableHandleProps extends HTMLAttributes<HTMLDivElement> { /** Render a grip indicator on the divider. */ withHandle?: boolean /** When `true`, this handle cannot be dragged. */ disabled?: boolean children?: Snippet ref?: HTMLDivElement | null } export type { ResizableDirection } </script> <script lang="ts"> import { getContext } from 'svelte' import { GripVertical } from '@lucide/svelte' import { cn } from '$lib/utils' let { class: className, withHandle = false, disabled = undefined, children, onkeydown: onkeydownProp, ref = $bindable(null), ...restProps }: ResizableHandleProps = $props() const group = getContext<ResizableGroupContext | undefined>('resizableGroup') let el: HTMLDivElement | null = null $effect(() => { ref = el }) const effectiveDisabled = $derived(disabled ?? group?.disabled ?? false) const direction = $derived(group?.direction ?? 'horizontal') const vertical = $derived(direction === 'vertical') /** Panels adjacent to this handle, resolved through DOM order. */ function adjacentPanels(): [string, string] | null { if (!el || !group) return null const panels = [...(el.parentElement?.querySelectorAll(':scope > [data-slot="resizable-panel"]') ?? [])] const handleIndex = [...(el.parentElement?.children ?? [])].indexOf(el) const before = [...panels].filter((p) => [...(el!.parentElement?.children ?? [])].indexOf(p) < handleIndex).pop() const after = [...panels].find((p) => [...(el!.parentElement?.children ?? [])].indexOf(p) > handleIndex) const beforeId = before?.getAttribute('data-panel-id') const afterId = after?.getAttribute('data-panel-id') if (!beforeId || !afterId) return null return [beforeId, afterId] } function groupSpan(): number { const parent = el?.parentElement if (!parent) return 0 const rect = parent.getBoundingClientRect() return vertical ? rect.height : rect.width } function onPointerDown(event: PointerEvent) { if (effectiveDisabled || !group) return const pair = adjacentPanels() if (!pair) return event.preventDefault() ;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId) const span = groupSpan() const start = vertical ? event.clientY : event.clientX const [beforeId, afterId] = pair let last = start const move = (e: PointerEvent) => { const pos = vertical ? e.clientY : e.clientX if (span > 0) group.resize(beforeId, afterId, ((pos - last) / span) * 100) last = pos } const up = () => { window.removeEventListener('pointermove', move) window.removeEventListener('pointerup', up) window.removeEventListener('pointercancel', up) } window.addEventListener('pointermove', move) window.addEventListener('pointerup', up) window.addEventListener('pointercancel', up) } function handleKeydown(event: KeyboardEvent) { if (effectiveDisabled || !group) return const pair = adjacentPanels() if (!pair) return const step = event.shiftKey ? 10 : 2 let delta: number | null = null if (!vertical && event.key === 'ArrowRight') delta = step else if (!vertical && event.key === 'ArrowLeft') delta = -step else if (vertical && event.key === 'ArrowDown') delta = step else if (vertical && event.key === 'ArrowUp') delta = -step else if (event.key === 'Home') delta = -100 else if (event.key === 'End') delta = 100 else return event.preventDefault() group.resize(pair[0], pair[1], delta) } const ariaValue = $derived.by(() => { if (!group) return undefined const pair = adjacentPanels() if (!pair) return undefined return Math.round(group.sizeOf(pair[0]) * 10) / 10 }) </script> <!-- role="separator" owns keyboard + pointer resizing; the svelte a11y rule only whitelists a subset of interactive roles. --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_tabindex --> <div bind:this={el} role="separator" tabindex={effectiveDisabled ? -1 : 0} aria-orientation={vertical ? 'vertical' : 'horizontal'} aria-valuemin={0} aria-valuemax={100} aria-valuenow={ariaValue} aria-disabled={effectiveDisabled || undefined} data-uipkge="" data-slot="resizable-handle" data-orientation={direction} data-disabled={effectiveDisabled || undefined} class={cn( 'bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[orientation=vertical]:h-px data-[orientation=vertical]:w-full data-[orientation=vertical]:after:left-0 data-[orientation=vertical]:after:h-1 data-[orientation=vertical]:after:w-full data-[orientation=vertical]:after:translate-x-0 data-[orientation=vertical]:after:-translate-y-1/2 [&[data-orientation=vertical]>div]:rotate-90', effectiveDisabled && 'pointer-events-none opacity-50', className, )} {...restProps} onpointerdown={onPointerDown} onkeydown={(e) => { handleKeydown(e) onkeydownProp?.(e) }} > {#if withHandle} <div class="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-sm border"> {#if children} {@render children()} {:else} <GripVertical class="size-2.5" aria-hidden="true" /> {/if} </div> {/if} </div>src/lib/components/ui/resizable/ResizablePanel.svelte1.6 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' import type { ResizableGroupContext } from './ResizablePanelGroup.svelte' export interface ResizablePanelProps extends HTMLAttributes<HTMLDivElement> { /** Stable id used for layout bookkeeping + handle aria-controls. Auto-generated when omitted. */ id?: string /** Initial size in percent. Panels without one split the remainder equally. */ defaultSize?: number /** Minimum size in percent. */ minSize?: number /** Maximum size in percent. */ maxSize?: number children?: Snippet ref?: HTMLDivElement | null } </script> <script lang="ts"> import { getContext } from 'svelte' import { cn } from '$lib/utils' let { class: className, id: idProp = undefined, defaultSize = undefined, minSize = undefined, maxSize = undefined, children, ref = $bindable(null), ...restProps }: ResizablePanelProps = $props() const group = getContext<ResizableGroupContext | undefined>('resizableGroup') const autoId = $props.id() const panelId = $derived(idProp ?? `resizable-panel-${autoId}`) $effect(() => { if (!group) return group.registerPanel(panelId, { defaultSize, minSize, maxSize }) return () => group.unregisterPanel(panelId) }) const size = $derived(group?.sizeOf(panelId) ?? 0) </script> <div bind:this={ref} data-uipkge="" data-slot="resizable-panel" data-panel-id={panelId} id={panelId} style="flex: {size} 1 0px; min-height: 0; min-width: 0;" class={cn('relative', className)} {...restProps} > {@render children?.()} </div>src/lib/components/ui/resizable/ResizablePanelGroup.svelte5.1 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' export type ResizableDirection = 'horizontal' | 'vertical' export interface ResizablePanelSpec { defaultSize?: number minSize?: number maxSize?: number } /** Context shared with ResizablePanel / ResizableHandle. Getters stay reactive in children. */ export interface ResizableGroupContext { readonly direction: ResizableDirection readonly disabled: boolean sizeOf: (id: string) => number registerPanel: (id: string, spec: ResizablePanelSpec) => void unregisterPanel: (id: string) => void resize: (beforeId: string, afterId: string, delta: number) => void panelIdAt: (index: number) => string | undefined panelCount: () => number } export interface ResizablePanelGroupProps extends HTMLAttributes<HTMLDivElement> { /** Split direction. Default 'horizontal'. */ direction?: ResizableDirection /** When `true`, handles cannot be dragged. */ disabled?: boolean /** Called with the ordered panel sizes (percent) after every resize commit. */ onLayoutChange?: (sizes: number[]) => void children?: Snippet ref?: HTMLDivElement | null } </script> <script lang="ts"> import { setContext } from 'svelte' import { cn } from '$lib/utils' let { class: className, direction = 'horizontal', disabled = false, onLayoutChange, children, ref = $bindable(null), ...restProps }: ResizablePanelGroupProps = $props() let root: HTMLDivElement | null = null // Explicit sizes written by drag/keyboard commits, keyed by panel id. let overrides = $state<Record<string, number>>({}) let specs = $state<Record<string, ResizablePanelSpec>>({}) // Bump to recompute order after mount/unmount (DOM order is the source of truth). let registryVersion = $state(0) $effect(() => { ref = root }) /** Panel ids in DOM order. */ function orderedIds(): string[] { if (!root) return Object.keys(specs) return [...root.querySelectorAll(':scope > [data-slot="resizable-panel"]')] .map((el) => el.getAttribute('data-panel-id')) .filter((id): id is string => id != null && id in specs) } /** Resolved layout: overrides win, then defaultSize, remainder split equally, normalized to 100. */ const layout = $derived.by(() => { registryVersion const ids = orderedIds() const explicit = new Map<string, number>() let remainder = 100 const flexible: string[] = [] for (const id of ids) { const spec = specs[id]! const pinned = overrides[id] ?? spec.defaultSize if (pinned != null) { explicit.set(id, pinned) remainder -= pinned } else { flexible.push(id) } } if (flexible.length > 0) { const share = remainder / flexible.length for (const id of flexible) explicit.set(id, share) } const total = [...explicit.values()].reduce((a, b) => a + b, 0) || 1 return ids.map((id) => ({ id, size: ((explicit.get(id) ?? 0) / total) * 100 })) }) function sizeOf(id: string): number { return layout.find((p) => p.id === id)?.size ?? 0 } function emitLayout() { onLayoutChange?.(layout.map((p) => Math.round(p.size * 100) / 100)) } function resize(beforeId: string, afterId: string, delta: number) { if (disabled || delta === 0) return const before = layout.find((p) => p.id === beforeId) const after = layout.find((p) => p.id === afterId) if (!before || !after) return const beforeSpec = specs[beforeId] ?? {} const afterSpec = specs[afterId] ?? {} const beforeMin = beforeSpec.minSize ?? 0 const beforeMax = beforeSpec.maxSize ?? 100 const afterMin = afterSpec.minSize ?? 0 const afterMax = afterSpec.maxSize ?? 100 // Clamp the delta so neither panel leaves its [min, max] window. const clamped = Math.min( Math.max(delta, beforeMin - before.size, after.size - afterMax), beforeMax - before.size, after.size - afterMin, ) if (clamped === 0) return overrides = { ...overrides, [beforeId]: before.size + clamped, [afterId]: after.size - clamped, } emitLayout() } setContext<ResizableGroupContext>('resizableGroup', { get direction() { return direction }, get disabled() { return disabled }, sizeOf, registerPanel: (id, spec) => { specs = { ...specs, [id]: spec } registryVersion += 1 }, unregisterPanel: (id) => { const { [id]: _removedSpec, ...restSpecs } = specs void _removedSpec specs = restSpecs const { [id]: _removedOverride, ...restOverrides } = overrides void _removedOverride overrides = restOverrides registryVersion += 1 }, resize, panelIdAt: (index) => orderedIds()[index], panelCount: () => orderedIds().length, }) </script> <div bind:this={root} data-uipkge="" data-slot="resizable-panel-group" data-orientation={direction} data-disabled={disabled || undefined} class={cn('flex h-full w-full data-[orientation=vertical]:flex-col', className)} {...restProps} > {@render children?.()} </div>src/lib/components/ui/resizable/index.ts0.4 kB
export { default as ResizablePanelGroup, type ResizableDirection, type ResizableGroupContext, type ResizablePanelGroupProps, type ResizablePanelSpec, } from './ResizablePanelGroup.svelte' export { default as ResizablePanel, type ResizablePanelProps } from './ResizablePanel.svelte' export { default as ResizableHandle, type ResizableHandleProps } from './ResizableHandle.svelte'
Raw manifest:https://uipkge.dev/r/svelte/resizable.json