Skip to content
Gx
GitHub

Resizable

A group of panels with a handle that changes their sizes.

<resizable.ResizableHandle id="resize-between" />

Installation

Run the command in the app module.

Terminal
gx add resizable

The command writes these files.

File Path in the app
ResizablePanelGroup.gx ui/resizable/ResizablePanelGroup.gx
ResizablePanel.gx ui/resizable/ResizablePanel.gx
ResizableHandle.gx ui/resizable/ResizableHandle.gx
ResizablePanelGroup.fixtures.go ui/resizable/ResizablePanelGroup.fixtures.go
ResizablePanel.fixtures.go ui/resizable/ResizablePanel.fixtures.go
ResizableHandle.fixtures.go ui/resizable/ResizableHandle.fixtures.go
ResizableDrag.ts ui/resizable/ResizableDrag.ts
styles.go ui/resizable/styles.go

Copy each file to its path in the app. Change each import of a registry package to the path of that package in the app.

ui/resizable/ResizablePanelGroup.gx
package resizable

props {
  // Orientation sets the direction of the panels: Horizontal or Vertical.
  Orientation Orientation = Horizontal
  // Class adds classes to the root element.
  Class       string = ""
  // Children are the panels, with one ResizableHandle between two panels.
  Children    gx.Node
  // Attrs adds HTML attributes to the root element.
  Attrs       gx.Attrs = nil
}

<div data-slot="resizable-panel-group" data-orientation={p.orientation()} class={gx.Cx(groupClass[p.orientation()], p.Class)} {...p.Attrs}>{p.Children}</div>
ui/resizable/ResizablePanel.gx
package resizable

props {
  // Size is the first size of the panel, in percent of the group. A zero value shares the free space.
  Size     int = 0
  // MinSize is the smallest size of the panel, in percent of the group.
  MinSize  int = 0
  // MaxSize is the largest size of the panel, in percent of the group. A zero value is 100.
  MaxSize  int = 0
  // Class adds classes to the content element inside the panel.
  Class    string = ""
  // Children is the content of the panel.
  Children gx.Node
  // Attrs adds HTML attributes to the root element.
  Attrs    gx.Attrs = nil
}

<div data-slot="resizable-panel" data-min-size={p.MinSize} data-max-size={p.maxSize()} style={p.style()} class={panelClass} {...p.Attrs}>
  <div class={gx.Cx(contentClass, p.Class)}>{p.Children}</div>
</div>
ui/resizable/ResizableHandle.gx
package resizable

props {
  // Id is the id of the handle.
  Id          string
  // Label is the accessible name of the handle.
  Label       string = "Resize panels"
  // Orientation is the direction of the panel group: Horizontal or Vertical.
  Orientation Orientation = Horizontal
  // Step is the size of one arrow key step, in percent of the group. A zero value is 5.
  Step        int = 5
  // Class adds classes to the root element.
  Class       string = ""
  // Attrs adds HTML attributes to the root element.
  Attrs       gx.Attrs = nil
}

<div id={p.Id} role="separator" tabindex="0" aria-label={p.label()} aria-orientation={p.ariaOrientation()} aria-valuenow="50" aria-valuemin="0" aria-valuemax="100" data-slot="resizable-handle" class={gx.Cx(handleClass[p.orientation()], p.Class)} {...p.Attrs}>
  <ResizableDrag handle={p.Id} step={p.step()} gripClass={gripClass[p.orientation()]} load="eager" />
</div>
ui/resizable/ResizablePanelGroup.fixtures.go
package resizable

import "github.com/alternayte/gx"

// paneClass centres the text of a demo panel.
const paneClass = "flex items-center justify-center p-4 text-sm font-medium"

var ResizablePanelGroupFixtures = gx.Fixtures[ResizablePanelGroupProps]{
	"Horizontal": {Class: "h-40 max-w-md rounded-md border border-border", Children: gx.Frag(
		ResizablePanel(ResizablePanelProps{Size: 40, MinSize: 20, Class: paneClass, Children: gx.Text("One")}),
		ResizableHandle(ResizableHandleProps{Id: "resize-h"}),
		ResizablePanel(ResizablePanelProps{Size: 60, MinSize: 30, Class: paneClass, Children: gx.Text("Two")}),
	)},
	"Vertical": {Orientation: Vertical, Class: "h-56 max-w-md rounded-md border border-border", Children: gx.Frag(
		ResizablePanel(ResizablePanelProps{Size: 50, Class: paneClass, Children: gx.Text("Top")}),
		ResizableHandle(ResizableHandleProps{Id: "resize-v", Orientation: Vertical}),
		ResizablePanel(ResizablePanelProps{Size: 50, Class: paneClass, Children: gx.Text("Bottom")}),
	)},
}

// ResizablePanelGroupWrap gives a fixture the width of a page column.
func ResizablePanelGroupWrap(n gx.Node) gx.Node {
	return gx.El("div", gx.Attrs{{Key: "class", Value: "w-full max-w-md"}}, n)
}
ui/resizable/ResizablePanel.fixtures.go
package resizable

import "github.com/alternayte/gx"

var ResizablePanelFixtures = gx.Fixtures[ResizablePanelProps]{
	"Default": {Children: gx.Text("A panel")},
}
ui/resizable/ResizableHandle.fixtures.go
package resizable

import "github.com/alternayte/gx"

var ResizableHandleFixtures = gx.Fixtures[ResizableHandleProps]{
	"Between": {Id: "resize-between"},
}

// ResizableHandleWrap puts the handle between two panels: a handle works
// only there.
func ResizableHandleWrap(n gx.Node) gx.Node {
	return ResizablePanelGroup(ResizablePanelGroupProps{Class: "h-24 max-w-md rounded-md border border-border", Children: gx.Frag(
		ResizablePanel(ResizablePanelProps{Size: 50, Children: gx.Text("One")}),
		n,
		ResizablePanel(ResizablePanelProps{Size: 50, Children: gx.Text("Two")}),
	)})
}
ui/resizable/ResizableDrag.ts
import type { Mount } from "./ResizableDrag.props";

// The island makes one handle between two panels movable: with the pointer
// and with the arrow keys. The panels are server HTML with their first
// sizes, so with no script the page shows a fixed split.
const mount: Mount = (root, props, ctx) => {
  const handle = document.getElementById(props.handle);
  if (!handle) throw new Error(`no handle with the id ${props.handle}`);
  const before = handle.previousElementSibling;
  const after = handle.nextElementSibling;
  const group = handle.parentElement;
  if (!(before instanceof HTMLElement) || !(after instanceof HTMLElement) || !group) {
    throw new Error("a handle needs a panel before it and a panel after it");
  }
  const vertical = group.getAttribute("data-orientation") === "vertical";
  root.className = props.gripClass;
  root.setAttribute("aria-hidden", "true");

  const size = (el: HTMLElement): number => (vertical ? el.getBoundingClientRect().height : el.getBoundingClientRect().width);
  const limit = (el: HTMLElement, name: string, fallback: number): number => {
    const v = Number(el.getAttribute(name));
    return Number.isFinite(v) && el.hasAttribute(name) ? v : fallback;
  };

  // A size is a part of the room that the panels of the group share, in
  // percent. The handles take none of that room.
  const panels = (): HTMLElement[] =>
    [...group.children].filter((el): el is HTMLElement => el instanceof HTMLElement && el.getAttribute("data-slot") === "resizable-panel");
  const room = (): number => panels().reduce((sum, el) => sum + size(el), 0);
  const total = (): number => ((size(before) + size(after)) / room()) * 100;
  const share = (): number => (size(before) / room()) * 100;
  // Each panel gets its measured size as its weight, so a panel with no
  // size from the server keeps its place when a different one changes.
  const weigh = (): void => {
    const all = panels();
    const sizes = all.map(size);
    const sum = sizes.reduce((a, b) => a + b, 0) || 1;
    all.forEach((el, i) => (el.style.flex = `${(sizes[i] / sum) * 100} 1 0%`));
  };

  // set gives the first panel a size in percent of the group, inside the
  // limits of both panels. The second panel takes the rest of the two.
  const set = (percent: number): void => {
    const both = total();
    const min = Math.max(limit(before, "data-min-size", 0), both - limit(after, "data-max-size", 100));
    const max = Math.min(limit(before, "data-max-size", 100), both - limit(after, "data-min-size", 0));
    const value = Math.max(min, Math.min(max, percent));
    before.style.flex = `${value} 1 0%`;
    after.style.flex = `${both - value} 1 0%`;
    handle.setAttribute("aria-valuenow", String(Math.round(value)));
    handle.setAttribute("aria-valuemin", String(Math.round(min)));
    handle.setAttribute("aria-valuemax", String(Math.round(max)));
    handle.dispatchEvent(new CustomEvent("resize-panels", { bubbles: true, detail: { before: value, after: both - value } }));
  };

  const listen = { signal: ctx.abort };
  let dragging = false;
  handle.addEventListener(
    "pointerdown",
    (event) => {
      dragging = true;
      handle.setPointerCapture(event.pointerId);
      handle.setAttribute("data-dragging", "true");
      event.preventDefault();
    },
    listen,
  );
  handle.addEventListener(
    "pointermove",
    (event) => {
      if (!dragging) return;
      const box = group.getBoundingClientRect();
      const start = vertical ? before.getBoundingClientRect().top : before.getBoundingClientRect().left;
      const at = vertical ? event.clientY : event.clientX;
      set(((at - start) / (vertical ? box.height : box.width)) * 100);
    },
    listen,
  );
  const stop = (): void => {
    dragging = false;
    handle.removeAttribute("data-dragging");
  };
  handle.addEventListener("pointerup", stop, listen);
  handle.addEventListener("pointercancel", stop, listen);
  handle.addEventListener(
    "keydown",
    (event) => {
      const back = vertical ? "ArrowUp" : "ArrowLeft";
      const forward = vertical ? "ArrowDown" : "ArrowRight";
      switch (event.key) {
        case back:
          set(share() - props.step);
          break;
        case forward:
          set(share() + props.step);
          break;
        case "Home":
          set(0);
          break;
        case "End":
          set(100);
          break;
        default:
          return;
      }
      event.preventDefault();
    },
    listen,
  );
  // The first sizes come from the server; the handle reports them.
  weigh();
  set(share());

  return () => {
    root.className = "";
  };
};

