Components
splitter
A resizable two-pane split. The divider is a focusable role="separator" widget — drag it, or use the arrow keys — that drives a single CSS variable feeding a grid track.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/splitter.json2. Use it
import { Splitter } from "@/components/ui/splitter"
<Splitter
ariaLabel="Files"
value={30}
primaryId="files"
primary={<p>Sidebar</p>}
secondary={<p>Editor</p>}
/>Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren, Child } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Splitter (window splitter) — shadcn-htmx, htmx v4 + Tailwind v4.
//
// shadcn/ui has no splitter; the closest analogue is the community
// "resizable" component built on react-resizable-panels. We do NOT copy that
// React/JS machinery. Instead we build the WAI-ARIA Window Splitter pattern on
// web standards: a CSS grid whose first track is sized by a single custom
// property (--split, a percentage), plus a real focusable divider.
//
// Accessibility contract follows the WAI-ARIA APG Window Splitter pattern:
// repos/aria-practices/content/patterns/windowsplitter/windowsplitter-pattern.html
// and the focusable-separator widget semantics on MDN:
// repos/mdn/files/en-us/web/accessibility/aria/reference/roles/separator_role/index.md
// ("If the separator is focusable … the value of aria-valuenow must be set
// to a number reflecting the current position … An accessible name, with
// aria-label should be included if there is more than one focusable
// separator.")
//
// The contract we implement:
// - The divider is the focusable widget: role="separator", tabindex="0",
// aria-valuenow / aria-valuemin / aria-valuemax describing the SIZE of the
// primary pane (APG: "A window splitter has a value that represents the
// size of one of the panes … called the primary pane"), aria-orientation
// reflecting the layout, aria-controls pointing at the primary pane, and an
// accessible name matching the primary pane (aria-label / aria-labelledby).
// - aria-valuemin is typically 0 (primary fully collapsed) and aria-valuemax
// typically 100 (primary at its max), per the APG.
//
// What the platform does NOT give us, and what public/site.js layers on
// (keyed off data-slot="splitter" / the divider's role="separator"):
// - pointer drag: dragging the divider updates --split + aria-valuenow.
// - the APG keyboard contract: ArrowLeft/Right (or Up/Down when vertical)
// resize by `step`; Home → valuemin, End → valuemax; Enter toggles collapse
// (collapse to valuemin, restore to the previous position).
// The divider element carries data-* hooks (data-min/max/step/orientation and
// data-collapsed) so site.js needs no per-instance config.
//
// Refs:
// repos/mdn/files/en-us/web/css/grid-template-columns/index.md (grid sizing)
// repos/mdn/files/en-us/web/css/css_custom_properties (the --split variable)
// repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-controls/index.md
export type SplitterOrientation = "horizontal" | "vertical"
// The root is a CSS grid. For a horizontal split (panes side by side) the first
// COLUMN is the primary pane, sized to --split%; for a vertical split (panes
// stacked) the first ROW is the primary pane. The middle track is the divider's
// hit area (auto-sized to its own width/height).
const ROOT_CLASS =
"grid w-full overflow-hidden rounded-md border bg-card " +
// Horizontal: [primary | divider | secondary] across columns.
"data-[orientation=horizontal]:h-64 " +
"data-[orientation=horizontal]:grid-cols-[var(--split,50%)_auto_minmax(0,1fr)] " +
// Vertical: [primary / divider / secondary] down rows.
"data-[orientation=vertical]:h-96 " +
"data-[orientation=vertical]:grid-rows-[var(--split,50%)_auto_minmax(0,1fr)]"
const PANE_CLASS = "min-h-0 min-w-0 overflow-auto p-4 text-sm text-foreground"
// The divider. A focusable separator widget: a thin bar with a grab handle.
// touch-none / select-none keep dragging from scrolling or selecting text.
const DIVIDER_CLASS =
"group/splitter relative flex shrink-0 touch-none items-center justify-center bg-border outline-none transition-colors select-none " +
"hover:bg-ring/40 focus-visible:bg-ring/40 focus-visible:ring-[3px] focus-visible:ring-ring/50 " +
"data-[orientation=horizontal]:w-1.5 data-[orientation=horizontal]:cursor-col-resize data-[orientation=horizontal]:h-full " +
"data-[orientation=vertical]:h-1.5 data-[orientation=vertical]:cursor-row-resize data-[orientation=vertical]:w-full"
// The visible grab affordance inside the divider (a short pill).
const HANDLE_CLASS =
"pointer-events-none rounded-full bg-muted-foreground/40 transition-colors group-hover/splitter:bg-muted-foreground/70 " +
"group-data-[orientation=horizontal]/splitter:h-6 group-data-[orientation=horizontal]/splitter:w-0.5 " +
"group-data-[orientation=vertical]/splitter:w-6 group-data-[orientation=vertical]/splitter:h-0.5"
type SplitterProps = PropsWithChildren<{
// Layout axis. horizontal → panes side by side; vertical → stacked.
orientation?: SplitterOrientation
// Content of the two panes.
primary?: Child
secondary?: Child
// Current size of the primary pane, between min and max (percent of root).
value?: number
min?: number
max?: number
// Resize increment per arrow press (in the same units as value).
step?: number
// Accessible name for the divider. APG: the name matches the primary pane.
// Provide a visible label's id via ariaLabelledby, else a literal ariaLabel.
ariaLabel?: string
ariaLabelledby?: string
// Human-readable value announced in place of the bare aria-valuenow number
// (e.g. "Files, 30%"). Per MDN, a focusable separator may carry aria-valuetext
// when aria-valuenow alone is not optimal for AT:
// repos/mdn/files/en-us/web/accessibility/aria/reference/roles/separator_role/index.md
// (aria-valuetext associated property for focusable separator)
ariaValuetext?: string
// Id given to the primary pane; the divider's aria-controls points at it.
// Auto-derived from `id` when omitted.
primaryId?: string
id?: string
class?: ClassValue
[key: `hx-${string}`]: any
[key: `data-${string}`]: any
[key: `aria-${string}`]: any
}>
export function Splitter(props: SplitterProps) {
const {
orientation = "horizontal",
primary,
secondary,
value = 50,
min = 0,
max = 100,
step = 10,
ariaLabel,
ariaLabelledby,
ariaValuetext,
primaryId,
id,
class: className,
children,
...rest
} = props as any
// Clamp the initial value into [min, max] so --split and aria-valuenow agree.
const now = Math.min(max, Math.max(min, value))
const paneId = primaryId ?? (id ? `${id}-primary` : undefined)
return (
<div
id={id}
data-slot="splitter"
data-orientation={orientation}
style={`--split:${now}%`}
class={cn(ROOT_CLASS, className)}
{...rest}
>
<div data-slot="splitter-panel" data-splitter-panel="primary" id={paneId} class={PANE_CLASS}>
{primary}
</div>
<div
role="separator"
tabindex={0}
data-slot="splitter-handle"
data-orientation={orientation}
// Position bookkeeping for site.js (drag + keyboard), so it needs no
// per-instance wiring. Mirrors aria-valuemin/max/step.
data-min={min}
data-max={max}
data-step={step}
data-collapsed="false"
aria-orientation={orientation}
aria-controls={paneId}
aria-label={ariaLabelledby ? undefined : ariaLabel}
aria-labelledby={ariaLabelledby}
// The value is the SIZE of the primary pane (APG). min/max are the
// collapsed / fully-expanded positions, typically 0 / 100.
aria-valuenow={now}
aria-valuemin={min}
aria-valuemax={max}
// Announced in place of aria-valuenow when provided (separator_role).
aria-valuetext={ariaValuetext}
class={DIVIDER_CLASS}
>
<span class={HANDLE_CLASS} aria-hidden="true"></span>
</div>
<div data-slot="splitter-panel" data-splitter-panel="secondary" class={PANE_CLASS}>
{secondary}
{children}
</div>
</div>
)
}
1. Save the file
Copy splitter.html into templates/components/.
2. Use it
{% from "components/splitter.html" import splitter %}
{{ splitter(aria_label="Files", value=30, primary_id="files",
primary="Sidebar", secondary="Editor") }}View source
{# Splitter (window splitter) macro — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/splitter.tsx. A CSS grid whose first track is sized by
--split (a percentage) plus a focusable role="separator" divider. site.js
(data-slot="splitter") owns pointer drag + the APG keyboard contract and
keeps --split / aria-valuenow in sync.
Accessibility contract:
repos/aria-practices/content/patterns/windowsplitter/windowsplitter-pattern.html #}
{% macro splitter(
orientation="horizontal",
primary="", secondary="",
value=50, min=0, max=100, step=10,
aria_label=none, aria_labelledby=none, aria_valuetext=none,
primary_id=none, id=none,
extra_class="", **attrs
) %}
{% set now = [[value, min]|max, max]|min %}
{% set pane_id = primary_id if primary_id is not none else (id ~ "-primary" if id is not none else none) %}
<div {% if id %}id="{{ id }}"{% endif %}
data-slot="splitter"
data-orientation="{{ orientation }}"
style="--split:{{ now }}%"
class="grid w-full overflow-hidden rounded-md border bg-card data-[orientation=horizontal]:h-64 data-[orientation=horizontal]:grid-cols-[var(--split,50%)_auto_minmax(0,1fr)] data-[orientation=vertical]:h-96 data-[orientation=vertical]:grid-rows-[var(--split,50%)_auto_minmax(0,1fr)] {{ extra_class }}"
{%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor %}>
<div data-slot="splitter-panel" data-splitter-panel="primary" {% if pane_id %}id="{{ pane_id }}"{% endif %} class="min-h-0 min-w-0 overflow-auto p-4 text-sm text-foreground">{{ primary }}</div>
<div role="separator" tabindex="0"
data-slot="splitter-handle"
data-orientation="{{ orientation }}"
data-min="{{ min }}" data-max="{{ max }}" data-step="{{ step }}" data-collapsed="false"
aria-orientation="{{ orientation }}"
{%- if pane_id %} aria-controls="{{ pane_id }}"{% endif %}
{%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% elif aria_label %} aria-label="{{ aria_label }}"{% endif %}
aria-valuenow="{{ now }}" aria-valuemin="{{ min }}" aria-valuemax="{{ max }}"
{#- aria-valuetext: announced in place of aria-valuenow when set (MDN separator_role) #}
{%- if aria_valuetext %} aria-valuetext="{{ aria_valuetext }}"{% endif %}
class="group/splitter relative flex shrink-0 touch-none items-center justify-center bg-border outline-none transition-colors select-none hover:bg-ring/40 focus-visible:bg-ring/40 focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[orientation=horizontal]:w-1.5 data-[orientation=horizontal]:cursor-col-resize data-[orientation=horizontal]:h-full data-[orientation=vertical]:h-1.5 data-[orientation=vertical]:cursor-row-resize data-[orientation=vertical]:w-full">
<span class="pointer-events-none rounded-full bg-muted-foreground/40 transition-colors group-hover/splitter:bg-muted-foreground/70 group-data-[orientation=horizontal]/splitter:h-6 group-data-[orientation=horizontal]/splitter:w-0.5 group-data-[orientation=vertical]/splitter:w-6 group-data-[orientation=vertical]/splitter:h-0.5" aria-hidden="true"></span>
</div>
<div data-slot="splitter-panel" data-splitter-panel="secondary" class="min-h-0 min-w-0 overflow-auto p-4 text-sm text-foreground">{{ secondary }}{{ caller() if caller is defined }}</div>
</div>
{% endmacro %}
1. Save the file
Add splitter.tmpl alongside your other templates.
2. Use it
{{template "splitter" (dict "AriaLabel" "Files" "Value" 30 "PrimaryID" "files" "Primary" (htmlSafe "Sidebar") "Secondary" (htmlSafe "Editor"))}}View source
{{/*
Splitter (window splitter) template — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/splitter.tsx. A CSS grid whose first track is sized by
--split (a percentage) plus a focusable role="separator" divider. site.js
(data-slot="splitter") owns pointer drag + the APG keyboard contract and
keeps --split / aria-valuenow in sync.
Accessibility contract:
repos/aria-practices/content/patterns/windowsplitter/windowsplitter-pattern.html
type SplitterArgs struct {
ID, PrimaryID string
Orientation string // "horizontal" | "vertical"
Value, Min, Max, Step int
AriaLabel, AriaLabelledby, AriaValuetext string
Primary, Secondary template.HTML // pane bodies via htmlSafe
// Everything else (hx-get, data-*, …) goes here.
Attrs map[string]string
}
*/}}
{{define "splitter"}}
{{- $orientation := or .Orientation "horizontal" -}}
{{- $now := or .Value 50 -}}{{- $min := or .Min 0 -}}{{- $max := or .Max 100 -}}{{- $step := or .Step 10 -}}
{{- $paneId := .PrimaryID -}}{{- if not $paneId}}{{- if .ID}}{{- $paneId = print .ID "-primary" -}}{{end}}{{end -}}
<div {{if .ID}}id="{{.ID}}"{{end}}
data-slot="splitter"
data-orientation="{{$orientation}}"
style="--split:{{$now}}%"
class="grid w-full overflow-hidden rounded-md border bg-card data-[orientation=horizontal]:h-64 data-[orientation=horizontal]:grid-cols-[var(--split,50%)_auto_minmax(0,1fr)] data-[orientation=vertical]:h-96 data-[orientation=vertical]:grid-rows-[var(--split,50%)_auto_minmax(0,1fr)]"
{{- range $k, $v := .Attrs}} {{$k}}="{{$v}}"{{end}}>
<div data-slot="splitter-panel" data-splitter-panel="primary" {{if $paneId}}id="{{$paneId}}"{{end}} class="min-h-0 min-w-0 overflow-auto p-4 text-sm text-foreground">{{.Primary}}</div>
<div role="separator" tabindex="0"
data-slot="splitter-handle"
data-orientation="{{$orientation}}"
data-min="{{$min}}" data-max="{{$max}}" data-step="{{$step}}" data-collapsed="false"
aria-orientation="{{$orientation}}"
{{if $paneId}}aria-controls="{{$paneId}}"{{end}}
{{if .AriaLabelledby}}aria-labelledby="{{.AriaLabelledby}}"{{else if .AriaLabel}}aria-label="{{.AriaLabel}}"{{end}}
aria-valuenow="{{$now}}" aria-valuemin="{{$min}}" aria-valuemax="{{$max}}"
{{/* aria-valuetext: announced in place of aria-valuenow when set (MDN separator_role) */}}
{{if .AriaValuetext}}aria-valuetext="{{.AriaValuetext}}"{{end}}
class="group/splitter relative flex shrink-0 touch-none items-center justify-center bg-border outline-none transition-colors select-none hover:bg-ring/40 focus-visible:bg-ring/40 focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[orientation=horizontal]:w-1.5 data-[orientation=horizontal]:cursor-col-resize data-[orientation=horizontal]:h-full data-[orientation=vertical]:h-1.5 data-[orientation=vertical]:cursor-row-resize data-[orientation=vertical]:w-full">
<span class="pointer-events-none rounded-full bg-muted-foreground/40 transition-colors group-hover/splitter:bg-muted-foreground/70 group-data-[orientation=horizontal]/splitter:h-6 group-data-[orientation=horizontal]/splitter:w-0.5 group-data-[orientation=vertical]/splitter:w-6 group-data-[orientation=vertical]/splitter:h-0.5" aria-hidden="true"></span>
</div>
<div data-slot="splitter-panel" data-splitter-panel="secondary" class="min-h-0 min-w-0 overflow-auto p-4 text-sm text-foreground">{{.Secondary}}</div>
</div>
{{end}}
1. Save the file
Drop splitter.ex into lib/my_app_web/components/.
2. Use it
<.splitter aria-label="Files" value={30} primary_id="files">
<:primary>Sidebar</:primary>
<:secondary>Editor</:secondary>
</.splitter>View source
defmodule ShadcnHtmx.Components.Splitter do
@moduledoc """
Splitter (window splitter) — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Mirrors registry/ui/splitter.tsx. A CSS grid whose first track is sized by
`--split` (a percentage) plus a focusable `role="separator"` divider.
public/site.js (keyed on data-slot="splitter") owns pointer drag + the APG
keyboard contract and keeps `--split` / `aria-valuenow` in sync.
Accessibility contract follows the WAI-ARIA APG Window Splitter pattern:
repos/aria-practices/content/patterns/windowsplitter/windowsplitter-pattern.html
## Examples
<.splitter aria-label="Files" value={30} primary_id="files">
<:primary>Sidebar</:primary>
<:secondary>Editor</:secondary>
</.splitter>
"""
use Phoenix.Component
attr :orientation, :string, default: "horizontal", values: ~w(horizontal vertical)
attr :value, :integer, default: 50
attr :min, :integer, default: 0
attr :max, :integer, default: 100
attr :step, :integer, default: 10
attr :"aria-label", :string, default: nil
attr :"aria-labelledby", :string, default: nil
# Announced in place of aria-valuenow when set (MDN separator_role):
# repos/mdn/files/en-us/web/accessibility/aria/reference/roles/separator_role/index.md
attr :"aria-valuetext", :string, default: nil
attr :primary_id, :string, default: nil
attr :id, :string, default: nil
attr :class, :string, default: nil
attr :rest, :global
slot :primary, required: true
slot :secondary, required: true
def splitter(assigns) do
# Clamp the initial value into [min, max] so --split and aria-valuenow agree.
now = assigns.value |> max(assigns.min) |> min(assigns.max)
pane_id = assigns.primary_id || (assigns.id && "#{assigns.id}-primary")
assigns = assign(assigns, now: now, pane_id: pane_id)
~H"""
<div
id={@id}
data-slot="splitter"
data-orientation={@orientation}
style={"--split:#{@now}%"}
class={[
"grid w-full overflow-hidden rounded-md border bg-card",
"data-[orientation=horizontal]:h-64 data-[orientation=horizontal]:grid-cols-[var(--split,50%)_auto_minmax(0,1fr)]",
"data-[orientation=vertical]:h-96 data-[orientation=vertical]:grid-rows-[var(--split,50%)_auto_minmax(0,1fr)]",
@class
]}
{@rest}
>
<div data-slot="splitter-panel" data-splitter-panel="primary" id={@pane_id} class="min-h-0 min-w-0 overflow-auto p-4 text-sm text-foreground">
{render_slot(@primary)}
</div>
<div
role="separator"
tabindex="0"
data-slot="splitter-handle"
data-orientation={@orientation}
data-min={@min}
data-max={@max}
data-step={@step}
data-collapsed="false"
aria-orientation={@orientation}
aria-controls={@pane_id}
aria-label={!assigns[:"aria-labelledby"] && assigns[:"aria-label"]}
aria-labelledby={assigns[:"aria-labelledby"]}
aria-valuenow={@now}
aria-valuemin={@min}
aria-valuemax={@max}
aria-valuetext={assigns[:"aria-valuetext"]}
class={[
"group/splitter relative flex shrink-0 touch-none items-center justify-center bg-border outline-none transition-colors select-none",
"hover:bg-ring/40 focus-visible:bg-ring/40 focus-visible:ring-[3px] focus-visible:ring-ring/50",
"data-[orientation=horizontal]:w-1.5 data-[orientation=horizontal]:cursor-col-resize data-[orientation=horizontal]:h-full",
"data-[orientation=vertical]:h-1.5 data-[orientation=vertical]:cursor-row-resize data-[orientation=vertical]:w-full"
]}
>
<span
class="pointer-events-none rounded-full bg-muted-foreground/40 transition-colors group-hover/splitter:bg-muted-foreground/70 group-data-[orientation=horizontal]/splitter:h-6 group-data-[orientation=horizontal]/splitter:w-0.5 group-data-[orientation=vertical]/splitter:w-6 group-data-[orientation=vertical]/splitter:h-0.5"
aria-hidden="true"
>
</span>
</div>
<div data-slot="splitter-panel" data-splitter-panel="secondary" class="min-h-0 min-w-0 overflow-auto p-4 text-sm text-foreground">
{render_slot(@secondary)}
</div>
</div>
"""
end
end
1. Save the file
Paste the markup; it relies only on the theme tokens in styles.css.
2. Use it
<div data-slot="splitter" data-orientation="horizontal" style="--split:30%" class="grid …">
<div data-slot="splitter-panel" data-splitter-panel="primary" id="files" class="…">Sidebar</div>
<div role="separator" tabindex="0" data-slot="splitter-handle" data-orientation="horizontal"
data-min="0" data-max="100" data-step="10" data-collapsed="false"
aria-orientation="horizontal" aria-controls="files" aria-label="Files"
aria-valuenow="30" aria-valuemin="0" aria-valuemax="100" class="…">
<span aria-hidden="true" class="…"></span>
</div>
<div data-slot="splitter-panel" data-splitter-panel="secondary" class="…">Editor</div>
</div>View source
<!--
shadcn-htmx — raw HTML splitter (window splitter) snippet.
A CSS grid whose first track is sized by --split (a percentage), with a
focusable role="separator" divider between two panes. The divider is the
WAI-ARIA Window Splitter widget: aria-valuenow/min/max describe the SIZE of
the primary pane, aria-orientation reflects the layout, aria-controls points
at the primary pane, and aria-label names it.
The inline IIFE below is the only JS needed: it implements pointer drag plus
the APG keyboard contract (Arrow keys resize by step, Home/End jump to
min/max, Enter toggles collapse) and keeps --split + aria-valuenow in sync.
In the full library this lives in public/site.js (keyed on
data-slot="splitter"). It relies only on the theme tokens in styles.css.
-->
<div data-slot="splitter" data-orientation="horizontal" style="--split:30%"
class="grid w-full overflow-hidden rounded-md border bg-card data-[orientation=horizontal]:h-64 data-[orientation=horizontal]:grid-cols-[var(--split,50%)_auto_minmax(0,1fr)] data-[orientation=vertical]:h-96 data-[orientation=vertical]:grid-rows-[var(--split,50%)_auto_minmax(0,1fr)]">
<div data-slot="splitter-panel" data-splitter-panel="primary" id="files" class="min-h-0 min-w-0 overflow-auto p-4 text-sm text-foreground">
<p class="font-medium">Files</p>
<p class="mt-1 text-muted-foreground">Drag the divider, or focus it and press the arrow keys.</p>
</div>
<div role="separator" tabindex="0" data-slot="splitter-handle" data-orientation="horizontal"
data-min="0" data-max="100" data-step="10" data-collapsed="false"
aria-orientation="horizontal" aria-controls="files" aria-label="Files"
aria-valuenow="30" aria-valuemin="0" aria-valuemax="100"
aria-valuetext="Files, 30%"
class="group/splitter relative flex shrink-0 touch-none items-center justify-center bg-border outline-none transition-colors select-none hover:bg-ring/40 focus-visible:bg-ring/40 focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[orientation=horizontal]:w-1.5 data-[orientation=horizontal]:cursor-col-resize data-[orientation=horizontal]:h-full data-[orientation=vertical]:h-1.5 data-[orientation=vertical]:cursor-row-resize data-[orientation=vertical]:w-full">
<span class="pointer-events-none rounded-full bg-muted-foreground/40 transition-colors group-hover/splitter:bg-muted-foreground/70 group-data-[orientation=horizontal]/splitter:h-6 group-data-[orientation=horizontal]/splitter:w-0.5 group-data-[orientation=vertical]/splitter:w-6 group-data-[orientation=vertical]/splitter:h-0.5" aria-hidden="true"></span>
</div>
<div data-slot="splitter-panel" data-splitter-panel="secondary" class="min-h-0 min-w-0 overflow-auto p-4 text-sm text-foreground">
<p class="font-medium">Editor</p>
<p class="mt-1 text-muted-foreground">The secondary pane takes the remaining space.</p>
</div>
</div>
<script>
// Minimal standalone boot for the snippet. In the full library this lives in
// public/site.js (keyed on data-slot="splitter"). Implements the WAI-ARIA
// Window Splitter pattern: pointer drag + Arrow/Home/End/Enter keyboard.
document.querySelectorAll('[data-slot="splitter"]').forEach(function (root) {
var handle = root.querySelector('[data-slot="splitter-handle"]')
if (!handle || handle._scnSplit) return
handle._scnSplit = true
var min = +handle.getAttribute('data-min') || 0
var max = +handle.getAttribute('data-max')
if (isNaN(max)) max = 100
var step = +handle.getAttribute('data-step') || 10
var vertical = handle.getAttribute('data-orientation') === 'vertical'
var clamp = function (v) { return Math.min(max, Math.max(min, v)) }
var current = function () { return +handle.getAttribute('aria-valuenow') || 0 }
var apply = function (v) {
v = clamp(Math.round(v))
root.style.setProperty('--split', v + '%')
handle.setAttribute('aria-valuenow', v)
handle.setAttribute('data-collapsed', v <= min ? 'true' : 'false')
}
handle.addEventListener('pointerdown', function (e) {
e.preventDefault()
handle.setPointerCapture(e.pointerId)
var move = function (ev) {
var r = root.getBoundingClientRect()
var pct = vertical
? ((ev.clientY - r.top) / r.height) * 100
: ((ev.clientX - r.left) / r.width) * 100
apply(pct)
}
var up = function () {
handle.removeEventListener('pointermove', move)
handle.removeEventListener('pointerup', up)
}
handle.addEventListener('pointermove', move)
handle.addEventListener('pointerup', up)
})
handle.addEventListener('keydown', function (e) {
var dec = vertical ? 'ArrowUp' : 'ArrowLeft'
var inc = vertical ? 'ArrowDown' : 'ArrowRight'
if (e.key === dec) { e.preventDefault(); apply(current() - step) }
else if (e.key === inc) { e.preventDefault(); apply(current() + step) }
else if (e.key === 'Home') { e.preventDefault(); apply(min) }
else if (e.key === 'End') { e.preventDefault(); apply(max) }
else if (e.key === 'Enter') {
e.preventDefault()
if (handle.getAttribute('data-collapsed') === 'true') {
apply(+handle.getAttribute('data-prev') || Math.round((min + max) / 2))
} else {
handle.setAttribute('data-prev', current())
apply(min)
}
}
})
})
</script>
Examples
Horizontal split
Drag the divider, or focus it and press ←/→ to resize by the step. Home collapses the primary pane, End maximises it, Enter toggles collapse.
The divider is the focusable widget. Per the APG Window Splitter pattern it carries role="separator", aria-valuenow/min/max describing the size of the primary pane, aria-controls pointing at that pane, and an accessible name. A small script in site.js handles drag + keyboard and writes the --split CSS variable that sizes the first grid track.
Files
Drag the bar, or focus it and use the arrow keys.
Editor
The secondary pane fills the rest.
<Splitter ariaLabel="Files" value={30} primaryId="files"
primary={<p>Files</p>}
secondary={<p>Editor</p>} />{{ splitter(aria_label="Files", value=30, primary_id="files",
primary="Files", secondary="Editor") }}{{template "splitter" (dict "AriaLabel" "Files" "Value" 30 "PrimaryID" "files" "Primary" (htmlSafe "Files") "Secondary" (htmlSafe "Editor"))}}<.splitter aria-label="Files" value={30} primary_id="files">
<:primary>Files</:primary>
<:secondary>Editor</:secondary>
</.splitter><div id="ex-split-files" data-slot="splitter" data-orientation="horizontal" style="--split:30%" class="grid w-full overflow-hidden rounded-md border bg-card data-[orientation=horizontal]:h-64 data-[orientation=horizontal]:grid-cols-[var(--split,50%)_auto_minmax(0,1fr)] data-[orientation=vertical]:h-96 data-[orientation=vertical]:grid-rows-[var(--split,50%)_auto_minmax(0,1fr)] w-full max-w-xl">
<div data-slot="splitter-panel" data-splitter-panel="primary" id="ex-split-files-pane" class="min-h-0 min-w-0 overflow-auto p-4 text-sm text-foreground">
<p class="font-medium">Files</p>
<p class="mt-1 text-muted-foreground">Drag the bar, or focus it and use the arrow keys.</p>
</div>
<div role="separator" tabindex="0" data-slot="splitter-handle" data-orientation="horizontal" data-min="0" data-max="100" data-step="10" data-collapsed="false" aria-orientation="horizontal" aria-controls="ex-split-files-pane" aria-label="Files" aria-valuenow="30" aria-valuemin="0" aria-valuemax="100" class="group/splitter relative flex shrink-0 touch-none items-center justify-center bg-border outline-none transition-colors select-none hover:bg-ring/40 focus-visible:bg-ring/40 focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[orientation=horizontal]:w-1.5 data-[orientation=horizontal]:cursor-col-resize data-[orientation=horizontal]:h-full data-[orientation=vertical]:h-1.5 data-[orientation=vertical]:cursor-row-resize data-[orientation=vertical]:w-full">
<span class="pointer-events-none rounded-full bg-muted-foreground/40 transition-colors group-hover/splitter:bg-muted-foreground/70 group-data-[orientation=horizontal]/splitter:h-6 group-data-[orientation=horizontal]/splitter:w-0.5 group-data-[orientation=vertical]/splitter:w-6 group-data-[orientation=vertical]/splitter:h-0.5" aria-hidden="true">
</span>
</div>
<div data-slot="splitter-panel" data-splitter-panel="secondary" class="min-h-0 min-w-0 overflow-auto p-4 text-sm text-foreground">
<p class="font-medium">Editor</p>
<p class="mt-1 text-muted-foreground">The secondary pane fills the rest.</p>
</div>
</div>Further reading
Vertical split
orientation="vertical" stacks the panes; aria-orientation flips and the arrow-key axis follows — Up/Down resize instead of Left/Right.
The same grid, rotated. We size the first row with --split instead of the first column, set aria-orientation="vertical", and the keyboard handler reads that to drive Up/Down.
Preview
Focus the divider and press ↑/↓.
Console
Output goes here.
<Splitter orientation="vertical" ariaLabel="Preview" value={40} primaryId="preview"
primary={<p>Preview</p>}
secondary={<p>Console</p>} />{{ splitter(orientation="vertical", aria_label="Preview", value=40,
primary_id="preview", primary="Preview", secondary="Console") }}{{template "splitter" (dict "Orientation" "vertical" "AriaLabel" "Preview" "Value" 40 "PrimaryID" "preview" "Primary" (htmlSafe "Preview") "Secondary" (htmlSafe "Console"))}}<.splitter orientation="vertical" aria-label="Preview" value={40} primary_id="preview">
<:primary>Preview</:primary>
<:secondary>Console</:secondary>
</.splitter><div id="ex-split-vert" data-slot="splitter" data-orientation="vertical" style="--split:40%" class="grid w-full overflow-hidden rounded-md border bg-card data-[orientation=horizontal]:h-64 data-[orientation=horizontal]:grid-cols-[var(--split,50%)_auto_minmax(0,1fr)] data-[orientation=vertical]:h-96 data-[orientation=vertical]:grid-rows-[var(--split,50%)_auto_minmax(0,1fr)] h-72 w-full max-w-xl">
<div data-slot="splitter-panel" data-splitter-panel="primary" id="ex-split-vert-pane" class="min-h-0 min-w-0 overflow-auto p-4 text-sm text-foreground">
<p class="font-medium">Preview</p>
<p class="mt-1 text-muted-foreground">Focus the divider and press ↑/↓.</p>
</div>
<div role="separator" tabindex="0" data-slot="splitter-handle" data-orientation="vertical" data-min="0" data-max="100" data-step="10" data-collapsed="false" aria-orientation="vertical" aria-controls="ex-split-vert-pane" aria-label="Preview" aria-valuenow="40" aria-valuemin="0" aria-valuemax="100" class="group/splitter relative flex shrink-0 touch-none items-center justify-center bg-border outline-none transition-colors select-none hover:bg-ring/40 focus-visible:bg-ring/40 focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[orientation=horizontal]:w-1.5 data-[orientation=horizontal]:cursor-col-resize data-[orientation=horizontal]:h-full data-[orientation=vertical]:h-1.5 data-[orientation=vertical]:cursor-row-resize data-[orientation=vertical]:w-full">
<span class="pointer-events-none rounded-full bg-muted-foreground/40 transition-colors group-hover/splitter:bg-muted-foreground/70 group-data-[orientation=horizontal]/splitter:h-6 group-data-[orientation=horizontal]/splitter:w-0.5 group-data-[orientation=vertical]/splitter:w-6 group-data-[orientation=vertical]/splitter:h-0.5" aria-hidden="true">
</span>
</div>
<div data-slot="splitter-panel" data-splitter-panel="secondary" class="min-h-0 min-w-0 overflow-auto p-4 text-sm text-foreground">
<p class="font-medium">Console</p>
<p class="mt-1 text-muted-foreground">Output goes here.</p>
</div>
</div>Further reading
Bounds & collapse
min/max constrain how far the divider travels; step sets the arrow-key increment. Home and Enter collapse the primary pane to its minimum.
Set min / max to keep the primary pane within a usable range, and step for the keyboard increment. Enter toggles collapse: it drops to the minimum, then restores the previous position — the APG Window Splitter Enter behaviour.
Sidebar
Travels 20–80%. ←/→ moves by 5.
Content
Press Enter on the divider to collapse and restore.
<Splitter ariaLabel="Sidebar" value={50} min={20} max={80} step={5} primaryId="sidebar"
primary={<p>Sidebar</p>}
secondary={<p>Content</p>} />{{ splitter(aria_label="Sidebar", value=50, min=20, max=80, step=5,
primary_id="sidebar", primary="Sidebar", secondary="Content") }}{{template "splitter" (dict "AriaLabel" "Sidebar" "Value" 50 "Min" 20 "Max" 80 "Step" 5 "PrimaryID" "sidebar" "Primary" (htmlSafe "Sidebar") "Secondary" (htmlSafe "Content"))}}<.splitter aria-label="Sidebar" value={50} min={20} max={80} step={5} primary_id="sidebar">
<:primary>Sidebar</:primary>
<:secondary>Content</:secondary>
</.splitter><div id="ex-split-bounds" data-slot="splitter" data-orientation="horizontal" style="--split:50%" class="grid w-full overflow-hidden rounded-md border bg-card data-[orientation=horizontal]:h-64 data-[orientation=horizontal]:grid-cols-[var(--split,50%)_auto_minmax(0,1fr)] data-[orientation=vertical]:h-96 data-[orientation=vertical]:grid-rows-[var(--split,50%)_auto_minmax(0,1fr)] w-full max-w-xl">
<div data-slot="splitter-panel" data-splitter-panel="primary" id="ex-split-bounds-pane" class="min-h-0 min-w-0 overflow-auto p-4 text-sm text-foreground">
<p class="font-medium">Sidebar</p>
<p class="mt-1 text-muted-foreground">Travels 20–80%. ←/→ moves by 5.</p>
</div>
<div role="separator" tabindex="0" data-slot="splitter-handle" data-orientation="horizontal" data-min="20" data-max="80" data-step="5" data-collapsed="false" aria-orientation="horizontal" aria-controls="ex-split-bounds-pane" aria-label="Sidebar" aria-valuenow="50" aria-valuemin="20" aria-valuemax="80" class="group/splitter relative flex shrink-0 touch-none items-center justify-center bg-border outline-none transition-colors select-none hover:bg-ring/40 focus-visible:bg-ring/40 focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[orientation=horizontal]:w-1.5 data-[orientation=horizontal]:cursor-col-resize data-[orientation=horizontal]:h-full data-[orientation=vertical]:h-1.5 data-[orientation=vertical]:cursor-row-resize data-[orientation=vertical]:w-full">
<span class="pointer-events-none rounded-full bg-muted-foreground/40 transition-colors group-hover/splitter:bg-muted-foreground/70 group-data-[orientation=horizontal]/splitter:h-6 group-data-[orientation=horizontal]/splitter:w-0.5 group-data-[orientation=vertical]/splitter:w-6 group-data-[orientation=vertical]/splitter:h-0.5" aria-hidden="true">
</span>
</div>
<div data-slot="splitter-panel" data-splitter-panel="secondary" class="min-h-0 min-w-0 overflow-auto p-4 text-sm text-foreground">
<p class="font-medium">Content</p>
<p class="mt-1 text-muted-foreground">Press Enter on the divider to collapse and restore.</p>
</div>
</div>Further reading
API Reference
<Splitter>
| Prop | Type | Default | Description |
|---|---|---|---|
ariaValuetext | string | — | Human-readable value announced by assistive tech in place of the bare aria-valuenow number on the divider (e.g. "Files, 30%"). Per MDN, a focusable role=separator may carry aria-valuetext when aria-valuenow alone is not optimal. |
orientation | "horizontal"|"vertical" | "horizontal" | Layout axis. horizontal puts the panes side by side (the divider resizes width); vertical stacks them (resizes height). Sets aria-orientation and selects the arrow-key axis: Left/Right when horizontal, Up/Down when vertical.MDNaria-orientation |
primary | Child | — | Content of the primary pane — the one the divider's value sizes. |
secondary | Child | — | Content of the secondary pane, which fills the remaining space. |
value | number | 50 | Initial size of the primary pane as a percent, clamped into [min, max]. Becomes aria-valuenow and the --split CSS variable.APGWindow Splitter (value = primary pane size) |
min | number | 0 | Position giving the primary pane its smallest size (aria-valuemin). Typically 0 — fully collapsed.MDNaria-valuemin |
max | number | 100 | Position giving the primary pane its largest size (aria-valuemax). Typically 100.MDNaria-valuemax |
step | number | 10 | Resize increment per arrow-key press (in the same percent units as value). |
ariaLabel | string | — | Accessible name for the divider; per APG it matches the primary pane (e.g. "Files"). Required when there's no visible label and ariaLabelledby is not set.APGWindow Splitter roles, states & properties |
ariaLabelledby | string | — | Id of a visible element (typically the primary pane's heading) that names the divider, used in place of ariaLabel.MDNaria-labelledby |
primaryId | string | id-primary | Id given to the primary pane; the divider's aria-controls points at it. Auto-derived from id when omitted.MDNaria-controls |
id | string | — | Root id. Also seeds primaryId (id-primary) when primaryId is not supplied. |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |