Resizable
Panels side by side (or stacked) with handles between them to drag or move with the arrow keys.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the component:
npx ermineui add resizablepnpm dlx ermineui add resizableyarn dlx ermineui add resizablebunx ermineui add resizableThat copies 5 files into src/components/ui/resizable/, 1 helper into src/lib/ermine/. Files you already have are kept.
Use
import { ResizableHandle, ResizablePanel, ResizablePanels } from '@/components/ui/resizable'<Carousel v-model="slide" label="Recent releases">
<CarouselSlide v-for="release in releases" :key="release.id">…</CarouselSlide>
</Carousel>
<ResizablePanels v-model:sizes="sizes"> <!-- direction="vertical" stacks them -->
<ResizablePanel :default-size="25" :min-size="15">Files</ResizablePanel>
<ResizableHandle grip />
<ResizablePanel>Editor</ResizablePanel>
</ResizablePanels>In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.
What gets copied
| File | Where it goes |
|---|---|
ResizableHandle.vue | src/components/ui/resizable/ResizableHandle.vue |
ResizablePanel.vue | src/components/ui/resizable/ResizablePanel.vue |
ResizablePanels.vue | src/components/ui/resizable/ResizablePanels.vue |
context.ts | src/components/ui/resizable/context.ts |
index.ts | src/components/ui/resizable/index.ts |
context.ts helper | src/lib/ermine/context.ts |
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
ResizableHandle.vue 82 lines
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, useId } from 'vue'
import { useResizable } from './context'
const props = withDefaults(
defineProps<{
/** Show a small grip in the middle of the line. */
grip?: boolean
/** How far one press of an arrow key moves it, in percent. */
step?: number
/** Accessible name. */
label?: string
}>(),
{ step: 5, label: 'Resize' },
)
const group = useResizable()
const { index, unregister } = group.registerHandle(useId())
onBeforeUnmount(unregister)
const dragging = ref(false)
const before = computed(() => group.panels.value[index.value])
const after = computed(() => group.panels.value[index.value + 1])
const size = computed(() => group.sizes.value[index.value] ?? 0)
const pair = computed(() => size.value + (group.sizes.value[index.value + 1] ?? 0))
const min = computed(() => before.value?.minSize.value ?? 0)
const max = computed(() => pair.value - (after.value?.minSize.value ?? 0))
let start = { position: 0, size: 0 }
const along = (event: PointerEvent) => (group.direction.value === 'horizontal' ? event.clientX : event.clientY)
function onPointerDown(event: PointerEvent) {
if (event.button !== 0) return
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
dragging.value = true
start = { position: along(event), size: size.value }
// Stops text being selected across the panels while dragging.
event.preventDefault()
}
function onPointerMove(event: PointerEvent) {
if (!dragging.value) return
const extent = group.extent()
if (extent > 0) group.resize(index.value, start.size + ((along(event) - start.position) / extent) * 100)
}
function onKeydown(event: KeyboardEvent) {
const horizontal = group.direction.value === 'horizontal'
if (event.key === (horizontal ? 'ArrowLeft' : 'ArrowUp')) group.resize(index.value, size.value - props.step)
else if (event.key === (horizontal ? 'ArrowRight' : 'ArrowDown')) group.resize(index.value, size.value + props.step)
else if (event.key === 'Home') group.resize(index.value, min.value)
else if (event.key === 'End') group.resize(index.value, max.value)
else return
event.preventDefault()
}
</script>
<template>
<!-- A focusable separator with a value is how a splitter is announced; the value is the size of the panel before it. -->
<div
role="separator"
tabindex="0"
class="er-resizable-handle"
:aria-orientation="group.direction.value === 'horizontal' ? 'vertical' : 'horizontal'"
:aria-label="label"
:aria-controls="before?.id"
:aria-valuenow="Math.round(size)"
:aria-valuemin="Math.round(min)"
:aria-valuemax="Math.round(max)"
:data-dragging="dragging ? '' : undefined"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="dragging = false"
@pointercancel="dragging = false"
@keydown="onKeydown"
>
<span v-if="grip" class="er-resizable-grip" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.5" /><circle cx="15" cy="6" r="1.5" /><circle cx="9" cy="12" r="1.5" /><circle cx="15" cy="12" r="1.5" /><circle cx="9" cy="18" r="1.5" /><circle cx="15" cy="18" r="1.5" /></svg>
</span>
</div>
</template>ResizablePanel.vue 51 lines
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, toRef, useId } from 'vue'
import { useResizable } from './context'
const props = withDefaults(
defineProps<{
/** Starting share of the space, in percent. Panels without one split what is left. */
defaultSize?: number
/** Smallest share it can be dragged down to, in percent. */
minSize?: number
}>(),
{ minSize: 10 },
)
const group = useResizable()
const id = useId()
const root = ref<HTMLElement | null>(null)
/** True while the content is bigger than the panel, which is when the panel scrolls. */
const scrollable = ref(false)
const unregister = group.registerPanel({ id, defaultSize: toRef(props, 'defaultSize'), minSize: toRef(props, 'minSize') })
const size = computed(() => group.sizes.value[group.panels.value.findIndex((panel) => panel.id === id)] ?? 0)
let observer: ResizeObserver | undefined
onMounted(() => {
const el = root.value
if (!el) return
const measure = () => {
scrollable.value = el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1
}
// Watches the panel and what is in it: either changing size can start or stop the scrolling.
observer = new ResizeObserver(measure)
observer.observe(el)
for (const child of Array.from(el.children)) observer.observe(child)
measure()
})
onBeforeUnmount(() => {
observer?.disconnect()
unregister()
})
</script>
<template>
<!-- A panel that scrolls can be focused, so its content can be scrolled from the keyboard too. -->
<div :id="id" ref="root" class="er-resizable-panel" :tabindex="scrollable ? 0 : undefined" :style="{ '--er-panel-size': size }">
<slot :size="size" />
</div>
</template>ResizablePanels.vue 163 lines
<script setup lang="ts">
import { computed, ref, shallowRef, toRef } from 'vue'
import { provideResizable, type ResizablePanelEntry } from './context'
const props = withDefaults(
defineProps<{
direction?: 'horizontal' | 'vertical'
}>(),
{ direction: 'horizontal' },
)
/** Each panel's share in percent, in order. Bind it to remember a layout; leave it out to start from the defaults. */
const model = defineModel<number[]>('sizes')
const root = ref<HTMLElement | null>(null)
const panels = shallowRef<ResizablePanelEntry[]>([])
const handles = shallowRef<string[]>([])
const sizes = computed(() => {
const count = panels.value.length
if (model.value?.length === count) return model.value
// Panels with a default keep it; the rest share whatever is left equally.
const given = panels.value.map((panel) => panel.defaultSize.value)
const used = given.reduce<number>((sum, size) => sum + (size ?? 0), 0)
const open = given.filter((size) => size === undefined).length
return given.map((size) => size ?? Math.max(0, 100 - used) / Math.max(1, open))
})
function resize(index: number, size: number) {
const before = panels.value[index]
const after = panels.value[index + 1]
if (!before || !after) return
const current = sizes.value
const pair = current[index]! + current[index + 1]!
// Neither neighbour may go below its minimum, so the handle stops there.
const next = Math.max(before.minSize.value, Math.min(size, pair - after.minSize.value))
const result = current.slice()
result[index] = next
result[index + 1] = pair - next
model.value = result
}
provideResizable({
direction: toRef(props, 'direction'),
sizes,
panels,
registerPanel: (panel) => {
panels.value = [...panels.value, panel]
return () => {
panels.value = panels.value.filter((other) => other !== panel)
}
},
registerHandle: (id) => {
handles.value = [...handles.value, id]
return {
index: computed(() => handles.value.indexOf(id)),
unregister: () => {
handles.value = handles.value.filter((other) => other !== id)
},
}
},
resize,
extent: () => (props.direction === 'horizontal' ? (root.value?.clientWidth ?? 0) : (root.value?.clientHeight ?? 0)),
})
</script>
<template>
<div ref="root" class="er-resizable" :data-direction="direction">
<slot :sizes="sizes" />
</div>
</template>
<style>
@layer components {
.er-resizable {
display: flex;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
.er-resizable[data-direction='vertical'] {
flex-direction: column;
}
/* Sizes are flex-grow shares of the space left after the handles, so they always add up exactly. */
.er-resizable-panel {
flex: var(--er-panel-size, 1) 1 0px;
min-width: 0;
min-height: 0;
overflow: auto;
}
.er-resizable-panel:focus-visible {
outline: 2px solid var(--er-ring);
outline-offset: -2px;
}
.er-resizable-handle {
position: relative;
flex: none;
width: 1px;
background: var(--er-border);
cursor: col-resize;
touch-action: none;
outline: none;
transition: background-color var(--er-duration);
}
.er-resizable[data-direction='vertical'] > .er-resizable-handle {
width: auto;
height: 1px;
cursor: row-resize;
}
/* The line is 1px, but the area that can be grabbed is much wider. */
.er-resizable-handle::before {
content: '';
position: absolute;
inset: 0 -0.375rem;
z-index: 1;
}
.er-resizable[data-direction='vertical'] > .er-resizable-handle::before {
inset: -0.375rem 0;
}
.er-resizable-handle:hover,
.er-resizable-handle[data-dragging],
.er-resizable-handle:focus-visible {
background: var(--er-ring);
box-shadow: 0 0 0 1px var(--er-ring);
}
.er-resizable-grip {
position: absolute;
top: 50%;
left: 50%;
z-index: 2;
display: grid;
place-items: center;
width: 0.75rem;
height: 1.25rem;
border: 1px solid var(--er-border);
border-radius: 3px;
background: var(--er-background);
color: var(--er-muted-foreground);
transform: translate(-50%, -50%);
pointer-events: none;
}
.er-resizable[data-direction='vertical'] > .er-resizable-handle > .er-resizable-grip {
transform: translate(-50%, -50%) rotate(90deg);
}
.er-resizable-grip svg {
width: 0.625rem;
height: 0.625rem;
}
}
</style>context.ts 28 lines
import type { Ref } from 'vue'
import { createContext } from '../../lib/context'
export interface ResizablePanelEntry {
id: string
/** Starting share of the space, in percent. Panels without one split what is left. */
defaultSize: Ref<number | undefined>
/** Smallest share it can be dragged down to, in percent. */
minSize: Ref<number>
}
export interface ResizableContext {
direction: Ref<'horizontal' | 'vertical'>
/** Each panel's share of the space in percent, in order. */
sizes: Ref<number[]>
panels: Ref<ResizablePanelEntry[]>
/** Adds a panel in the order panels are written; returns the function that removes it. */
registerPanel: (panel: ResizablePanelEntry) => () => void
/** Adds a handle; returns its position (handle 0 sits between panels 0 and 1) and the function that removes it. */
registerHandle: (id: string) => { index: Ref<number>; unregister: () => void }
/** Give the panel before handle `index` this share; the panel after it gives up or takes the difference. */
resize: (index: number, size: number) => void
/** Size of the whole group along its direction, in px. */
extent: () => number
}
export const [provideResizable, useResizable] = createContext<ResizableContext>('ResizablePanels')index.ts 5 lines
/** Resizable — panels side by side (or stacked) with handles between them to drag or move with the arrow keys. */
export { default as ResizableHandle } from './ResizableHandle.vue'
export { default as ResizablePanel } from './ResizablePanel.vue'
export { default as ResizablePanels } from './ResizablePanels.vue'