GrSplitter

  • core
  • utilities
  • API · 12 props
  • ⌨ keys

Splits a layout into two resizable panels with a draggable divider.

import { GrSplitter } from '@feugene/granularity/components/GrSplitter'

Overview

When to take it

  • two areas need a shared boundary — a tree and the content, an editor and a preview, a list and a detail;
  • the size is chosen by the user — the boundary moves with the mouse and from the keyboard, and min/max hold the edges;
  • one area is collapsed — collapsible removes a panel entirely and brings it back;
  • the layout is saved — v-model gives away the size, and all that is left is to write it down.

When to take something else

NeedTake
There are many areas and they are on a gridGrDashboard
The layout is fixedCSS Grid and GrCard
The panel slides out over the contentGrDrawer
Sections are switchedGrTabs
Just a line between blocksGrDivider

Playground controls come from the API, not from hand

Stage

Loading…

0 changed· state is in the URL

Examples 2 each with a live demo and its code

  • A conversation list and the open message

    The model is the share of the list in per cent: it survives a narrower window, and the server renders the same split, so hydration is clean. min and max keep the list readable, min-end keeps the message from being squeezed to nothing. change comes at the end of a drag and on every arrow step — the place to save the layout; a double click on the border returns to default-size.

    Open · 4Layout: list 38%
    #4182 · Northwind TradersCard declined on renewal

    Our card was declined this morning, but the bank says. Can you check what happened on your side? We would like to keep the subscription active.

  • Tables, a query and its result

    Three panes are two splitters, one inside the other, each with its own model. The table list folds away with collapsible and v-model:collapsed — the share survives the fold, so the pane returns where it was; Enter on the border and a drag against the edge fold it too. orientation="vertical" divides the editor from the result, step and big-step set the arrows, and a locked layout is disabled.

    • billing · 6 tables
    • customers
    • invoices
    • invoice_lines
    • payments
    • refunds
    • subscriptions
    SELECT c.name, s.plan, SUM(i.total) AS billed
    FROM customers c
    JOIN subscriptions s ON s.customer_id = c.id
    JOIN invoices i ON i.customer_id = c.id
    WHERE i.issued_at >= DATE '2026-09-01'
    GROUP BY c.name, s.plan
    ORDER BY billed DESC;
    3 rows · 41 ms
    Northwind TradersBusiness€4,820.00
    Contoso LtdTeam€1,296.00
    Fabrikam GmbHTeam€1,104.00

API generated from the package · cannot drift

Props

PropTypedefaultDescription
disabledboolean | undefinedfalseThe separator does not drag, does not take focus and does not answer to keys.
modelValuev-modelnumber | undefinedundefinedThe share of the first panel as a percentage. It supports `v-model`; without it the component remembers the size itself.
ariaLabelstring | undefinedundefinedThe name of the separator for a screen reader. Unset — from the locale.
maxnumber | undefined90The maximum of the first panel as a percentage.
orientation"horizontal" | "vertical" | undefinedundefined`horizontal` — the panels side by side, `vertical` — one above the other.
stepnumber | undefined1The step of the arrows.
minnumber | undefined10The minimum of the first panel as a percentage.
collapsedv-modelboolean | undefinedfalseThe first panel is collapsed. It supports `v-model:collapsed`.
defaultSizenumber | undefined50The starting share and the point to return to on a double click.
minEndnumber | undefined10The minimum of the second panel: without it the panel can be squeezed to zero.
bigStepnumber | undefined10The step of the arrows with `Shift`.
collapsibleboolean | undefinedundefinedPermit collapsing the first panel.

Slots

SlotTypeDescription
startanyThe first panel — the one whose size is set by the model.
endanyThe second panel: it takes the remainder.

Events

EventSignatureDescription
update:modelValue(event: "update:modelValue", value: number): void—
change(event: "change", value: number): void—
update:collapsed(event: "update:collapsed", value: boolean): void—

Types

Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.

Import
import type { GrSplitterProps, GrSplitterEmits, GrSplitterOrientation } from '@feugene/granularity/components/GrSplitter'

Filedist/types/components/GrSplitter/GrSplitter.vue.d.ts

GrSplitterPropsinterface
interface GrSplitterProps {
    modelValue?: number;
    defaultSize?: number;
    orientation?: GrSplitterOrientation;
    min?: number;
    max?: number;
    minEnd?: number;
    step?: number;
    bigStep?: number;
    collapsible?: boolean;
    collapsed?: boolean;
    disabled?: boolean;
    ariaLabel?: string;
}
GrSplitterEmitsinterface
interface GrSplitterEmits {
    (e: 'update:modelValue', value: number): void;
    (e: 'update:collapsed', value: boolean): void;
    (e: 'change', value: number): void;
}