export default mount;
ui/resizable/styles.go
// Package resizable is a group of panels with a handle between two panels.
// The ResizableDrag island moves a handle with the pointer and the keyboard.
package resizable

import (
	"strconv"

	"github.com/alternayte/gx"
)

// Orientation is the direction of a panel group.
type Orientation string

// The orientations of resizable.ResizablePanelGroup.
const (
	Horizontal Orientation = "horizontal"
	Vertical   Orientation = "vertical"
)

// ResizableDragProps are the props of the island of one handle. The island
// changes the sizes of the panel before and the panel after the element
// with the id Handle.
type ResizableDragProps struct {
	// Handle is the id of the handle element.
	Handle string `json:"handle"`
	// Step is the size of one arrow key step, in percent of the group.
	Step int `json:"step"`
	// GripClass is the classes of the grip in the middle of the handle. It
	// is a Go constant, so the stylesheet build sees it.
	GripClass string `json:"gripClass"`
}

var groupClass = gx.Enum[Orientation]{
	Horizontal: "flex h-full w-full flex-row",
	Vertical:   "flex h-full w-full flex-col",
}

// panelClass is the classes of one panel. A panel with no size shares the
// free space.
const panelClass = "min-h-0 min-w-0 flex-1 overflow-auto"

// contentClass is the classes of the content element of a panel. The
// classes of the user go here: padding on the panel itself would change its
// part of the group.
const contentClass = "h-full w-full"

// handleClass is the classes of a handle: a line with a wider area for the
// pointer.
var handleClass = gx.Enum[Orientation]{
	Horizontal: "relative flex w-px shrink-0 cursor-col-resize touch-none items-center justify-center bg-border outline-none after:absolute after:inset-y-0 after:left-1/2 after:w-2 after:-translate-x-1/2 focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[dragging=true]:bg-ring",
	Vertical:   "relative flex h-px w-full shrink-0 cursor-row-resize touch-none items-center justify-center bg-border outline-none after:absolute after:inset-x-0 after:top-1/2 after:h-2 after:-translate-y-1/2 focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[dragging=true]:bg-ring",
}

// gripClass is the classes of the grip that the island shows on a handle.
var gripClass = gx.Enum[Orientation]{
	Horizontal: "z-10 block h-4 w-1.5 rounded-xs border border-border bg-border",
	Vertical:   "z-10 block h-1.5 w-4 rounded-xs border border-border bg-border",
}

// orientation returns the direction; a zero value is Horizontal.
func (p ResizablePanelGroupProps) orientation() Orientation {
	if p.Orientation == Vertical {
		return Vertical
	}
	return Horizontal
}

// orientation returns the direction of the group; a zero value is
// Horizontal.
func (p ResizableHandleProps) orientation() Orientation {
	if p.Orientation == Vertical {
		return Vertical
	}
	return Horizontal
}

// ariaOrientation returns the direction of the separator line, which is
// across the direction of the group.
func (p ResizableHandleProps) ariaOrientation() string {
	if p.orientation() == Vertical {
		return "horizontal"
	}
	return "vertical"
}

