Push or swap
Drag a card. Nudge into a neighbor to push it aside; cover it to swap. Every commit names the strategy that produced it.
A framework-neutral engine that moves, resizes, places, and transfers items in pixel coordinates, projects a layout onto any canvas size, and flattens trees of nested layouts. Zero runtime dependencies; adapters for React, Vue, Svelte, Solid, Angular, Qwik, and Web Components included.
npm install gridlaOne core, one adapter per framework
Solve in the core with plain objects, or let an adapter own measurement, gestures, and previews while you own the state. Same names, same data attributes, in every framework.
import { moveItem, type GridLayout } from 'gridla'
const layout: GridLayout = {
canvas: {
width: 960,
height: 600,
padding: { top: 0, right: 0, bottom: 0, left: 0 },
heightMode: 'bounded',
},
items: [
{ id: 'chart', x: 0, y: 0, w: 640, h: 360 },
{ id: 'note', x: 660, y: 0, w: 300, h: 360 },
],
}
const result = moveItem({ layout, itemId: 'note', position: { x: 0, y: 0 }, options: { gap: 12 } })
result.strategy // 'push-x' — chart slid right to make room
result.layout.items[0] // { id: 'chart', x: 312, ... } — note sits at x=0; the input layout is untouchedimport { GridCanvas, GridItem, GridProvider } from 'gridla/react'
import type { GridLayout } from 'gridla'
type Props = { layout: GridLayout; onChange: (next: GridLayout) => void }
export function Dashboard({ layout, onChange }: Props) {
return (
<GridProvider layout={layout} onLayoutChange={onChange} gap={12}>
<GridCanvas style={{ height: 480 }}>
{layout.items.map((item) => (
<GridItem key={item.id} id={item.id} resizeEdges={['e', 's', 'se']}>
{item.id}
</GridItem>
))}
</GridCanvas>
</GridProvider>
)
}<script setup lang="ts">
import type { GridLayout } from 'gridla'
import { GridCanvas, GridItem, GridPreviewOutline, GridProvider } from 'gridla/vue'
const layout = defineModel<GridLayout>('layout', { required: true })
</script>
<template>
<GridProvider v-model:layout="layout" :gap="12">
<GridCanvas style="height: 480px">
<GridItem
v-for="item in layout.items"
:key="item.id"
:id="item.id"
:resize-edges="['e', 's', 'se']"
>
{{ item.id }}
</GridItem>
<GridPreviewOutline />
</GridCanvas>
</GridProvider>
</template><script lang="ts">
import type { GridLayout } from 'gridla'
import { GridCanvas, GridItem, GridPreviewOutline, GridProvider } from 'gridla/svelte'
let { layout = $bindable() }: { layout: GridLayout } = $props()
</script>
<GridProvider bind:layout gap={12}>
<GridCanvas style="height: 480px">
{#each layout.items as item (item.id)}
<GridItem id={item.id} resizeEdges={['e', 's', 'se']}>{item.id}</GridItem>
{/each}
<GridPreviewOutline />
</GridCanvas>
</GridProvider>/** @jsxImportSource solid-js */
import { For, createSignal } from 'solid-js'
import type { GridLayout } from 'gridla'
import { GridCanvas, GridItem, GridPreviewOutline, GridProvider } from 'gridla/solid'
export function Dashboard(props: { initial: GridLayout }) {
const [layout, setLayout] = createSignal(props.initial)
return (
<GridProvider layout={layout()} onLayoutChange={setLayout} gap={12}>
<GridCanvas style={{ height: '480px' }}>
<For each={layout().items}>
{(item) => (
<GridItem id={item.id} resizeEdges={['e', 's', 'se']}>
{item.id}
</GridItem>
)}
</For>
<GridPreviewOutline />
</GridCanvas>
</GridProvider>
)
}import { Component, model } from '@angular/core'
import type { GridLayout } from 'gridla'
import { GridCanvasComponent, GridItemDirective, GridProviderComponent } from 'gridla/angular'
@Component({
selector: 'app-dashboard',
imports: [GridProviderComponent, GridCanvasComponent, GridItemDirective],
template: `
<gridla-provider [(layout)]="layout" [gap]="12">
<gridla-canvas style="height: 480px">
@for (item of layout().items; track item.id) {
<div [gridlaItem]="item.id" [resizeEdges]="['e', 's', 'se']">{{ item.id }}</div>
}
</gridla-canvas>
</gridla-provider>
`,
})
export class DashboardComponent {
readonly layout = model.required<GridLayout>()
}import type { GridLayout } from 'gridla'
import {
defineGridlaElements,
type GridlaCanvasElement,
type GridlaLayoutChangeDetail,
} from 'gridla/elements'
defineGridlaElements()
document.body.innerHTML = `
<gridla-canvas id="dashboard" gap="12" resize-edges="e s se" style="height: 480px">
<gridla-item item-id="chart">Chart</gridla-item>
<gridla-item item-id="note">Note</gridla-item>
<gridla-preview></gridla-preview>
</gridla-canvas>`
declare const layout: GridLayout
const canvas = document.getElementById('dashboard') as GridlaCanvasElement
canvas.layout = layout
canvas.addEventListener('layout-change', (event) => {
const { change } = (event as CustomEvent<GridlaLayoutChangeDetail>).detail
console.log(change.reason, change.strategy)
})import type { GridLayout } from 'gridla'
import { mountGrid } from 'gridla/dom'
declare const layout: GridLayout
const element = document.getElementById('dashboard') as HTMLElement
const grid = mountGrid(element, {
defaultLayout: layout,
gap: 12,
resizeEdges: ['e', 's', 'se'],
preview: true,
renderItem: (item, node) => {
node.textContent = item.id
},
onLayoutChange: (next, detail) => console.log(detail.reason, detail.strategy, next),
})
// grid.getLayout() reads the layout in effect; grid.destroy() unmounts./** @jsxImportSource @builder.io/qwik */
import { component$, useSignal } from '@builder.io/qwik'
import type { GridLayout } from 'gridla'
import { GridCanvas, GridItem, GridPreviewOutline, GridProvider } from 'gridla/qwik'
export const Dashboard = component$((props: { initial: GridLayout }) => {
const layout = useSignal(props.initial)
return (
<GridProvider layout={layout.value} gap={12} onLayoutChange$={(next) => (layout.value = next)}>
<GridCanvas style={{ height: '480px' }}>
{layout.value.items.map((item) => (
<GridItem key={item.id} id={item.id} resizeEdges={['e', 's', 'se']}>
{item.id}
</GridItem>
))}
<GridPreviewOutline />
</GridCanvas>
</GridProvider>
)
})// Alias react and react-dom to preact/compat (see the Preact guide); the React adapter is unchanged.
import { GridCanvas, GridItem, GridPreviewOutline, GridProvider } from 'gridla/react'
import type { GridLayout } from 'gridla'
export function Dashboard({ initial }: { initial: GridLayout }) {
return (
<GridProvider defaultLayout={initial} gap={12}>
<GridCanvas style={{ height: 480 }}>
{initial.items.map((item) => (
<GridItem key={item.id} id={item.id} resizeEdges={['e', 's', 'se']}>
{item.id}
</GridItem>
))}
<GridPreviewOutline />
</GridCanvas>
</GridProvider>
)
}Try it here
Each box below is GridProvider, GridCanvas, and GridItem with a few lines of CSS. Pointer, touch, and keyboard all work.
Drag a card. Nudge into a neighbor to push it aside; cover it to swap. Every commit names the strategy that produced it.
100%Slide to narrow the canvas. Rows behave like flex chains and gaps stay exact; the navigation column is fixed-w and keeps its 140 pixels.
A group is a layout inside an item: one provider per container. Drag the cards inside the group, or drag the group by its bar and push the side column.
What it does
moveItem infers intent from overlap: push along an axis, swap, reorder a row or column, insert into a lane, trim a large neighbor, snap to an open slot, or shrink a chain. Every result names the strategy that produced it.
push-xswapreorder-rowinsert-columntrim-neighborfit-open-slotresizeItem snaps the dragged edge and shrinks only the neighbors it collides with. placeItem inserts by top-left intent or centered on a pointer. transferItem moves an item between canvases and rescales it.
projectLayout re-fits a layout to a new canvas: rows and columns behave like flex chains, fixed items keep their pixels, gaps stay exact, free space scales.
flattenLayout turns a tree of layouts into root-relative rectangles, with hit testing, container lookup, locked subtrees, and coordinate conversion.
Per-item minW/maxH, four size modes, collision ignore ghosts, and locked walls that never move as a side effect.
GridProvider, GridCanvas, and GridItem handle measurement, pointer and keyboard gestures, previews, and cross-canvas transfer, in React, Vue, Svelte, Solid, Angular, Qwik, Web Components, or plain DOM. Appearance stays yours.
Go deeper