Filedist/types/components/GrSplitter/splitterGeometry.d.ts

GrSplitterOrientationtype
type GrSplitterOrientation = typeof GR_SPLITTER_ORIENTATIONS[number];
GrSplitterBoundsinterface
interface GrSplitterBounds {
    min: number;
    max: number;
    minEnd: number;
}
sizeFromPointerfunction
function sizeFromPointer(rect: {
    left: number;
    top: number;
    width: number;
    height: number;
}, clientX: number, clientY: number, orientation: GrSplitterOrientation): number;

Filedist/types/components/GrSplitter/config.d.ts

grSplitterConfigconst
const grSplitterConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrSplitter">;

Filedist/types/components/GrSplitter/safelist.d.ts

grSplitterSafelistconst
const grSplitterSafelist: string[];

Filedist/types/components/GrSplitter/defaults.d.ts

GrSplitterConfigurablePropsinterface
interface GrSplitterConfigurableProps {
    orientation: GrSplitterOrientation;
    collapsible: boolean;
}

Filedist/types/components/GrSplitter/index.d.ts

GrSplitterInstancetype
type GrSplitterInstance = ComponentExposed<typeof GrSplitterComponent>;

Accessibility

APG pattern

window splitter

Keys

Home / EndEnter

Usage from the library prose

The size is a share, not pixels

modelValue is the percentage the first panel takes up. Percentages survive a change of the width of the window: a layout tuned on a wide monitor stays meaningful on a laptop, whereas a pixel width there would eat up half the screen.

The second consequence matters more than the first: the share is known before the render, so the server markup matches the client one and the hydration passes cleanly. The component measures the DOM in exactly one place — when it converts the coordinate of the pointer into percentages; neither the keyboard, nor Home/End, nor collapsing require measurements.

v-model is not mandatory: without it the splitter remembers the size itself (as collapsed does in GrSidebar). change is emitted at the end of a gesture and on every step from the keyboard — that is the point where the saving of the layout is hung.

Three panels means nesting

The component holds exactly two panels and one divider. The “tree | editor / console” layout is assembled by nesting, as in code editors:

vue
<GrSplitter v-model="treeWidth">
  <template #start><FileTree /></template>
  <template #end>
    <GrSplitter v-model="consoleHeight" orientation="vertical">
      <template #start><Editor /></template>
      <template #end><Console /></template>
    </GrSplitter>
  </template>
</GrSplitter>

Nested splitters are independent: each runs a model of its own.

The bounds of the panels

min and max limit the first panel, and minEnd the second. Without minEnd the second panel can be squeezed to zero, and there will be nothing to bring it back with.

In a conflict between min and minEnd the min wins: the first panel does not go below its minimum even if the second one turns out narrower than its own. The reverse order would give a panel the user cannot get out of.

The keyboard and the mouse

The divider stands in the tab order (role="separator", tabindex="0"):

  • ←/→ in the horizontal layout, ↑/↓ in the vertical one — a step;
  • Shift + an arrow — a large step;
  • Home / End — to min / max;
  • Enter — collapse and bring back (with collapsible);
  • a double click — a reset to defaultSize;
  • dragging right up to the edge (below half of min) collapses the panel with the mouse: the double click is taken by the reset, and there has to be a way to collapse with the mouse.

The grab zone is wider than the visible strip: a six-pixel target is not caught with a mouse, and making the strip thicker for that would mean painting the layout to suit the mouse.

aria-orientation is inverted — and that is right

orientation describes the layout: horizontal means the panels stand side by side. aria-orientation describes the divider itself, and panels standing side by side are separated by a vertical strip. A horizontal layout therefore has aria-orientation="vertical", and vice versa.

The divider announces aria-valuenow/valuemin/valuemax in the same percentages as the model and refers to the first panel through aria-controls.

Being collapsed is not a zero in the model

collapsed is a separate state (v-model:collapsed) rather than modelValue: 0. The previous size has to survive the collapsing, otherwise Enter would bring the panel back to the wrong place. In the track a collapsed panel takes up 0 %, and aria-valuenow shows 0 — what is visible on the screen.

A collapsed panel is clipped and hidden (overflow: hidden, visibility: hidden) and marked inert: its content is not painted over the second panel, is not announced by a screen reader and does not get into the Tab order. Previously a zero width left the content visible over the neighbour, and the links inside it reachable blindly. On expanding, everything comes back.

Limits
  • GrResizable (resizing a block by a corner) does not belong here — a different scenario and a different pattern;
  • saving the layout is the job of the application: v-model and change give everything necessary, and a localStorage inside the component would be state the application knows nothing about;
  • there are no snap points and no “magnets” — without a scenario that is extra mechanics on top of the clamping;
  • there is no touch physics of its own — the pointer events cover touch as it is.