Gridla ships no visual styles. Every adapter renders plain elements with geometry as inline style and state as data-gridla-* attributes, and leaves color, borders, shadows, and motion to your stylesheet. The same attributes come out of every adapter, so the CSS on this page works with React, Vue, Svelte, Solid, Angular, Qwik, Web Components, and the DOM adapter alike.
position: relative, box-sizing: border-box, touch-action: none; user-select: none while a gesture runs; width/height when responsive={false}, min-height for a scrollable canvas. A responsive, bounded canvas has no height of its own: give it one.
Item
position: absolute, box-sizing: border-box, width, height, and either transform: translate(x, y) (default) or left/top (positioning="absolute"); z-index: 2 while active; opacity: 0.4 while transferring to another canvas.
Preview outline
The same geometry as an item plus pointer-events: none.
Built-in resize handle
position: absolute, touch-action: none, the edge cursor, and its size and placement, all through custom properties (see Resize handles).
Everything else is yours. Three rules keep the geometry honest:
Do not change box-sizing. Widths and heights are outer sizes; borders and padding live inside them.
Clip or scroll the content. An item is exactly as large as the layout says; use overflow: hidden or overflow: auto on the item so content never spills into a neighbor.
Animate transform, not width and height. The active item follows the pointer, siblings move to where the solver puts them. A transform transition on siblings runs on the compositor; animating size forces layout on every frame. contain: layout paint on items is a cheap win for large canvases.
positioning="transform" (the default) keeps layout work off the main thread during gestures. Switch to positioning="absolute" only when a child depends on left/top, for example position: sticky content.
Every attribute below is emitted by every adapter. Boolean attributes are present with an empty value when true and absent otherwise, so select them with [attr].
the whole item (draggable default) or your own handle (dragHandleProps)
[data-gridla-drag-handle] { cursor: grab; }
data-gridla-resize-handle="<id>"
handle
built-in handles (resizeEdges) and your own (getResizeHandleProps(edge))
[data-gridla-resize-handle] { z-index: 1; }
data-gridla-edge="n|s|e|w|ne|nw|se|sw"
handle
together with data-gridla-resize-handle
[data-gridla-edge='se'] { cursor: nwse-resize; }
data-gridla-dragging
<html>
while any gesture runs on the page
html[data-gridla-dragging] { user-select: none; }
The Web Components adapter adds the tag names gridla-canvas, gridla-item, gridla-preview, and gridla-transfer-scope (or your prefix) on top of the same attributes. Custom elements are display: inline by default; the adapter sets display: block on the canvas, but give gridla-item a display if your content needs one.
Built-in handles are empty divs the adapter appends to the item when you pass resizeEdges. They sit inside the item so they stay hit-testable when the item clips its overflow: corner handles are squares in the corners, edge handles run along a side and stop short of each corner so the two never overlap.
┌──┬───────────────┬──┐│nw│ n │ne│ corners: size × size├──┼───────────────┼──┤ edges: size thick, inset from each corner│ │ │ ││w │ content │ e│ everything sits inside the item box│ │ │ │├──┼───────────────┼──┤│sw│ s │se│└──┴───────────────┴──┘
The geometry is inline, but every length reads a custom property with a fallback, so CSS on the handle, the item, the canvas, or :root changes it without !important:
Custom property
Default
Effect
--gridla-handle-size
10px
Thickness of every handle; the side length of a corner handle.
--gridla-handle-inset
--gridla-handle-size
How far an edge handle stops short of the corners.
--gridla-handle-cursor-<edge>
the *-resize cursor
Cursor of one edge, for example --gridla-handle-cursor-se.
--gridla-handle-cursor
the *-resize cursor
Cursor of every edge (an edge property wins over this one).
Resize handles
10px
Hit areas paints the built-in handles as they are laid out: eight invisible boxes inside the item, sized by --gridla-handle-size. Grips keeps the hit areas and draws a small grip with ::after on hover and selection. Corners only renders four handles through resizeEdges.
/* Thicker handles everywhere. */[data-gridla-canvas] { --gridla-handle-size: 14px;}/* Bigger targets on touch screens; 44px is the usual minimum. */@media (pointer: coarse) { [data-gridla-canvas] { --gridla-handle-size: 24px; }}/* Corner-only feel: edges keep clear of big corner grips. */[data-gridla-item] { --gridla-handle-inset: 20px;}
The properties inherit, so a value on the canvas applies to every item. Setting --gridla-handle-size on a single item (inline or via a class) changes that item only.
Handles are invisible until you paint them. A pseudo-element keeps the hit area at full size while the visible grip stays small; show it on hover, on selection, or on keyboard focus of the canvas.
The demo above uses these rules. Hover a card or click it to select it and the grips appear; the hit areas stay 10px (or whatever you set) regardless of how small the grip is drawn.
The inline cursor reads --gridla-handle-cursor-<edge> first, then --gridla-handle-cursor, then the matching resize cursor, so a plain declaration overrides it:
/* One custom cursor for every edge. */[data-gridla-canvas] { --gridla-handle-cursor: crosshair;}/* Or per edge. */[data-gridla-canvas] { --gridla-handle-cursor-se: url('grab-corner.svg') 8 8, nwse-resize; --gridla-handle-cursor-nw: url('grab-corner.svg') 8 8, nwse-resize;}
When you render handles yourself (getResizeHandleProps(edge) in React, Vue, Svelte, Solid, and Qwik; gridlaResizeHandle="se" in Angular; the attributes by hand in the DOM), position and style them however you like: outside the item, as a floating knob, as an SVG. resizeHandleStyle(edge) from gridla/interaction gives you the built-in geometry (with the same custom properties) if you want to start from it:
By default the whole item is a drag surface: the item element carries data-gridla-drag-handle. Pass draggable={false} and spread dragHandleProps (Angular: gridlaDragHandle) on the element that should start a move, typically a header, and controls inside the item keep working.
The canvas already has touch-action: none, which is what lets a touch start a drag instead of a scroll. If you build a custom canvas element, keep it. Text selection during a gesture is suppressed twice: the canvas sets user-select: none while dragging, and html[data-gridla-dragging] lets you extend that to the whole page:
GridPreviewOutline (and preview: true in the DOM adapter, <gridla-preview> in Web Components) renders a box where the active item will land when released. It has geometry and pointer-events: none and nothing else.
A short transform transition makes the outline glide between candidate slots instead of jumping. Keep it faster than your item transition so it never lags behind the pointer.
The built-in outline only renders accepted previews. To color the outline by what the solver did, or to show a rejected drop, read the preview yourself and render your own element. useGridPreview() (React, Vue, Solid), controller.preview() (Angular), gridStore((state) => state.preview) (Svelte), or useGridState().value.preview (Qwik) gives you a GridPreview: the item rect, the strategy name, shiftedSiblings, and accepted.
import { rectStyle } from 'gridla/interaction'import { useGridPreview } from 'gridla/react'export function StrategyPreview() { const preview = useGridPreview() if (!preview) return null const { x, y, w, h } = preview.item return ( <div data-gridla-preview="" data-strategy={preview.strategy} data-rejected={preview.accepted ? undefined : ''} style={{ pointerEvents: 'none', boxSizing: 'border-box', ...rectStyle({ x, y, w, h }, 'transform'), }} /> )}
The strategy names are the SolveStrategy union: push-x, push-y, push-down, push-shrink-x, push-shrink-y, swap, group-swap, reorder-row, reorder-column, insert-row, insert-column, shrink-neighbor, resize, resize-shrink-neighbors, snap, free, and a few more listed in the instrumentation API.
Preview outline by strategy
A custom outline built from useGridPreview() and rectStyle(). Its border color follows the strategy (push, swap, reorder, shrink), the name is printed with attr(data-strategy), and a drop the solver rejects, such as a push into the locked row, turns red.
Items are positioned with a transform that changes when the solver moves them, so a CSS transition on transform animates every sibling the solver pushes, swaps, or reorders. The active item must not animate: it follows the pointer and any transition would make it lag.
Animate the release only. Keep siblings snapping instantly while the pointer is down, then let everything settle when the gesture ends: html[data-gridla-dragging] [data-gridla-item] { transition: none; }. Note that this also removes the settle animation of the released item; use html[data-gridla-dragging] [data-gridla-item]:not([data-gridla-active]) if you only want the siblings to snap.
Animate size too.width and height change when the solver shrinks a neighbor or when you resize; transitioning them looks smooth on small canvases but costs layout on every frame. Prefer it only for a handful of items.
The active item already gets z-index: 2 inline; if you raise other items above 2 (a sticky header, a hovered card), raise the active item further with [data-gridla-item][data-gridla-active] { z-index: 10; }. will-change: transform on every item is rarely worth it: it forces a compositor layer per item and costs memory on large canvases. Reserve it for the active item, or skip it.
Motion
Drag one across the row. With siblings, pushed cards glide to their new slots while the active card stays under the pointer. With active too, the same transition applies to the active card and it visibly lags: keep [data-gridla-active] on transition: none.
/* Selection: an outline that works in both themes and does not rely on color alone. */[data-gridla-item][data-gridla-selected] { outline: 2px solid #3b82f6; outline-offset: -2px;}/* Siblings that moved to make room. */[data-gridla-item][data-gridla-shifted] { border-style: dashed;}/* The source of a cross-canvas transfer; the adapter already fades it to 0.4. */[data-gridla-item][data-gridla-transferring] { border-style: dotted; filter: grayscale(1);}/* Keyboard users: the canvas is focusable and arrow keys move the selection. */[data-gridla-canvas] { outline: none;}[data-gridla-canvas]:focus-visible { box-shadow: 0 0 0 2px #fff, 0 0 0 4px #3b82f6;}
Every state attribute is boolean and set on the item itself, so [data-gridla-item][data-gridla-selected] and [data-gridla-selected] select the same element; the longer form keeps specificity predictable next to your own item class.
A group is an item that hosts a canvas of its own. Give it a head strip that carries dragHandleProps and a body that fills the rest; the inner canvas measures the body and inner gestures stay inside it (onPointerDown={stop}, see nesting).
The package ships an optional starter stylesheet with the rules from this page: item box, states, selection ring, focus ring, preview outline, hover-revealed grips, larger touch targets, and reduced-motion handling. Import it and override its tokens, or copy it into your project and edit.
import 'gridla/base.css'
/* Override the tokens on the canvas or on :root. */[data-gridla-canvas] { --gridla-accent: #6d28d9; --gridla-select: #0ea5e9; --gridla-radius: 10px; --gridla-handle-size: 12px;}
The full file, for reference:
/* * gridla/base.css — an optional starter stylesheet for the headless adapters. * Everything hangs off the data attributes every adapter emits. */[data-gridla-canvas] { --gridla-line: #8884; --gridla-surface: Canvas; --gridla-accent: #e0562f; --gridla-select: #3b82f6; --gridla-radius: 6px; --gridla-duration: 180ms; --gridla-ease: cubic-bezier(0.22, 1, 0.36, 1); /* Built-in resize handles read these; unset means 10px. */ --gridla-handle-size: 10px; --gridla-handle-inset: 10px; position: relative; outline: none;}/* Keyboard users need to see which canvas has focus. */[data-gridla-canvas]:focus-visible { box-shadow: 0 0 0 2px var(--gridla-select);}/* Items: a box the content can fill. Geometry (size, transform) is inline. */[data-gridla-item] { box-sizing: border-box; overflow: hidden; border: 1px solid var(--gridla-line); border-radius: var(--gridla-radius); background: var(--gridla-surface); cursor: grab; user-select: none; -webkit-user-select: none; transition: transform var(--gridla-duration) var(--gridla-ease), width var(--gridla-duration) var(--gridla-ease), height var(--gridla-duration) var(--gridla-ease);}/* The active item follows the pointer; never animate it. */[data-gridla-item][data-gridla-active] { cursor: grabbing; transition: none; border-color: var(--gridla-accent); box-shadow: 0 4px 16px #0003;}[data-gridla-item][data-gridla-selected] { border-color: var(--gridla-select); box-shadow: 0 0 0 2px color-mix(in srgb, var(--gridla-select) 30%, transparent);}/* Siblings the solver moved out of the way during a gesture. */[data-gridla-item][data-gridla-shifted] { border-style: dashed;}/* The source of a cross-canvas transfer (the adapter also fades it). */[data-gridla-item][data-gridla-transferring] { border-style: dotted;}/* Explicit drag handles inside an item (`draggable={false}` on the item). */[data-gridla-item] [data-gridla-drag-handle] { cursor: grab; touch-action: none;}/* Where the active item will land. */[data-gridla-preview] { border: 2px dashed var(--gridla-accent); border-radius: var(--gridla-radius); background: color-mix(in srgb, var(--gridla-accent) 12%, transparent);}/* Resize handles: invisible hit areas; show a grip when the item is selected * or hovered. */[data-gridla-resize-handle] { z-index: 1;}[data-gridla-resize-handle]::after { content: ''; position: absolute; inset: 2px; border-radius: 2px; background: var(--gridla-select); opacity: 0; transition: opacity 120ms var(--gridla-ease);}[data-gridla-item]:hover [data-gridla-resize-handle]::after,[data-gridla-item][data-gridla-selected] [data-gridla-resize-handle]::after { opacity: 0.85;}/* Bigger targets on touch screens. */@media (pointer: coarse) { [data-gridla-canvas] { --gridla-handle-size: 18px; --gridla-handle-inset: 18px; }}/* No text selection anywhere while a gesture runs. */html[data-gridla-dragging] { user-select: none; -webkit-user-select: none;}@media (prefers-reduced-motion: reduce) { [data-gridla-item], [data-gridla-resize-handle]::after { transition: none; }}