Skip to content

shakcho/react-driftkit

Repository files navigation

react-driftkit

Small, focused building blocks for floating UI in React. Tree-shakable, unstyled, one component per job.

npm version npm downloads bundle size license

Live Demo · NPM · GitHub


Why react-driftkit?

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.

Components

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.

Installation

npm install react-driftkit
yarn / pnpm / bun
yarn add react-driftkit
pnpm add react-driftkit
bun add react-driftkit

Quick Start

import { 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.


MovableLauncher

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.

Features

  • 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

Examples

Snap to corners

<MovableLauncher defaultPosition="bottom-right" snapToCorners>
  <div className="my-widget">Drag me!</div>
</MovableLauncher>

Free positioning

<MovableLauncher defaultPosition={{ x: 100, y: 200 }}>
  <div className="toolbar">Toolbar</div>
</MovableLauncher>

Styled widget

<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>

Props

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.

Types

type Corner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';

interface Position {
  x: number;
  y: number;
}

CSS classes

Class When
movable-launcher Always present
movable-launcher--dragging While the user is actively dragging

SnapDock

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.

Features

  • Edge pinningleft, right, top, bottom, with a 0..1 offset 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
  • shadow prop — adds a sensible default drop shadow, overridable via style.boxShadow
  • Zero built-in visuals — you supply the background, padding, gap, etc. via style or className
  • data-edge / data-orientation attributes — flip your CSS layout without re-rendering

Examples

Basic dock

<SnapDock defaultEdge="left">
  <MyToolbar />
</SnapDock>

Styled dock with shadow

<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".

Tracking edge and offset changes

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>
  );
}

Props

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.

Types

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;
}

Data attributes

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

CSS classes

Class When
snap-dock Always present
snap-dock--dragging While the user is actively dragging

DraggableSheet

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.

Features

  • 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 single snapPoints array
  • Any edge — pin to bottom (default), top, left, or right; 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 snap for uncontrolled, pass it for parent-driven transitions
  • Data attributesdata-edge, data-snap, data-dragging for CSS-only styling

Examples

Basic bottom sheet

<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>

Mixed snap points

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%"
/>

Drag handle

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>

Controlled mode

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>
    </>
  );
}

Props

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.

Snap point resolution

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)

Types

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;
}

Data attributes

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

CSS classes

Class When
draggable-sheet Always present
draggable-sheet--dragging While the user is actively dragging

ResizableSplitPane

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.

Features

  • N panes — pass 2 or more children; each adjacent pair gets a drag handle
  • Single handle render prop — define handle once, 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 persistKey to save the split ratios to localStorage across sessions
  • Controlled & uncontrolled — omit sizes for 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

Examples

Basic two-pane split

<ResizableSplitPane defaultSizes={[0.3, 0.7]}>
  <Sidebar />
  <MainContent />
</ResizableSplitPane>

Three panes with custom handle

<ResizableSplitPane
  defaultSizes={[0.25, 0.5, 0.25]}
  handle={({ index, isDragging }) => (
    <div style={{ background: isDragging ? '#6366f1' : '#e5e7eb' }}>
      {index}
    </div>
  )}
>
  <FileTree />
  <Editor />
  <Preview />
</ResizableSplitPane>

Vertical with constraints and persistence

<ResizableSplitPane
  orientation="vertical"
  minSize={100}
  maxSize={600}
  persistKey="my-editor-split"
>
  <CodeEditor />
  <Terminal />
</ResizableSplitPane>

Controlled mode

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>
  );
}

Props

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.

Types

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;
}

Data attributes

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, ...)

CSS classes

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

Use Cases

  • 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

How it works

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.

Contributing

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 suite

License

MIT © Sakti Kumar Chourasia

About

A lightweight, draggable floating widget wrapper for React — snap to corners or drag anywhere.

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors