shshadcn-htmx

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.json

2. Use it

components/ui/splitter.tsx
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
components/ui/splitter.tsx
/** @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

templates/components/splitter.html
{% from "components/splitter.html" import splitter %}

{{ splitter(aria_label="Files", value=30, primary_id="files",
            primary="Sidebar", secondary="Editor") }}
View source
templates/components/splitter.html
{# 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

components/splitter.tmpl
{{template "splitter" (dict "AriaLabel" "Files" "Value" 30 "PrimaryID" "files" "Primary" (htmlSafe "Sidebar") "Secondary" (htmlSafe "Editor"))}}
View source
components/splitter.tmpl
{{/*
  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

lib/my_app_web/components/splitter.ex
<.splitter aria-label="Files" value={30} primary_id="files">
  <:primary>Sidebar</:primary>
  <:secondary>Editor</:secondary>
</.splitter>
View source
lib/my_app_web/components/splitter.ex
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

snippets/splitter.html
<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
snippets/splitter.html
<!--
  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>

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>

API Reference

<Splitter>

PropTypeDefaultDescription
ariaValuetextstring
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
primaryChild
Content of the primary pane — the one the divider's value sizes.
secondaryChild
Content of the secondary pane, which fills the remaining space.
valuenumber50
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)
minnumber0
Position giving the primary pane its smallest size (aria-valuemin). Typically 0 — fully collapsed.MDNaria-valuemin
maxnumber100
Position giving the primary pane its largest size (aria-valuemax). Typically 100.MDNaria-valuemax
stepnumber10
Resize increment per arrow-key press (in the same percent units as value).
ariaLabelstring
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
ariaLabelledbystring
Id of a visible element (typically the primary pane's heading) that names the divider, used in place of ariaLabel.MDNaria-labelledby
primaryIdstringid-primary
Id given to the primary pane; the divider's aria-controls points at it. Auto-derived from id when omitted.MDNaria-controls
idstring
Root id. Also seeds primaryId (id-primary) when primaryId is not supplied.
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference