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 init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add resizable
pnpm dlx ermineui add resizable
yarn dlx ermineui add resizable
bunx ermineui add resizable

That 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

FileWhere it goes
ResizableHandle.vuesrc/components/ui/resizable/ResizableHandle.vue
ResizablePanel.vuesrc/components/ui/resizable/ResizablePanel.vue
ResizablePanels.vuesrc/components/ui/resizable/ResizablePanels.vue
context.tssrc/components/ui/resizable/context.ts
index.tssrc/components/ui/resizable/index.ts
context.ts helpersrc/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'