// label returns the accessible name; a zero value is "Resize panels".
func (p ResizableHandleProps) label() string {
	if p.Label == "" {
		return "Resize panels"
	}
	return p.Label
}

// step returns the arrow key step; a zero value is 5.
func (p ResizableHandleProps) step() int {
	if p.Step <= 0 {
		return 5
	}
	return p.Step
}

// maxSize returns the largest size; a zero value is 100.
func (p ResizablePanelProps) maxSize() int {
	if p.MaxSize <= 0 {
		return 100
	}
	return p.MaxSize
}

// style gives a panel with a size its part of the group.
func (p ResizablePanelProps) style() gx.Style {
	if p.Size <= 0 {
		return ""
	}
	// The size is the weight of the panel in the room that the panels
	// share. The handles take none of that room.
	return gx.Style("flex: " + strconv.Itoa(p.Size) + " 1 0%")
}

The theme must define these tokens: --border, --ring.

Usage

<resizable.ResizablePanelGroup class="h-64 rounded-md border">
  <resizable.ResizablePanel size={30} minSize={20}>Sidebar</resizable.ResizablePanel>
  <resizable.ResizableHandle id="sidebar-handle" />
  <resizable.ResizablePanel size={70}>Content</resizable.ResizablePanel>
</resizable.ResizablePanelGroup>

The panels are server HTML, and Size is the first size of a panel in percent of the group. The island of a handle changes the sizes of the panel before it and the panel after it. The handle sends the resize-panels event with the two sizes. With no script the page shows the panels with their first sizes.

Examples

ResizableHandle: Between

<resizable.ResizableHandle id="resize-between" />

ResizablePanel: Default

<resizable.ResizablePanel>A panel</resizable.ResizablePanel>

ResizablePanelGroup: Horizontal

<resizable.ResizablePanelGroup class="h-40 max-w-md rounded-md border border-border">
  <resizable.ResizablePanel size={40} minSize={20} class="flex items-center justify-center p-4 text-sm font-medium">
    One
  </resizable.ResizablePanel>
  <resizable.ResizableHandle id="resize-h" />
  <resizable.ResizablePanel size={60} minSize={30} class="flex items-center justify-center p-4 text-sm font-medium">
    Two
  </resizable.ResizablePanel>
</resizable.ResizablePanelGroup>

ResizablePanelGroup: Vertical

<resizable.ResizablePanelGroup orientation={resizable.Vertical} class="h-56 max-w-md rounded-md border border-border">
  <resizable.ResizablePanel size={50} class="flex items-center justify-center p-4 text-sm font-medium">
    Top
  </resizable.ResizablePanel>
  <resizable.ResizableHandle id="resize-v" orientation={resizable.Vertical} />
  <resizable.ResizablePanel size={50} class="flex items-center justify-center p-4 text-sm font-medium">
    Bottom
  </resizable.ResizablePanel>
</resizable.ResizablePanelGroup>

API reference

A tag sets a prop by its name with a lower-case first letter: Class is class.

resizable.ResizableHandle

Prop Type Default Description
Id string Required Id is the id of the handle.
Label string "Resize panels" Label is the accessible name of the handle.
Orientation Orientation Horizontal Orientation is the direction of the panel group: Horizontal or Vertical.
Step int 5 Step is the size of one arrow key step, in percent of the group. A zero value is 5.
Class string "" Class adds classes to the root element.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.

resizable.ResizablePanel

Prop Type Default Description
Size int 0 Size is the first size of the panel, in percent of the group. A zero value shares the free space.
MinSize int 0 MinSize is the smallest size of the panel, in percent of the group.
MaxSize int 0 MaxSize is the largest size of the panel, in percent of the group. A zero value is 100.
Class string "" Class adds classes to the content element inside the panel.
Children gx.Node Required Children is the content of the panel.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.

resizable.ResizablePanelGroup

Prop Type Default Description
Orientation Orientation Horizontal Orientation sets the direction of the panels: Horizontal or Vertical.
Class string "" Class adds classes to the root element.
Children gx.Node Required Children are the panels, with one ResizableHandle between two panels.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.

Do and do not

Do

  • Put one ResizableHandle between two panels.
  • Give the group a height. A vertical group needs one.
  • Set MinSize for a panel whose content needs room.

Don't

  • Do not put a handle at the start or the end of a group.
  • Do not use two handles with one Id.

Keyboard

Key Action
Tab Moves focus to the handle.
Arrow Left, Arrow Right Makes the first panel smaller or larger. A vertical group uses Arrow Up and Arrow Down.
Home Makes the first panel as small as its limits allow.
End Makes the first panel as large as its limits allow.