Skip to main content
astrocraft-ui/ components · 101

Resizable

Free · MIT

Headless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.

Install

Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).

Unstyled — behavior only, you write the CSS

Install command
npx astrocraft-ui add utility/resizable

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add utility/resizable --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add utility/resizable --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add utility/resizable --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add utility/resizable --theme tailwind --bridge lumos

Live demo

Resizable

A real ARIA window splitter: drag the handle, or tab to it and use ←→ and Home/End. It reports aria-valuenow as a percentage, and the range it reports is the one the boundary can actually travel — the middle pane's minSize of 20 constrains its neighbours' handles, not just its own.

Files

30, min 15

Editor

45, min 20

Preview

25, min 15

Output

Problems

Attribute surface

With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.

ComponentSlotsVariantsRuntime stateNative state
`ResizableGroup.astro``resizable-group``data-orientation`: `horizontal` · `vertical``data-orientation`—
`ResizableHandle.astro``resizable-handle``data-orientation`: `horizontal` · `vertical`——
`ResizablePanel.astro``resizable-panel`———

Source

What the command copies — 5 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.

src/components/ui/utility/resizable/ResizableGroup.astro
---
// src/components/ui/utility/resizable/ResizableGroup.astro — headless primitive (see ../../README.md).
// Split panes. Compose ResizablePanel / ResizableHandle / ResizablePanel as direct children, in that
// order — a handle resizes the panel immediately before it and the one immediately after, so the DOM
// order IS the wiring and there is nothing to keep in sync.
//
// The layout is flex with `flex-basis: 0` (structure.css), so a pane's `size` is a share of whatever
// is left after the handles, at any container width, with no arithmetic to redo on resize. The script
// normalises those shares to sum to 100 on load, which is what makes `minSize` / `maxSize` mean
// percentages; `resize-pair.ts` holds the one rule that keeps a drag honest, and is checked without a
// DOM by `resize-pair.test.ts`.
//
// ponytail: pixel-to-percent conversion measures the group's client size minus the handles, so a
// `gap` or padding on the group makes a drag track the pointer slightly loosely. The upgrade is
// measuring the leading panel's own rect per drag instead of the group's; it has not been worth it.
import "../../../../styles/structure.css";

import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"div"> & {
  /** `horizontal` lays panes side by side; `vertical` stacks them. */
  orientation?: "horizontal" | "vertical";
};

const { orientation = "horizontal", class: className, ...rest } = Astro.props;
---

<div data-slot="resizable-group" data-orientation={orientation} class={className} {...rest}>
  <slot />
</div>

