Small, focused building blocks for floating UI in React. Tree-shakable, unstyled, one component per job.
Building a chat widget, floating toolbar, debug panel, or side dock? You want these things to be draggable, stay on screen, and stay out of your way stylistically. Most draggable libraries are either too heavy, too opinionated, or don't handle edge cases like viewport resizing, touch input, and orientation changes.
react-driftkit ships each pattern as its own tiny component. Import only what you use — every component is tree-shakable and under a few KB gzipped. All visuals are yours; the kit owns positioning and interaction.
| Component | What it does |
|---|---|
<MovableLauncher> |
A draggable floating wrapper that pins to any viewport corner or lives at custom {x, y} — drop-anywhere with optional snap-on-release. |
<SnapDock> |
An edge-pinned dock that slides along any side of the viewport and flips orientation automatically between horizontal and vertical. |
<DraggableSheet> |
A pull-up / pull-down sheet pinned to an edge with named snap points (peek, half, full) or arbitrary pixel / percentage stops. |
<ResizableSplitPane> |
An N-pane resizable split layout with draggable handles, min/max constraints, and localStorage-persisted ratios. |
npm install react-driftkityarn / pnpm / bun
yarn add react-driftkitpnpm add react-driftkitbun add react-driftkitimport { MovableLauncher, SnapDock, ResizableSplitPane } from 'react-driftkit';
function App() {
return (
<>
<MovableLauncher defaultPosition="bottom-right">
<button>Chat with us</button>
</MovableLauncher>
<SnapDock defaultEdge="bottom" shadow>
<button>Home</button>
<button>Search</button>
<button>Settings</button>
</SnapDock>
<ResizableSplitPane defaultSizes={[0.3, 0.7]} persistKey="app-split">
<Sidebar />
<MainContent />
</ResizableSplitPane>
</>
);
}All components are tree-shakable — import only what you use.
A draggable floating wrapper that lets users pick up any widget and drop it anywhere on the viewport — or snap it to the nearest corner on release.
- Drag anywhere — pointer-based, works with mouse, touch, and pen
- Snap to corners — optional bounce-animated snap to the nearest viewport corner
- Named or custom positioning —
'top-left','bottom-right', or{ x, y } - Viewport-aware — auto-repositions on window resize and child size changes
- 5 px drag threshold — distinguishes clicks from drags so nested buttons still work
<MovableLauncher defaultPosition="bottom-right" snapToCorners>
<div className="my-widget">Drag me!</div>
</MovableLauncher><MovableLauncher defaultPosition={{ x: 100, y: 200 }}>
<div className="toolbar">Toolbar</div>
</MovableLauncher><MovableLauncher
defaultPosition="top-right"
snapToCorners
className="my-launcher"
style={{ borderRadius: 12, boxShadow: '0 4px 20px rgba(0,0,0,0.15)' }}
>
<div className="floating-panel">
<h3>Quick Actions</h3>
<button>New Task</button>
<button>Settings</button>
</div>
</MovableLauncher>| Prop | Type | Default | Description |
|---|---|---|---|
children |
ReactNode |
required | Content rendered inside the draggable container. |
defaultPosition |
Corner | { x, y } |
'bottom-right' |
Initial position — a named corner or pixel coordinates. |
snapToCorners |
boolean |
false |
Snap to the nearest viewport corner on release. |
style |
CSSProperties |
{} |
Inline styles merged with the wrapper. |
className |
string |
'' |
CSS class added to the wrapper. |
type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
interface Position {
x: number;
y: number;
}| Class | When |
|---|---|
movable-launcher |
Always present |
movable-launcher--dragging |
While the user is actively dragging |
An edge-pinned dock that slides along any side of the viewport. Drag it anywhere — on release it snaps to the nearest edge and automatically flips between horizontal (top/bottom) and vertical (left/right) layouts. The layout change animates via a FLIP-style transition anchored to the active edge.
- Edge pinning —
left,right,top,bottom, with a0..1offset along the edge - Automatic orientation — children lay out in a row or column based on the current edge
- Animated flip — cross-edge drops animate smoothly from the old footprint to the new one
- Drag anywhere — same 5 px pointer threshold as MovableLauncher
shadowprop — adds a sensible default drop shadow, overridable viastyle.boxShadow- Zero built-in visuals — you supply the background, padding, gap, etc. via
styleorclassName data-edge/data-orientationattributes — flip your CSS layout without re-rendering
<SnapDock defaultEdge="left">
<MyToolbar />
</SnapDock><SnapDock defaultEdge="bottom" shadow className="my-dock">
<button>Home</button>
<button>Search</button>
<button>Settings</button>
</SnapDock>.my-dock {
background: #111;
color: #fff;
padding: 8px;
border-radius: 12px;
gap: 6px;
}SnapDock already sets display: flex and flex-direction based on the active edge, so you don't need to write orientation CSS yourself — but if you want to, the wrapper exposes data-orientation="vertical" | "horizontal".
import { useState } from 'react';
import { SnapDock, type Edge } from 'react-driftkit';
function App() {
const [edge, setEdge] = useState<Edge>('left');
return (
<SnapDock
defaultEdge={edge}
onEdgeChange={setEdge}
onOffsetChange={(offset) => console.log('offset', offset)}
>
<Toolbar />
</SnapDock>
);
}| Prop | Type | Default | Description |
|---|---|---|---|
children |
ReactNode |
required | Content rendered inside the dock. |
defaultEdge |
'left' | 'right' | 'top' | 'bottom' |
'left' |
Which edge the dock pins to initially. |
defaultOffset |
number |
0.5 |
Position along the edge, from 0 (top/left) to 1 (bottom/right). |
snap |
boolean |
true |
Snap to the nearest edge on release. |
draggable |
boolean |
true |
Whether the user can drag the dock. |
edgePadding |
number |
16 |
Distance in pixels from the viewport edge. |
shadow |
boolean |
false |
Adds a default drop shadow. Override via style.boxShadow. |
onEdgeChange |
(edge: Edge) => void |
— | Fires when the dock moves to a new edge. |
onOffsetChange |
(offset: number) => void |
— | Fires when the dock's offset along the edge changes. |
style |
CSSProperties |
{} |
Inline styles merged with the wrapper. |
className |
string |
'' |
CSS class added to the wrapper. |
type Edge = 'left' | 'right' | 'top' | 'bottom';
type Orientation = 'vertical' | 'horizontal';
interface SnapDockProps {
children: ReactNode;
defaultEdge?: Edge;
defaultOffset?: number;
draggable?: boolean;
snap?: boolean;
edgePadding?: number;
shadow?: boolean;
onEdgeChange?: (edge: Edge) => void;
onOffsetChange?: (offset: number) => void;
style?: CSSProperties;
className?: string;
}The wrapper element exposes these attributes so you can drive CSS without re-rendering:
| Attribute | Values |
|---|---|
data-edge |
left, right, top, bottom |
data-orientation |
vertical, horizontal |
data-dragging |
present while the user is actively dragging |
| Class | When |
|---|---|
snap-dock |
Always present |
snap-dock--dragging |
While the user is actively dragging |
A pull-up / pull-down sheet pinned to an edge of the viewport, with snap points like peek, half, and full. Built for mobile-style detail drawers, filter panels, cart drawers, and inspector flyouts — but works at any edge on any screen size.
- Named snap presets —
'closed','peek','half','full'resolve to sensible defaults against the viewport axis - Arbitrary snap points — mix presets with raw pixel
numbers and percentage strings like'40%'in a singlesnapPointsarray - Any edge — pin to
bottom(default),top,left, orright; percentage snaps resolve against the drag axis automatically - Velocity-aware release — a fast flick advances one stop in the flick direction, slow drags snap to the nearest stop
- Drag handle selector — restrict drag to a nested handle so inner content stays scrollable and clickable
- Controlled & uncontrolled — omit
snapfor uncontrolled, pass it for parent-driven transitions - Data attributes —
data-edge,data-snap,data-draggingfor CSS-only styling
<DraggableSheet snapPoints={['peek', 'half', 'full']} defaultSnap="half">
<div className="my-sheet">
<div data-handle className="sheet-handle" />
<div className="sheet-body">Details, filters, cart...</div>
</div>
</DraggableSheet>Presets, pixels, and percentages in the same list. Order doesn't matter — internally the values are resolved against the viewport and sorted at gesture time.
<DraggableSheet
snapPoints={['peek', 200, '40%', 'full']}
defaultSnap="40%"
/>Restrict drag to a handle strip so the rest of the sheet remains free for scrolling and clicks.
<DraggableSheet
snapPoints={['peek', 'half', 'full']}
dragHandleSelector="[data-handle]"
>
<div data-handle className="handle-strip" />
<div className="scroll-area">{/* long content scrolls normally */}</div>
</DraggableSheet>import { useState } from 'react';
import { DraggableSheet, type SnapPoint } from 'react-driftkit';
function App() {
const [snap, setSnap] = useState<SnapPoint>('half');
return (
<>
<button onClick={() => setSnap('full')}>Expand</button>
<DraggableSheet
snap={snap}
snapPoints={['peek', 'half', 'full']}
onSnapChange={(next) => setSnap(next)}
>
<Sheet />
</DraggableSheet>
</>
);
}| Prop | Type | Default | Description |
|---|---|---|---|
children |
ReactNode |
required | Content rendered inside the sheet. |
edge |
'bottom' | 'top' | 'left' | 'right' |
'bottom' |
Edge the sheet is pinned to. |
snapPoints |
SnapPoint[] |
['peek', 'half', 'full'] |
Ordered list of stops. Mix presets, pixel numbers, and 'n%' strings. |
defaultSnap |
SnapPoint |
middle of snapPoints |
Uncontrolled initial stop. |
snap |
SnapPoint |
— | Controlled current stop. When set, parent drives transitions. |
onSnapChange |
(snap: SnapPoint, sizePx: number) => void |
— | Fires on drag release with the resolved stop and its pixel size. |
draggable |
boolean |
true |
Whether the user can drag the sheet. |
dragHandleSelector |
string |
— | CSS selector for a nested handle. When set, drag only begins inside matching elements. |
velocityThreshold |
number |
0.5 |
Flick velocity (px/ms) above which a release advances one stop in the flick direction. |
closeOnOutsideClick |
boolean |
false |
When true, a pointerdown outside the sheet collapses it to 0 and fires onSnapChange('closed', 0). Ignored while already closed or mid-drag. |
style |
CSSProperties |
{} |
Inline styles merged with the wrapper. |
className |
string |
'' |
CSS class added to the wrapper. |
| Preset | Resolves to |
|---|---|
'closed' |
0 |
'peek' |
96 px (capped at the viewport axis) |
'half' |
50% of the viewport along the drag axis |
'full' |
92% of the viewport along the drag axis |
number |
Raw pixels along the drag axis |
`${n}%` |
n% of the viewport along the drag axis (height for top/bottom, width for left/right) |
type SheetEdge = 'bottom' | 'top' | 'left' | 'right';
type SnapPoint =
| 'closed'
| 'peek'
| 'half'
| 'full'
| number
| `${number}%`;
interface DraggableSheetProps {
children: ReactNode;
edge?: SheetEdge;
snapPoints?: SnapPoint[];
defaultSnap?: SnapPoint;
snap?: SnapPoint;
onSnapChange?: (snap: SnapPoint, sizePx: number) => void;
draggable?: boolean;
dragHandleSelector?: string;
velocityThreshold?: number;
closeOnOutsideClick?: boolean;
style?: CSSProperties;
className?: string;
}| Attribute | Values |
|---|---|
data-edge |
bottom, top, left, right |
data-snap |
the stringified current SnapPoint (e.g. half, 40%, 200) |
data-dragging |
present while the user is actively dragging |
| Class | When |
|---|---|
draggable-sheet |
Always present |
draggable-sheet--dragging |
While the user is actively dragging |
An N-pane resizable split layout. Drag the handles between panes to redistribute space. Supports horizontal and vertical orientations, min/max size constraints, localStorage persistence, and a render prop for fully custom handles.
- N panes — pass 2 or more children; each adjacent pair gets a drag handle
- Single handle render prop — define
handleonce, it's called per boundary with{ index, isDragging, orientation } - Min / max constraints — clamp each pane's pixel size; conflicts are resolved gracefully
- Persisted layout — pass
persistKeyto save the split ratios to localStorage across sessions - Controlled & uncontrolled — omit
sizesfor uncontrolled, pass it for parent-driven layouts - Double-click reset — double-click any handle to reset to
defaultSizes(or equal split) - Pointer-based — works with mouse, touch, and pen; 3 px drag threshold
<ResizableSplitPane defaultSizes={[0.3, 0.7]}>
<Sidebar />
<MainContent />
</ResizableSplitPane><ResizableSplitPane
defaultSizes={[0.25, 0.5, 0.25]}
handle={({ index, isDragging }) => (
<div style={{ background: isDragging ? '#6366f1' : '#e5e7eb' }}>
{index}
</div>
)}
>
<FileTree />
<Editor />
<Preview />
</ResizableSplitPane><ResizableSplitPane
orientation="vertical"
minSize={100}
maxSize={600}
persistKey="my-editor-split"
>
<CodeEditor />
<Terminal />
</ResizableSplitPane>import { useState } from 'react';
import { ResizableSplitPane } from 'react-driftkit';
function App() {
const [sizes, setSizes] = useState([0.5, 0.5]);
return (
<ResizableSplitPane
sizes={sizes}
onSizesChange={setSizes}
>
<PanelA />
<PanelB />
</ResizableSplitPane>
);
}| Prop | Type | Default | Description |
|---|---|---|---|
children |
ReactNode[] |
required | Two or more child elements to render in the split panes. |
orientation |
'horizontal' | 'vertical' |
'horizontal' |
Split direction. Horizontal puts panes side-by-side; vertical stacks them. |
defaultSizes |
number[] |
equal split | Uncontrolled initial sizes as ratios summing to 1. |
sizes |
number[] |
— | Controlled sizes. When provided, the splitter is fully controlled by the parent. |
onSizesChange |
(sizes: number[]) => void |
— | Fires after a drag release with the committed sizes array. |
onDrag |
(sizes: number[]) => void |
— | Fires continuously while dragging with the live sizes array. |
minSize |
number |
50 |
Minimum size in pixels for any pane. |
maxSize |
number |
— | Maximum size in pixels for any pane. No limit when omitted. |
handleSize |
number |
8 |
Thickness of each drag handle in pixels. |
handle |
(info: HandleInfo) => ReactNode |
— | Render prop for each drag handle. Called once per boundary. |
persistKey |
string |
— | localStorage key to persist the sizes across sessions. |
draggable |
boolean |
true |
Whether the user can drag the handles. |
doubleClickReset |
boolean |
true |
Double-click a handle to reset to defaultSizes (or equal split). |
style |
CSSProperties |
{} |
Inline styles merged with the wrapper. |
className |
string |
'' |
CSS class added to the wrapper. |
type SplitOrientation = 'horizontal' | 'vertical';
interface HandleInfo {
/** Boundary index (0 = between pane 0 and pane 1). */
index: number;
/** Whether this specific handle is being dragged. */
isDragging: boolean;
/** Current orientation of the splitter. */
orientation: SplitOrientation;
}
interface ResizableSplitPaneProps {
children: ReactNode[];
orientation?: SplitOrientation;
defaultSizes?: number[];
sizes?: number[];
onSizesChange?: (sizes: number[]) => void;
onDrag?: (sizes: number[]) => void;
minSize?: number;
maxSize?: number;
handleSize?: number;
handle?: (info: HandleInfo) => ReactNode;
persistKey?: string;
draggable?: boolean;
doubleClickReset?: boolean;
style?: CSSProperties;
className?: string;
}| Attribute | Values |
|---|---|
data-orientation |
horizontal, vertical |
data-dragging |
present while any handle is being dragged |
data-pane |
numeric pane index (0, 1, 2, ...) |
data-handle |
numeric handle index (0, 1, ...) |
| Class | When |
|---|---|
resizable-split-pane |
Always present |
resizable-split-pane--dragging |
While any handle is being dragged |
resizable-split-pane__pane |
On each pane wrapper |
resizable-split-pane__handle |
On each handle wrapper |
resizable-split-pane__handle--dragging |
On the specific handle being dragged |
- Chat widgets — floating support buttons that stay accessible
- Floating toolbars — draggable formatting bars or quick-action panels
- Side docks — VS Code / Figma-style side rails that snap to any edge
- Mobile detail sheets — pull-up drawers for details, filters, or carts
- Inspector panels — developer tool drawers that expand between peek and full
- Code editors — resizable file tree + editor + preview split layouts
- Admin dashboards — adjustable sidebar and content regions
- Debug panels — dev tool overlays that can be moved out of the way
- Media controls — picture-in-picture style video or audio controls
- Notification centers — persistent notification panels users can reposition
- Accessibility helpers — movable assistive overlays
Under the hood all components use the Pointer Events API for universal input handling. MovableLauncher, SnapDock, and DraggableSheet render as position: fixed elements at the top of the z-index stack (2147483647) and use a ResizeObserver to stay pinned when their content changes size.
SnapDock's orientation flip uses a FLIP-style animation: it captures the old wrapper rect before the orientation changes, applies an inverse scale() anchored to the active edge, and animates back to identity in the next frame — so the dock glides between horizontal and vertical layouts instead of snapping.
ResizableSplitPane uses a flexbox layout with calc() sizing. Dragging a handle only redistributes space between the two adjacent panes, leaving all others unchanged. Window resize events trigger re-clamping against min/max constraints.
Contributions are welcome. Open an issue or send a pull request.
git clone https://github.com/shakcho/react-drift.git
cd react-drift
npm install
npm run dev # Start the demo app
npm test # Run the test suiteMIT © Sakti Kumar Chourasia