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, shown without its comments; the files you install keep them, with the reasoning behind every rule.

src/components/ui/utility/resizable/ResizableGroup.astro
---
import "../../../../styles/structure.css";

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

type Props = HTMLAttributes<"div"> & {
  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) {
    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);

    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;
    };

    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]);
      for (const other of handles) report(other);
    };

    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])];

      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) => {
        event.preventDefault();
        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(handle);
    }
  }

  onReadyOnce('[data-slot="resizable-group"]', wire);
</script>
src/components/ui/utility/resizable/ResizableHandle.astro
---
import "../../../../styles/structure.css";

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

type Props = HTMLAttributes<"div"> & {
  orientation?: "horizontal" | "vertical";
  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
---
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"div"> & {
  size?: number;
  minSize?: number;
  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
export interface PanelLimits {
  readonly min: number;
  readonly max: number;
}

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);
  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.