<script>
  import { onReadyOnce } from "../../_once";
  import { resizePair } from "./resize-pair";

  let uid = 0;

  const num = (value: string | undefined, fallback: number): number => {
    const parsed = Number(value);
    return Number.isFinite(parsed) ? parsed : fallback;
  };

  function wire(group: HTMLElement) {
    // Direct children only, so a nested group's panes stay that group's problem.
    const parts = [...group.children].filter(
      (el): el is HTMLElement =>
        el instanceof HTMLElement &&
        (el.dataset.slot === "resizable-panel" || el.dataset.slot === "resizable-handle"),
    );
    const panels = parts.filter((el) => el.dataset.slot === "resizable-panel");
    const handles = parts.filter((el) => el.dataset.slot === "resizable-handle");
    if (panels.length < 2 || handles.length === 0) return;

    const vertical = group.dataset.orientation === "vertical";
    const growOf = (panel: HTMLElement) => num(panel.style.flexGrow, 0);

    // Normalise to 100 so `minSize` / `maxSize` are percentages of the group rather than of whatever
    // the author's factors happened to add up to. Idempotent anyway, though `onReadyOnce` means a
    // persisted group is normalised once rather than again after every view transition.
    const authored = panels.reduce((sum, panel) => sum + growOf(panel), 0);
    for (const panel of panels) {
      panel.style.flexGrow = String(
        authored > 0 ? (growOf(panel) / authored) * 100 : 100 / panels.length,
      );
    }

    const limitsOf = (panel: HTMLElement) => ({
      min: num(panel.dataset.minSize, 0),
      max: num(panel.dataset.maxSize, 100),
    });

    const neighbours = (handle: HTMLElement): [HTMLElement, HTMLElement] | null => {
      const i = parts.indexOf(handle);
      const lead = parts[i - 1];
      const trail = parts[i + 1];
      const isPanel = (el?: HTMLElement) => el?.dataset.slot === "resizable-panel";
      return isPanel(lead) && isPanel(trail) ? [lead, trail] : null;
    };

    /**
     * Publish where a boundary is and how far it can travel.
     *
     * The reported range is the FOLDED one — not the leading panel's own min/max, but how far this
     * boundary can actually move, which is a different number whenever the TRAILING panel is the one
     * that runs out first. Asking `resizePair` for it with an infinite drag is cheaper than
     * restating the fold here, and cannot disagree with what a real drag will do.
     */
    const report = (handle: HTMLElement) => {
      const pair = neighbours(handle);
      if (!pair) return;
      const base: [number, number] = [growOf(pair[0]), growOf(pair[1])];
      const limits = [limitsOf(pair[0]), limitsOf(pair[1])] as const;
      handle.setAttribute(
        "aria-valuemin",
        String(Math.round(resizePair(base, -Infinity, limits)[0])),
      );
      handle.setAttribute(
        "aria-valuemax",
        String(Math.round(resizePair(base, Infinity, limits)[0])),
      );
      handle.setAttribute("aria-valuenow", String(Math.round(base[0])));
      handle.setAttribute("aria-valuetext", `${Math.round(base[0])}%`);
    };

    const apply = (handle: HTMLElement, base: [number, number], delta: number) => {
      const pair = neighbours(handle);
      if (!pair) return;
      const next = resizePair(base, delta, [limitsOf(pair[0]), limitsOf(pair[1])]);
      pair[0].style.flexGrow = String(next[0]);
      pair[1].style.flexGrow = String(next[1]);
      // EVERY handle, not only this one. Moving a boundary changes how much space its neighbour has
      // left to divide, so the neighbouring handle's reported range goes stale the instant this one
      // moves — and a splitter that announces a maximum it can no longer reach is worse than one
      // that announces nothing.
      for (const other of handles) report(other);
    };

    /** The pixels the flex factors divide up: the group, less the handles, which do not flex. */
    const span = () =>
      handles.reduce(
        (left, handle) => left - (vertical ? handle.offsetHeight : handle.offsetWidth),
        vertical ? group.clientHeight : group.clientWidth,
      );

    for (const handle of handles) {
      const pair = neighbours(handle);
      if (!pair) continue;
      const sizes = (): [number, number] => [growOf(pair[0]), growOf(pair[1])];

      // Both, from the group it actually landed in, so a handle whose `orientation` prop was left at
      // the default inside a vertical group corrects itself — `aria-orientation` is what it
      // announces, `data-orientation` is what structure.css sizes it by, and a handle that got one
      // right and the other wrong would be a separator you cannot grab.
      handle.setAttribute("aria-orientation", vertical ? "horizontal" : "vertical");
      handle.dataset.orientation = vertical ? "vertical" : "horizontal";
      pair[0].id ||= `resizable-panel-${(uid += 1)}`;
      pair[1].id ||= `resizable-panel-${(uid += 1)}`;
      handle.setAttribute("aria-controls", `${pair[0].id} ${pair[1].id}`);

      handle.addEventListener("keydown", (event) => {
        const step = num(handle.dataset.step, 2);
        const moves: Record<string, number> = {
          [vertical ? "ArrowUp" : "ArrowLeft"]: -step,
          [vertical ? "ArrowDown" : "ArrowRight"]: step,
          Home: -Infinity,
          End: Infinity,
        };
        const delta = moves[event.key];
        if (delta === undefined) return;
        event.preventDefault();
        apply(handle, sizes(), delta);
      });

      let drag: { at: number; base: [number, number]; span: number } | null = null;
      handle.addEventListener("pointerdown", (event) => {
        // Stops the browser starting a text selection across both panes. Cheaper and far less
        // invasive than writing `user-select: none` onto the document and remembering to undo it.
        event.preventDefault();
        // Armed before the capture, not after: a failed `setPointerCapture` throws, and a drag that
        // is only recorded on the line below would be lost entirely rather than merely uncaptured.
        drag = { at: vertical ? event.clientY : event.clientX, base: sizes(), span: span() };
        handle.setPointerCapture(event.pointerId);
        handle.focus();
      });
      handle.addEventListener("pointermove", (event) => {
        if (!drag || drag.span <= 0) return;
        const moved = (vertical ? event.clientY : event.clientX) - drag.at;
        apply(handle, drag.base, (moved / drag.span) * 100);
      });
      for (const type of ["pointerup", "pointercancel"]) {
        handle.addEventListener(type, () => {
          drag = null;
        });
      }

      // Report where the boundary starts, without moving it.
      report(handle);
    }
  }

  onReadyOnce('[data-slot="resizable-group"]', wire);
</script>
src/components/ui/utility/resizable/ResizableHandle.astro
---
// src/components/ui/utility/resizable/ResizableHandle.astro — Resizable part (see ../../README.md).
// The draggable boundary, and a real ARIA window splitter: `role="separator"` with a `tabindex`, a
// value, and arrow keys. A splitter that is pointer-only is the single most common way this component
// is shipped broken — it looks finished, and a keyboard user simply cannot move it.
//
// `orientation` is the GROUP's axis, so it reads the same way as ResizableGroup's, and it renders as
// `data-orientation` — the attribute a theme and structure.css both key on. The `aria-orientation`
// beside it is the SEPARATOR's own, which is the opposite: panes side by side are divided by a
// vertical line. Carrying both is not duplication; they are two different facts, and it is exactly
// why the CSS keys on the one that reads the same way as the group.
//
// The script re-asserts both from the group the handle actually lands in, so a handle left at the
// default inside a vertical group corrects itself rather than announcing and sizing itself wrongly.
//
// `touch-action: none` (structure.css) is what makes a touch drag resize instead of scrolling the page.
import "../../../../styles/structure.css";

