Components
Sortable
A pointer-driven drag-to-reorder list. Purely presentational — no Morphos equivalent.
Installation
npx kosmesis add sortablepnpm dlx kosmesis add sortableyarn dlx kosmesis add sortablebunx kosmesis add sortableCopy and paste the following code into your project.
import { StatefulComponent, StatelessComponent } from "@praxisjs/core";import { Component, FunctionProp, Prop, Ref, type Ref as RefType } from "@praxisjs/decorators";import type { Children } from "@praxisjs/shared";import { cn } from "@/lib/utils";export interface SortableProps { onReorder?: (order: string[]) => void; class?: string; id?: string; children?: Children;}// Reordering mutates the live DOM directly rather than tracking an items array in state. The// dragged item is lifted to `position: absolute` (so it can freely follow the pointer) and a// dashed "ghost" placeholder — a plain `div`, not a component instance — takes its spot in the// flex flow. The placeholder stays a child of `container` (never reparented to `document.body`)// so the pointer capture set on the dragged item keeps bubbling `pointermove`/`pointerup` up to// the listeners on `container`. Every other item FLIPs (capture-old-rect, mutate, invert,// transition to identity) into its new slot instead of snapping there instantly.const FLIP_DURATION_MS = 200;@Component()export class Sortable extends StatefulComponent { @Prop() class?: string; @Prop() id?: string; @Prop() children?: Children; @FunctionProp() onReorder?: SortableProps["onReorder"]; @Ref<HTMLDivElement>() containerRef!: RefType<HTMLDivElement>; private _dragEl: HTMLElement | undefined; private _ghostEl: HTMLElement | undefined; private _pointerOffsetX = 0; private _pointerOffsetY = 0; private readonly _handlePointerDown = (event: PointerEvent) => { const target = event.target as HTMLElement; const handle = target.closest<HTMLElement>("[data-slot=sortable-handle]"); const item = target.closest<HTMLElement>("[data-slot=sortable-item]"); const container = this.containerRef.current; if (!handle || !item || !container) return; event.preventDefault(); const containerRect = container.getBoundingClientRect(); const itemRect = item.getBoundingClientRect(); this._pointerOffsetX = event.clientX - itemRect.left; this._pointerOffsetY = event.clientY - itemRect.top; const ghost = document.createElement("div"); ghost.dataset.slot = "sortable-ghost"; ghost.style.width = `${String(itemRect.width)}px`; ghost.style.height = `${String(itemRect.height)}px`; ghost.className = "rounded-md border-2 border-dashed border-muted-foreground/40 bg-muted/30"; item.before(ghost); this._ghostEl = ghost; this._dragEl = item; item.dataset.dragging = ""; item.style.position = "absolute"; item.style.left = `${String(itemRect.left - containerRect.left)}px`; item.style.top = `${String(itemRect.top - containerRect.top)}px`; item.style.width = `${String(itemRect.width)}px`; item.style.zIndex = "20"; item.style.pointerEvents = "none"; item.setPointerCapture(event.pointerId); }; private readonly _handlePointerMove = (event: PointerEvent) => { const dragEl = this._dragEl; const ghost = this._ghostEl; const container = this.containerRef.current; if (!dragEl || !ghost || !container) return; const containerRect = container.getBoundingClientRect(); dragEl.style.left = `${String(event.clientX - containerRect.left - this._pointerOffsetX)}px`; dragEl.style.top = `${String(event.clientY - containerRect.top - this._pointerOffsetY)}px`; // Recomputed from scratch on every move (not just relative to the previous swap) so a single // fast pointer jump settles the ghost at the right slot instead of shifting it one step at a time. const siblings = Array.from(container.querySelectorAll<HTMLElement>("[data-slot=sortable-item]")).filter( (el) => el !== dragEl, ); let target: HTMLElement | null = null; for (const sibling of siblings) { const rect = sibling.getBoundingClientRect(); if (event.clientY < rect.top + rect.height / 2) { target = sibling; break; } } const firstRects = new Map(siblings.map((el) => [el, el.getBoundingClientRect()] as const)); container.insertBefore(ghost, target); for (const el of siblings) { const first = firstRects.get(el); if (!first) continue; const last = el.getBoundingClientRect(); const deltaX = first.left - last.left; const deltaY = first.top - last.top; if (deltaX === 0 && deltaY === 0) continue; el.style.transition = "none"; el.style.transform = `translate(${String(deltaX)}px, ${String(deltaY)}px)`; requestAnimationFrame(() => { el.style.transition = `transform ${String(FLIP_DURATION_MS)}ms ease`; el.style.transform = ""; }); } }; private readonly _handlePointerUp = () => { const dragEl = this._dragEl; const ghost = this._ghostEl; const container = this.containerRef.current; this._dragEl = undefined; this._ghostEl = undefined; if (!dragEl || !ghost || !container) return; ghost.replaceWith(dragEl); delete dragEl.dataset.dragging; dragEl.style.position = ""; dragEl.style.left = ""; dragEl.style.top = ""; dragEl.style.width = ""; dragEl.style.zIndex = ""; dragEl.style.pointerEvents = ""; const order = Array.from(container.querySelectorAll<HTMLElement>("[data-slot=sortable-item]")).map( (el) => el.dataset.value ?? "", ); this.onReorder?.(order); }; render() { return ( <div ref={this.containerRef} id={this.id} data-slot="sortable" class={cn("relative flex flex-col gap-2", this.class)} onPointerDown={this._handlePointerDown} onPointerMove={this._handlePointerMove} onPointerUp={this._handlePointerUp} > {this.children} </div> ); }}export interface SortableItemProps { value: string; class?: string; children?: Children;}@Component()export class SortableItem extends StatelessComponent<SortableItemProps> { render() { const { value, class: cls, children } = this.props; return ( <div data-slot="sortable-item" data-value={value} class={cn( "flex items-center gap-2 rounded-md border bg-card px-3 py-2 text-sm text-card-foreground shadow-xs", "data-dragging:opacity-90 data-dragging:shadow-lg", cls, )} > {children} </div> ); }}export interface SortableHandleProps { class?: string;}@Component()export class SortableHandle extends StatelessComponent<SortableHandleProps> { render() { const { class: cls } = this.props; return ( <span data-slot="sortable-handle" aria-hidden class={cn("cursor-grab touch-none text-muted-foreground active:cursor-grabbing", cls)} > ⠿ </span> ); }}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, FunctionProp, Prop, Ref, type Ref as RefType } from "@praxisjs/decorators";import type { Children } from "@praxisjs/shared";import { KosmesisTokens } from "@/lib/kosmesis-theme";const t = tokenVars(KosmesisTokens);class SortableStyles extends Stylesheet { $root = this.css({ position: "relative", display: "flex", flexDirection: "column", gap: "0.5rem" }); $item = this.css({ display: "flex", alignItems: "center", gap: "0.5rem", borderRadius: "0.375rem", border: `1px solid ${t.border}`, backgroundColor: t.card, color: t.cardForeground, padding: "0.5rem 0.75rem", fontSize: "0.875rem", boxShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)", }).on("&[data-dragging]", { opacity: 0.9, boxShadow: "0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)", }); // Assigned imperatively (`ghost.className = this.$s.$ghost`) — the placeholder is a plain `div` // created outside JSX so it can stay a child of `container` while the dragged item is lifted out. $ghost = this.css({ borderRadius: "0.375rem", border: `2px dashed color-mix(in oklch, ${t.mutedForeground} 40%, transparent)`, backgroundColor: `color-mix(in oklch, ${t.muted} 30%, transparent)`, }); $handle = this.css({ cursor: "grab", touchAction: "none", color: t.mutedForeground }).on("&:active", { cursor: "grabbing", });}export interface SortableProps { onReorder?: (order: string[]) => void; class?: string; id?: string; children?: Children;}// Reordering mutates the live DOM directly rather than tracking an items array in state. The// dragged item is lifted to `position: absolute` (so it can freely follow the pointer) and a// dashed "ghost" placeholder — a plain `div`, not a component instance — takes its spot in the// flex flow. The placeholder stays a child of `container` (never reparented to `document.body`)// so the pointer capture set on the dragged item keeps bubbling `pointermove`/`pointerup` up to// the listeners on `container`. Every other item FLIPs (capture-old-rect, mutate, invert,// transition to identity) into its new slot instead of snapping there instantly.const FLIP_DURATION_MS = 200;@Component()export class Sortable extends StatefulComponent { @Styled(SortableStyles) $s!: SortableStyles; @Prop() class?: string; @Prop() id?: string; @Prop() children?: Children; @FunctionProp() onReorder?: SortableProps["onReorder"]; @Ref<HTMLDivElement>() containerRef!: RefType<HTMLDivElement>; private _dragEl: HTMLElement | undefined; private _ghostEl: HTMLElement | undefined; private _pointerOffsetX = 0; private _pointerOffsetY = 0; private readonly _handlePointerDown = (event: PointerEvent) => { const target = event.target as HTMLElement; const handle = target.closest<HTMLElement>("[data-slot=sortable-handle]"); const item = target.closest<HTMLElement>("[data-slot=sortable-item]"); const container = this.containerRef.current; if (!handle || !item || !container) return; event.preventDefault(); const containerRect = container.getBoundingClientRect(); const itemRect = item.getBoundingClientRect(); this._pointerOffsetX = event.clientX - itemRect.left; this._pointerOffsetY = event.clientY - itemRect.top; const ghost = document.createElement("div"); ghost.dataset.slot = "sortable-ghost"; ghost.style.width = `${String(itemRect.width)}px`; ghost.style.height = `${String(itemRect.height)}px`; ghost.className = this.$s.$ghost; item.before(ghost); this._ghostEl = ghost; this._dragEl = item; item.dataset.dragging = ""; item.style.position = "absolute"; item.style.left = `${String(itemRect.left - containerRect.left)}px`; item.style.top = `${String(itemRect.top - containerRect.top)}px`; item.style.width = `${String(itemRect.width)}px`; item.style.zIndex = "20"; item.style.pointerEvents = "none"; item.setPointerCapture(event.pointerId); }; private readonly _handlePointerMove = (event: PointerEvent) => { const dragEl = this._dragEl; const ghost = this._ghostEl; const container = this.containerRef.current; if (!dragEl || !ghost || !container) return; const containerRect = container.getBoundingClientRect(); dragEl.style.left = `${String(event.clientX - containerRect.left - this._pointerOffsetX)}px`; dragEl.style.top = `${String(event.clientY - containerRect.top - this._pointerOffsetY)}px`; // Recomputed from scratch on every move (not just relative to the previous swap) so a single // fast pointer jump settles the ghost at the right slot instead of shifting it one step at a time. const siblings = Array.from(container.querySelectorAll<HTMLElement>("[data-slot=sortable-item]")).filter( (el) => el !== dragEl, ); let target: HTMLElement | null = null; for (const sibling of siblings) { const rect = sibling.getBoundingClientRect(); if (event.clientY < rect.top + rect.height / 2) { target = sibling; break; } } const firstRects = new Map(siblings.map((el) => [el, el.getBoundingClientRect()] as const)); container.insertBefore(ghost, target); for (const el of siblings) { const first = firstRects.get(el); if (!first) continue; const last = el.getBoundingClientRect(); const deltaX = first.left - last.left; const deltaY = first.top - last.top; if (deltaX === 0 && deltaY === 0) continue; el.style.transition = "none"; el.style.transform = `translate(${String(deltaX)}px, ${String(deltaY)}px)`; requestAnimationFrame(() => { el.style.transition = `transform ${String(FLIP_DURATION_MS)}ms ease`; el.style.transform = ""; }); } }; private readonly _handlePointerUp = () => { const dragEl = this._dragEl; const ghost = this._ghostEl; const container = this.containerRef.current; this._dragEl = undefined; this._ghostEl = undefined; if (!dragEl || !ghost || !container) return; ghost.replaceWith(dragEl); delete dragEl.dataset.dragging; dragEl.style.position = ""; dragEl.style.left = ""; dragEl.style.top = ""; dragEl.style.width = ""; dragEl.style.zIndex = ""; dragEl.style.pointerEvents = ""; const order = Array.from(container.querySelectorAll<HTMLElement>("[data-slot=sortable-item]")).map( (el) => el.dataset.value ?? "", ); this.onReorder?.(order); }; render() { return ( <div ref={this.containerRef} id={this.id} data-slot="sortable" class={cx(this.$s.$root, this.class)} onPointerDown={this._handlePointerDown} onPointerMove={this._handlePointerMove} onPointerUp={this._handlePointerUp} > {this.children} </div> ); }}export interface SortableItemProps { value: string; class?: string; children?: Children;}@Component()export class SortableItem extends StatelessComponent<SortableItemProps> { @Styled(SortableStyles) $s!: SortableStyles; render() { const { value, class: cls, children } = this.props; return ( <div data-slot="sortable-item" data-value={value} class={cx(this.$s.$item, cls)}> {children} </div> ); }}export interface SortableHandleProps { class?: string;}@Component()export class SortableHandle extends StatelessComponent<SortableHandleProps> { @Styled(SortableStyles) $s!: SortableStyles; render() { const { class: cls } = this.props; return ( <span data-slot="sortable-handle" aria-hidden class={cx(this.$s.$handle, cls)}> ⠿ </span> ); }}Examples
About
Purely presentational — no Morphos equivalent. Reordering is done the same way Resizable
handles dragging: walking and mutating the live DOM directly (insertBefore) rather than tracking
an items array in state, so each SortableItem stays fully consumer-owned (identified only by its
own value). Distinct from Drag Items, which repositions elements freely instead of reordering
a rank.
Usage
import { Sortable, SortableHandle, SortableItem } from "@/components/ui/sortable";
<Sortable onReorder={(order) => console.log(order)}>
{items.map((item) => (
<SortableItem key={item} value={item}>
<SortableHandle />
{item}
</SortableItem>
))}
</Sortable>Props
Sortable
| Prop | Type | Default |
|---|---|---|
onReorder | (order: string[]) => void | — |
class | string | — |
SortableItem
| Prop | Type | Default |
|---|---|---|
value | string | — |
class | string | — |
SortableHandle accepts class only — a drag only starts from a SortableHandle inside the item, not from anywhere on the item itself.