Components
Resizable
Drag-resizable panel group using native pointer events. Purely presentational — no Morphos equivalent.
Installation
npx kosmesis add resizablepnpm dlx kosmesis add resizableyarn dlx kosmesis add resizablebunx kosmesis add resizableCopy and paste the following code into your project.
import { StatefulComponent, StatelessComponent } from "@praxisjs/core";import { Component, Prop, Ref, State, type Ref as RefType } from "@praxisjs/decorators";import type { Children } from "@praxisjs/shared";import { cn } from "@/lib/utils";export interface ResizablePanelGroupProps { direction?: "horizontal" | "vertical"; class?: string; id?: string; children?: Children;}/** * Purely presentational — no Morphos equivalent (Radix has no resizable primitive either). * Unlike Dialog/Tabs/Accordion, there's no separate state instance to instantiate: the whole * group is a single JSX tree, and `ResizableHandle` finds its neighboring panels by walking the * DOM (`closest()`/`previousElementSibling`/`nextElementSibling`) at drag time instead of * requiring an external instance reference — sidestepping the "two different instances" pattern * entirely, since a live DOM node (not just component state) is what the drag math needs. */@Component()export class ResizablePanelGroup extends StatelessComponent<ResizablePanelGroupProps> { render() { const { direction = "horizontal", class: cls, id, children } = this.props; return ( <div id={id} data-slot="resizable-panel-group" data-direction={direction} class={cn("flex size-full data-[direction=vertical]:flex-col", cls)} > {children} </div> ); }}export interface ResizablePanelProps { defaultSize?: number; class?: string; id?: string; children?: Children;}@Component()export class ResizablePanel extends StatelessComponent<ResizablePanelProps> { render() { const { defaultSize = 50, class: cls, id, children } = this.props; return ( <div id={id} data-slot="resizable-panel" style={{ flexBasis: `${String(defaultSize)}%` }} class={cn("min-h-0 min-w-0 overflow-auto", cls)} > {children} </div> ); }}export interface ResizableHandleProps { withHandle?: boolean; class?: string;}@Component()export class ResizableHandle extends StatefulComponent { @Prop() withHandle = false; @Prop() class?: string; @Ref<HTMLDivElement>() handleRef!: RefType<HTMLDivElement>; @State() _dragging = false; private _startPos = 0; private _direction: "horizontal" | "vertical" = "horizontal"; private readonly _handlePointerDown = (event: PointerEvent) => { const handle = this.handleRef.current; const group = handle?.closest<HTMLElement>("[data-slot=resizable-panel-group]"); if (!handle || !group) return; event.preventDefault(); this._direction = group.dataset.direction === "vertical" ? "vertical" : "horizontal"; this._startPos = this._direction === "horizontal" ? event.clientX : event.clientY; this._dragging = true; handle.setPointerCapture(event.pointerId); }; private readonly _handlePointerMove = (event: PointerEvent) => { if (!this._dragging) return; const handle = this.handleRef.current; const before = handle?.previousElementSibling as HTMLElement | null; const after = handle?.nextElementSibling as HTMLElement | null; const group = handle?.closest<HTMLElement>("[data-slot=resizable-panel-group]"); if (!handle || !before || !after || !group) return; const pos = this._direction === "horizontal" ? event.clientX : event.clientY; const delta = pos - this._startPos; this._startPos = pos; const containerSize = this._direction === "horizontal" ? group.getBoundingClientRect().width : group.getBoundingClientRect().height; const deltaPct = (delta / containerSize) * 100; const beforeSize = parseFloat(before.style.flexBasis || "50"); const afterSize = parseFloat(after.style.flexBasis || "50"); const nextBefore = Math.min(Math.max(beforeSize + deltaPct, 5), 95); const nextAfter = beforeSize + afterSize - nextBefore; before.style.flexBasis = `${String(nextBefore)}%`; after.style.flexBasis = `${String(nextAfter)}%`; }; private readonly _handlePointerUp = () => { this._dragging = false; }; render() { return ( <div ref={this.handleRef} role="separator" data-slot="resizable-handle" data-resize-handle-active={() => (this._dragging ? "" : undefined)} class={cn( "relative flex w-px shrink-0 items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2", "data-resize-handle-active:bg-ring", "in-data-[direction=vertical]:h-px in-data-[direction=vertical]:w-full", this.class, )} onPointerDown={this._handlePointerDown} onPointerMove={this._handlePointerMove} onPointerUp={this._handlePointerUp} > {this.withHandle && ( <div class="z-10 flex h-4 w-3 items-center justify-center rounded-xs border bg-border"> <span class="sr-only">Drag to resize</span> </div> )} </div> ); }}Install the following dependencies:
npm install @praxisjs/csspnpm add @praxisjs/cssyarn add @praxisjs/cssbun add @praxisjs/cssCopy and paste the following code into your project.
import { StatefulComponent, StatelessComponent } from "@praxisjs/core";import { cx, Stylesheet, Styled, tokenVars } from "@praxisjs/css";import { Component, Prop, Ref, State, type Ref as RefType } from "@praxisjs/decorators";import type { Children } from "@praxisjs/shared";import { KosmesisTokens } from "@/lib/kosmesis-theme";const t = tokenVars(KosmesisTokens);class ResizableStyles extends Stylesheet { $group = this.css({ display: "flex", width: "100%", height: "100%" }).on('&[data-direction="vertical"]', { flexDirection: "column" }); $panel = this.css({ minHeight: "0", minWidth: "0", overflow: "auto" }); $handle = this.css({ position: "relative", display: "flex", width: "1px", flexShrink: 0, alignItems: "center", justifyContent: "center", backgroundColor: t.border, }) .after({ position: "absolute", inset: "0 auto", left: "50%", width: "0.25rem", transform: "translateX(-50%)", content: '""' }) .on("&[data-resize-handle-active]", { backgroundColor: t.ring }) .on('[data-direction="vertical"] &', { height: "1px", width: "100%" }); $handleGrip = this.css({ zIndex: 10, display: "flex", height: "1rem", width: "0.75rem", alignItems: "center", justifyContent: "center", borderRadius: "2px", border: `1px solid ${t.border}`, backgroundColor: t.border, }); /** Visually-hidden-but-accessible text — the `@praxisjs/css` equivalent of Tailwind's `sr-only` utility. */ $srOnly = this.css({ position: "absolute", width: "1px", height: "1px", padding: "0", margin: "-1px", overflow: "hidden", clip: "rect(0, 0, 0, 0)", whiteSpace: "nowrap", border: "0", });}export interface ResizablePanelGroupProps { direction?: "horizontal" | "vertical"; class?: string; id?: string; children?: Children;}/** * Purely presentational — no Morphos equivalent (Radix has no resizable primitive either). * Unlike Dialog/Tabs/Accordion, there's no separate state instance to instantiate: the whole * group is a single JSX tree, and `ResizableHandle` finds its neighboring panels by walking the * DOM (`closest()`/`previousElementSibling`/`nextElementSibling`) at drag time instead of * requiring an external instance reference — sidestepping the "two different instances" pattern * entirely, since a live DOM node (not just component state) is what the drag math needs. */@Component()export class ResizablePanelGroup extends StatelessComponent<ResizablePanelGroupProps> { @Styled(ResizableStyles) $s!: ResizableStyles; render() { const { direction = "horizontal", class: cls, id, children } = this.props; return ( <div id={id} data-slot="resizable-panel-group" data-direction={direction} class={cx(this.$s.$group, cls)}> {children} </div> ); }}export interface ResizablePanelProps { defaultSize?: number; class?: string; id?: string; children?: Children;}@Component()export class ResizablePanel extends StatelessComponent<ResizablePanelProps> { @Styled(ResizableStyles) $s!: ResizableStyles; render() { const { defaultSize = 50, class: cls, id, children } = this.props; return ( <div id={id} data-slot="resizable-panel" style={{ flexBasis: `${String(defaultSize)}%` }} class={cx(this.$s.$panel, cls)}> {children} </div> ); }}export interface ResizableHandleProps { withHandle?: boolean; class?: string;}@Component()export class ResizableHandle extends StatefulComponent { @Prop() withHandle = false; @Prop() class?: string; @Styled(ResizableStyles) $s!: ResizableStyles; @Ref<HTMLDivElement>() handleRef!: RefType<HTMLDivElement>; @State() _dragging = false; private _startPos = 0; private _direction: "horizontal" | "vertical" = "horizontal"; private readonly _handlePointerDown = (event: PointerEvent) => { const handle = this.handleRef.current; const group = handle?.closest<HTMLElement>("[data-slot=resizable-panel-group]"); if (!handle || !group) return; event.preventDefault(); this._direction = group.dataset.direction === "vertical" ? "vertical" : "horizontal"; this._startPos = this._direction === "horizontal" ? event.clientX : event.clientY; this._dragging = true; handle.setPointerCapture(event.pointerId); }; private readonly _handlePointerMove = (event: PointerEvent) => { if (!this._dragging) return; const handle = this.handleRef.current; const before = handle?.previousElementSibling as HTMLElement | null; const after = handle?.nextElementSibling as HTMLElement | null; const group = handle?.closest<HTMLElement>("[data-slot=resizable-panel-group]"); if (!handle || !before || !after || !group) return; const pos = this._direction === "horizontal" ? event.clientX : event.clientY; const delta = pos - this._startPos; this._startPos = pos; const containerSize = this._direction === "horizontal" ? group.getBoundingClientRect().width : group.getBoundingClientRect().height; const deltaPct = (delta / containerSize) * 100; const beforeSize = parseFloat(before.style.flexBasis || "50"); const afterSize = parseFloat(after.style.flexBasis || "50"); const nextBefore = Math.min(Math.max(beforeSize + deltaPct, 5), 95); const nextAfter = beforeSize + afterSize - nextBefore; before.style.flexBasis = `${String(nextBefore)}%`; after.style.flexBasis = `${String(nextAfter)}%`; }; private readonly _handlePointerUp = () => { this._dragging = false; }; render() { return ( <div ref={this.handleRef} role="separator" data-slot="resizable-handle" data-resize-handle-active={() => (this._dragging ? "" : undefined)} class={cx(this.$s.$handle, this.class)} onPointerDown={this._handlePointerDown} onPointerMove={this._handlePointerMove} onPointerUp={this._handlePointerUp} > {this.withHandle && ( <div class={this.$s.$handleGrip}> <span class={this.$s.$srOnly}>Drag to resize</span> </div> )} </div> ); }}Examples
Usage
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
<ResizablePanelGroup direction="horizontal" class="h-64 rounded-lg border">
<ResizablePanel defaultSize={50}>
<div class="flex h-full items-center justify-center p-6">One</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={50}>
<div class="flex h-full items-center justify-center p-6">Two</div>
</ResizablePanel>
</ResizablePanelGroup>ResizableHandle needs no instance reference — it finds its two neighboring panels via
previousElementSibling/nextElementSibling and its direction via the closest
[data-slot=resizable-panel-group] ancestor, entirely through the DOM at drag time. Place it
directly between the two ResizablePanels it should resize.
Props
ResizablePanelGroup
| Prop | Type | Default |
|---|---|---|
direction | "horizontal" | "vertical" | "horizontal" |
ResizablePanel
| Prop | Type | Default |
|---|---|---|
defaultSize | number | 50 — percentage of the group's main axis |
ResizableHandle
| Prop | Type | Default |
|---|---|---|
withHandle | boolean | false — renders a small grip glyph |