// The rule does not know that a FOCUSABLE separator is a widget in ARIA's own terms — the window
// splitter pattern requires exactly this tab stop, and without it the handle is pointer-only.
/* eslint-disable astro/jsx-a11y/no-noninteractive-tabindex */
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"div"> & {
  /** The group's axis — `horizontal` means panes side by side. */
  orientation?: "horizontal" | "vertical";
  /** Percentage points one arrow-key press moves the boundary. */
  step?: number;
  label?: string;
};

const {
  orientation = "horizontal",
  step = 2,
  label = "Resize panels",
  class: className,
  ...rest
} = Astro.props;
---

<div
  role="separator"
  tabindex="0"
  aria-orientation={orientation === "horizontal" ? "vertical" : "horizontal"}
  data-orientation={orientation}
  aria-label={label}
  aria-valuemin="0"
  aria-valuemax="100"
  aria-valuenow="50"
  data-slot="resizable-handle"
  data-step={step}
  class={className}
  {...rest}
>
  <slot />
</div>
src/components/ui/utility/resizable/ResizablePanel.astro
---
// src/components/ui/utility/resizable/ResizablePanel.astro — Resizable part (see ../../README.md).
// One pane. `size` is a flex-grow factor, so panes divide the space left over after the handles have
// taken theirs — no percentage arithmetic that has to be corrected for handle widths, and no absolute
// widths to recompute when the group is resized. The ResizableGroup script normalises the group's
// factors to sum to 100 on load, which is what makes `minSize` / `maxSize` readable as percentages.
//
// `min-width: 0` is in structure.css and is load-bearing: a flex item defaults to `min-width: auto`,
// which refuses to shrink below its content, so a pane with a long word in it silently ignores every
// size this component writes.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"div"> & {
  /** Share of the group, relative to its siblings. Normalised to percent on load. */
  size?: number;
  /** Percentage of the group this pane may not shrink below. */
  minSize?: number;
  /** Percentage of the group this pane may not grow past. */
  maxSize?: number;
};

const { size = 1, minSize = 0, maxSize = 100, class: className, ...rest } = Astro.props;
---

<div
  data-slot="resizable-panel"
  data-min-size={minSize}
  data-max-size={maxSize}
  style={`flex-grow:${size}`}
  class={className}
  {...rest}
>
  <slot />
</div>
src/components/ui/utility/resizable/index.ts
import ResizableGroup from "./ResizableGroup.astro";
import ResizableHandle from "./ResizableHandle.astro";
import ResizablePanel from "./ResizablePanel.astro";

export { ResizableGroup, ResizableHandle, ResizablePanel };
export default ResizableGroup;
src/components/ui/utility/resizable/resize-pair.ts
// src/components/ui/utility/resizable/resize-pair.ts — the one rule a split pane needs: moving a boundary
// takes space from one neighbour and gives exactly that much to the other, and neither may be pushed
// past its own limits. Pure, so it is checkable without a DOM or a pointer (see resize-pair.test.ts).
//
// Sizes are percentages of the group's free space and are held as flex-grow factors that sum to 100,
// which is why the invariant below is worth stating: `low + high` is CONSTANT. A resize that leaks a
// fraction of a percent per drag looks fine for ten drags and has collapsed a panel by the fiftieth.

export interface PanelLimits {
  /** Smallest share this panel may be left with, as a percentage of the group. */
  readonly min: number;
  /** Largest share, as a percentage of the group. */
  readonly max: number;
}

/**
 * Move the boundary between two adjacent panels by `delta` percent.
 *
 * Both panels' limits constrain the same single number, so they are folded into one range before
 * clamping — the leading panel cannot grow past its own `max`, and it also cannot grow past the point
 * where the trailing panel would drop below ITS `min`. A version that clamps against only the panel
 * being dragged looks right until the other one is the one that runs out.
 *
 * @param pair - the two panels' current sizes, leading first
 * @param delta - percentage points to add to the leading panel (negative shrinks it)
 * @param limits - each panel's own bounds, in the same order
 * @returns the sizes to write back; they always sum to `pair[0] + pair[1]`
 * @example resizePair([50, 50], 10, [{ min: 0, max: 100 }, { min: 20, max: 100 }]) // => [60, 40]
 */
export function resizePair(
  pair: readonly [number, number],
  delta: number,
  limits: readonly [PanelLimits, PanelLimits],
): [number, number] {
  const total = pair[0] + pair[1];
  const low = Math.max(limits[0].min, total - limits[1].max);
  const high = Math.min(limits[0].max, total - limits[1].min);
  // Limits that cannot all hold at once — two minimums adding up to more than the pair has between
  // them. Something has to give, and it is the trailing panel: honouring the leading one keeps the
  // handle where the user dragged it instead of snapping it backwards past their pointer.
  const leading = low > high ? low : Math.min(high, Math.max(low, pair[0] + delta));
  return [leading, total - leading];
}

What you get

The component source, copied into your project by npx astrocraft-ui add utility/resizable — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.