
Implementing Drag Drop
- 283 installs
- 426 repo stars
- Updated December 11, 2025
- ancoleman/ai-design-components
implementing-drag-drop is a Claude Code skill that implements accessible drag-and-drop and sortable React/TypeScript interfaces such as kanban boards, sortable lists, and file dropzones using dnd-kit.
About
This skill implements drag-and-drop and sortable interfaces in React and TypeScript using the dnd-kit library. Developers use it when building kanban boards, sortable lists, file upload dropzones, or reorderable grids. It emphasizes keyboard accessibility, touch support, and performance optimization such as virtual scrolling for lists over 100 items.
- React/TypeScript drag-and-drop built on dnd-kit with accessibility and touch support
- Covers kanban boards, sortable lists, file dropzones, and reorderable grids
- Includes keyboard navigation, screen-reader announcements, and performance patterns for large lists
Implementing Drag Drop by the numbers
- 283 all-time installs (skills.sh)
- Ranked #763 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
implementing-drag-drop capabilities & compatibility
- Capabilities
- kanban board · sortable list · file dropzone · grid reorder
- Use cases
- frontend · ui design
- Pricing
- Free
What implementing-drag-drop says it does
Implements drag-and-drop and sortable interfaces with React/TypeScript including kanban boards, sortable lists, file uploads, and reorderable grids.
Zero dependencies (~10KB core)
npx skills add https://github.com/ancoleman/ai-design-components --skill implementing-drag-dropAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 283 |
|---|---|
| repo stars | ★ 426 |
| Last updated | December 11, 2025 |
| Repository | ancoleman/ai-design-components ↗ |
What it does
Building accessible drag-and-drop UIs like kanban boards, sortable lists, and file dropzones in React.
Who is it for?
React/TypeScript projects needing accessible kanban, sortable, or file-drop UIs.
Skip if: Non-React stacks or simple static layouts with no reordering.
When should I use this skill?
You are building interactive UIs requiring direct manipulation, spatial organization, or touch-friendly reordering.
What you get
Accessible, touch-capable drag-and-drop components with keyboard navigation and screen-reader support.
- Sortable list component
- Kanban board component
- File dropzone component
By the numbers
- 6-step implementation workflow
- 44px minimum touch hit areas
- 300ms not applicable; virtual scrolling recommended above 100 items
Files
Drag-and-Drop & Sortable Interfaces
Purpose
This skill helps implement drag-and-drop interactions and sortable interfaces using modern React/TypeScript libraries. It covers accessibility-first approaches, touch support, and performance optimization for creating intuitive direct manipulation UIs.
When to Use
Invoke this skill when:
- Building Trello-style kanban boards with draggable cards between columns
- Creating sortable lists with drag handles for priority ordering
- Implementing file upload zones with visual drag-and-drop feedback
- Building reorderable grids for dashboard widgets or galleries
- Creating visual builders with node-based interfaces
- Implementing any UI requiring spatial reorganization through direct manipulation
Core Patterns
Sortable Lists
Reference references/dnd-patterns.md for:
- Vertical lists with drag handles
- Horizontal lists for tab/carousel reordering
- Grid layouts with 2D dragging
- Auto-scrolling near edges
Kanban Boards
Reference references/kanban-implementation.md for:
- Multi-column boards with cards
- WIP limits and swimlanes
- Card preview on hover
- Column management (add/remove/collapse)
File Upload Zones
Reference references/file-dropzone.md for:
- Visual feedback states
- File type validation
- Multi-file handling
- Progress indicators
Accessibility
Reference references/accessibility-dnd.md for:
- Keyboard navigation patterns
- Screen reader announcements
- Alternative UI approaches
- ARIA attributes
Library Selection
Primary: dnd-kit
Modern, accessible, and performant drag-and-drop for React.
Reference references/library-guide.md for:
- Library comparison (dnd-kit vs alternatives)
- Installation and setup
- Core concepts and API
- Migration from react-beautiful-dnd
Key Features
- Built-in accessibility support
- Touch, mouse, and keyboard input
- Zero dependencies (~10KB core)
- Highly customizable
- TypeScript native
Implementation Workflow
Step 1: Analyze Requirements
Determine the drag-and-drop pattern needed:
- Simple list reordering → Sortable list pattern
- Multi-container movement → Kanban pattern
- File handling → Dropzone pattern
- Complex interactions → Visual builder pattern
Step 2: Set Up Library
Install required packages:
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilitiesStep 3: Implement Core Functionality
Use examples as starting points:
examples/sortable-list.tsxfor basic listsexamples/kanban-board.tsxfor multi-column boardsexamples/file-dropzone.tsxfor file uploadsexamples/grid-reorder.tsxfor grid layouts
Step 4: Add Accessibility
Reference references/accessibility-dnd.md to:
- Implement keyboard navigation
- Add screen reader announcements
- Provide alternative controls
- Test with assistive technologies
Run scripts/validate_accessibility.js to check implementation.
Step 5: Optimize Performance
For lists with >100 items:
- Reference
references/performance-optimization.md - Implement virtual scrolling
- Use
scripts/calculate_drop_position.jsfor efficient calculations
Step 6: Style with Design Tokens
Apply theming using the design-tokens skill:
- Reference design token variables
- Implement drag states (hovering, dragging, dropping)
- Add visual feedback and animations
Mobile & Touch Support
Reference references/touch-support.md for:
- Long press to initiate drag
- Preventing scroll during drag
- Touch-friendly hit areas (44px minimum)
- Gesture conflict resolution
State Management
Reference references/state-management.md for:
- Managing drag state in React
- Optimistic updates
- Undo/redo functionality
- Persisting order changes
Scripts
Calculate Drop Position
Run scripts/calculate_drop_position.js to:
- Determine valid drop zones
- Calculate insertion indices
- Handle edge cases
Generate Configuration
Run scripts/generate_dnd_config.js to:
- Create dnd-kit configuration
- Set up sensors and modifiers
- Configure animations
Validate Accessibility
Run scripts/validate_accessibility.js to:
- Check keyboard navigation
- Verify ARIA attributes
- Test screen reader compatibility
Examples
Each example includes complete TypeScript code with accessibility:
Sortable List
examples/sortable-list.tsx
- Vertical list with drag handles
- Keyboard navigation (Space/Enter to grab, arrows to move)
- Screen reader announcements
Kanban Board
examples/kanban-board.tsx
- Multiple columns with draggable cards
- Card movement between columns
- Column management features
- WIP limits
File Dropzone
examples/file-dropzone.tsx
- Drag files to upload
- Visual feedback states
- File type validation
- Upload progress
Grid Reorder
examples/grid-reorder.tsx
- 2D grid dragging
- Auto-layout on drop
- Responsive breakpoints
Assets
TypeScript Types
assets/drag-state-types.ts provides:
- Type definitions for drag state
- Event handler types
- Configuration interfaces
Configuration Schema
assets/dnd-config-schema.json defines:
- Valid configuration options
- Sensor settings
- Animation parameters
Best Practices
Visual Feedback
- Show drag handles (⋮⋮) to indicate draggability
- Change cursor (grab → grabbing)
- Display drop zone placeholders
- Make dragged items semi-transparent
- Highlight valid drop targets
Performance
- Use CSS transforms, not position properties
- Apply
will-change: transformfor animations - Throttle drag events for large lists
- Implement virtual scrolling when needed
Accessibility First
- Always provide keyboard alternatives
- Include screen reader announcements
- Test with NVDA/JAWS/VoiceOver
- Provide non-drag alternatives (buttons/forms)
Error Handling
- Show invalid drop feedback
- Implement undo functionality
- Auto-save after successful drops
- Handle network failures gracefully
Common Pitfalls
Avoid These Issues
- Forgetting keyboard navigation
- Missing touch support
- Not preventing scroll during drag
- Ignoring accessibility
- Poor performance with large lists
Solutions
Reference the appropriate guide for each issue:
- Accessibility →
references/accessibility-dnd.md - Touch →
references/touch-support.md - Performance →
references/performance-optimization.md - State →
references/state-management.md
Testing Checklist
Before deployment, verify:
- [ ] Keyboard navigation works completely
- [ ] Screen readers announce all actions
- [ ] Touch devices can drag smoothly
- [ ] Performance acceptable with expected data volume
- [ ] Visual feedback clear and responsive
- [ ] Undo/redo functionality works
- [ ] Alternative UI provided for accessibility
- [ ] Works across all target browsers
Next Steps
After implementing basic drag-and-drop: 1. Add advanced features (auto-scroll, multi-select) 2. Implement gesture support for mobile 3. Add animation polish with Framer Motion 4. Create custom drag preview components 5. Build complex interactions (nested dragging)
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "DnD-Kit Configuration Schema",
"description": "Configuration schema for dnd-kit drag-and-drop library",
"type": "object",
"properties": {
"sensors": {
"type": "array",
"description": "Input sensors for detecting drag operations",
"items": {
"type": "object",
"properties": {
"sensor": {
"type": "string",
"enum": ["PointerSensor", "KeyboardSensor", "TouchSensor"],
"description": "Type of sensor to use"
},
"options": {
"type": "object",
"properties": {
"activationConstraint": {
"type": "object",
"properties": {
"distance": {
"type": "number",
"description": "Minimum distance in pixels to activate drag"
},
"delay": {
"type": "number",
"description": "Delay in milliseconds before drag activation"
},
"tolerance": {
"type": "number",
"description": "Movement tolerance during delay period"
}
}
},
"coordinateGetter": {
"type": "string",
"description": "Function name for getting keyboard coordinates"
},
"scrollBehavior": {
"type": "string",
"enum": ["auto", "smooth"],
"description": "Scrolling behavior during keyboard navigation"
}
}
}
},
"required": ["sensor"]
}
},
"modifiers": {
"type": "array",
"description": "Modifiers to constrain or modify drag behavior",
"items": {
"type": "object",
"properties": {
"modifier": {
"type": "string",
"enum": [
"restrictToVerticalAxis",
"restrictToHorizontalAxis",
"restrictToWindowEdges",
"restrictToParentElement",
"restrictToFirstScrollableAncestor",
"snapCenterToCursor"
],
"description": "Type of modifier to apply"
},
"options": {
"type": "object",
"properties": {
"gridSize": {
"type": "number",
"description": "Grid size for snapping (in pixels)"
}
}
}
},
"required": ["modifier"]
}
},
"animation": {
"type": "object",
"description": "Animation configuration",
"properties": {
"duration": {
"type": "number",
"description": "Animation duration in milliseconds",
"minimum": 0
},
"easing": {
"type": "string",
"description": "CSS easing function",
"examples": [
"ease",
"ease-in",
"ease-out",
"ease-in-out",
"cubic-bezier(0.4, 0, 0.2, 1)"
]
},
"dragOverlay": {
"type": ["object", "null"],
"description": "Drag overlay animation settings",
"properties": {
"opacity": {
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "Opacity of dragged element"
},
"scale": {
"type": "number",
"minimum": 0,
"description": "Scale factor for dragged element"
}
}
},
"dropAnimation": {
"type": ["object", "null"],
"description": "Drop animation settings",
"properties": {
"duration": {
"type": "number",
"minimum": 0,
"description": "Drop animation duration"
},
"easing": {
"type": "string",
"description": "Drop animation easing"
},
"keyframes": {
"type": "array",
"description": "Animation keyframes",
"items": {
"type": "object"
}
}
}
},
"layoutAnimation": {
"type": ["object", "null"],
"description": "Layout shift animation settings",
"properties": {
"duration": {
"type": "number",
"minimum": 0
},
"easing": {
"type": "string"
}
}
}
}
},
"collision": {
"type": "object",
"description": "Collision detection strategy",
"properties": {
"name": {
"type": "string",
"enum": [
"closestCenter",
"closestCorners",
"rectIntersection",
"pointerWithin"
],
"description": "Collision detection algorithm"
},
"description": {
"type": "string",
"description": "Description of the collision strategy"
}
},
"required": ["name"]
},
"sorting": {
"type": "object",
"description": "Sorting strategy for sortable items",
"properties": {
"name": {
"type": "string",
"enum": [
"verticalListSortingStrategy",
"horizontalListSortingStrategy",
"rectSortingStrategy"
],
"description": "Sorting strategy name"
},
"description": {
"type": "string",
"description": "Description of the sorting strategy"
},
"direction": {
"type": "string",
"enum": ["vertical", "horizontal", "both"],
"description": "Direction of sorting"
}
},
"required": ["name", "direction"]
},
"accessibility": {
"type": "object",
"description": "Accessibility configuration",
"properties": {
"announcements": {
"type": ["object", "null"],
"description": "Screen reader announcements",
"properties": {
"onDragStart": {
"type": "string",
"description": "Template for drag start announcement"
},
"onDragOver": {
"type": "string",
"description": "Template for drag over announcement"
},
"onDragEnd": {
"type": "string",
"description": "Template for drag end announcement"
},
"onDragCancel": {
"type": "string",
"description": "Template for drag cancel announcement"
}
}
},
"liveRegion": {
"type": "boolean",
"description": "Enable live region for announcements",
"default": true
},
"screenReaderInstructions": {
"type": "boolean",
"description": "Include screen reader instructions",
"default": true
},
"ariaDescribedBy": {
"type": "string",
"description": "ID of element containing instructions"
},
"ariaAttributes": {
"type": "object",
"description": "Additional ARIA attributes",
"additionalProperties": {
"type": "string"
}
}
}
},
"autoScroll": {
"type": ["object", "null"],
"description": "Auto-scroll configuration",
"properties": {
"enabled": {
"type": "boolean",
"description": "Enable auto-scrolling",
"default": true
},
"threshold": {
"type": "object",
"description": "Distance from edge to trigger scrolling (0-1)",
"properties": {
"x": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"y": {
"type": "number",
"minimum": 0,
"maximum": 1
}
}
},
"maxSpeed": {
"type": "number",
"description": "Maximum scroll speed in pixels per frame",
"minimum": 0
},
"acceleration": {
"type": "number",
"description": "Scroll acceleration factor",
"minimum": 0
},
"interval": {
"type": "number",
"description": "Scroll interval in milliseconds",
"minimum": 0
},
"canScroll": {
"type": "boolean",
"description": "Whether scrolling is allowed"
}
}
}
},
"required": ["sensors", "collision", "sorting"],
"examples": [
{
"sensors": [
{
"sensor": "PointerSensor",
"options": {
"activationConstraint": {
"distance": 10
}
}
},
{
"sensor": "KeyboardSensor",
"options": {
"coordinateGetter": "sortableKeyboardCoordinates"
}
}
],
"modifiers": [
{
"modifier": "restrictToWindowEdges"
}
],
"animation": {
"duration": 200,
"easing": "cubic-bezier(0.4, 0, 0.2, 1)",
"dragOverlay": {
"opacity": 0.5,
"scale": 1.05
}
},
"collision": {
"name": "closestCenter",
"description": "Drop on the element whose center is closest to the pointer"
},
"sorting": {
"name": "verticalListSortingStrategy",
"description": "For vertical lists",
"direction": "vertical"
},
"accessibility": {
"announcements": {
"onDragStart": "Picked up draggable item ${id}",
"onDragEnd": "Dropped draggable item ${id}"
},
"liveRegion": true,
"screenReaderInstructions": true,
"ariaDescribedBy": "dnd-instructions"
},
"autoScroll": {
"enabled": true,
"threshold": {
"x": 0.2,
"y": 0.2
},
"maxSpeed": 10,
"acceleration": 10,
"interval": 10
}
}
]
}/**
* TypeScript Types for Drag-and-Drop State Management
*/
import type { Active, Over, DragStartEvent, DragEndEvent, DragOverEvent, DragCancelEvent } from '@dnd-kit/core';
// Basic drag state
export interface DragState {
isDragging: boolean;
draggedItem: DraggableItem | null;
draggedFrom: string | null;
draggedOver: string | null;
initialPosition: Position | null;
currentPosition: Position | null;
}
// Position coordinates
export interface Position {
x: number;
y: number;
}
// Draggable item interface
export interface DraggableItem {
id: string;
content: React.ReactNode;
order?: number;
parentId?: string;
type?: string;
data?: Record<string, any>;
}
// Container interface for multi-container drag-drop
export interface DroppableContainer {
id: string;
title: string;
items: DraggableItem[];
acceptTypes?: string[];
maxItems?: number;
disabled?: boolean;
}
// Event handlers
export interface DragEventHandlers {
onDragStart?: (event: DragStartEvent) => void;
onDragMove?: (event: DragOverEvent) => void;
onDragOver?: (event: DragOverEvent) => void;
onDragEnd?: (event: DragEndEvent) => void;
onDragCancel?: (event: DragCancelEvent) => void;
}
// Sensor configuration
export interface SensorConfig {
pointer?: {
enabled: boolean;
activationConstraint?: {
distance?: number;
delay?: number;
tolerance?: number;
};
};
keyboard?: {
enabled: boolean;
coordinateGetter?: string;
scrollBehavior?: 'auto' | 'smooth';
};
touch?: {
enabled: boolean;
activationConstraint?: {
delay?: number;
tolerance?: number;
};
};
}
// Animation configuration
export interface AnimationConfig {
duration?: number;
easing?: string;
dragOverlay?: {
opacity?: number;
scale?: number;
};
dropAnimation?: {
duration?: number;
easing?: string;
keyframes?: Keyframe[];
};
}
// Collision detection strategies
export type CollisionDetectionStrategy =
| 'closestCenter'
| 'closestCorners'
| 'rectIntersection'
| 'pointerWithin'
| ((args: any) => any);
// Sorting strategies
export type SortingStrategy =
| 'vertical'
| 'horizontal'
| 'grid'
| 'rect';
// Accessibility configuration
export interface AccessibilityConfig {
announcements?: {
onDragStart?: (id: string) => string;
onDragOver?: (activeId: string, overId?: string) => string;
onDragEnd?: (activeId: string, overId?: string) => string;
onDragCancel?: (id: string) => string;
};
screenReaderInstructions?: string;
ariaDescribedBy?: string;
liveRegion?: boolean;
}
// Auto-scroll configuration
export interface AutoScrollConfig {
enabled?: boolean;
threshold?: {
x?: number;
y?: number;
};
maxSpeed?: number;
acceleration?: number;
interval?: number;
canScroll?: (element: Element) => boolean;
}
// Complete dnd-kit configuration
export interface DndKitConfig {
sensors?: SensorConfig;
collisionDetection?: CollisionDetectionStrategy;
sortingStrategy?: SortingStrategy;
animation?: AnimationConfig;
accessibility?: AccessibilityConfig;
autoScroll?: AutoScrollConfig;
modifiers?: Array<(args: any) => any>;
}
// Kanban-specific types
export namespace Kanban {
export interface Task {
id: string;
title: string;
description?: string;
columnId: string;
order: number;
priority?: 'low' | 'medium' | 'high' | 'urgent';
assignee?: string;
tags?: string[];
dueDate?: Date;
createdAt: Date;
updatedAt: Date;
}
export interface Column {
id: string;
title: string;
order: number;
wipLimit?: number;
collapsed?: boolean;
color?: string;
}
export interface Board {
id: string;
name: string;
columns: Column[];
tasks: Task[];
}
export interface Swimlane {
id: string;
title: string;
order: number;
collapsed?: boolean;
}
}
// File upload types
export namespace FileUpload {
export interface FileWithMeta extends File {
id: string;
preview?: string;
progress: number;
status: 'pending' | 'uploading' | 'success' | 'error';
error?: string;
}
export interface DropzoneConfig {
accept?: Record<string, string[]>;
maxSize?: number;
minSize?: number;
maxFiles?: number;
multiple?: boolean;
disabled?: boolean;
validator?: (file: File) => boolean | Promise<boolean>;
}
export interface UploadProgress {
loaded: number;
total: number;
percentage: number;
}
}
// Grid layout types
export namespace Grid {
export interface GridItem {
id: string;
x: number;
y: number;
width: number;
height: number;
minWidth?: number;
maxWidth?: number;
minHeight?: number;
maxHeight?: number;
isDraggable?: boolean;
isResizable?: boolean;
static?: boolean;
}
export interface GridLayout {
columns: number;
rowHeight: number;
width: number;
margin?: [number, number];
containerPadding?: [number, number];
isDragging?: boolean;
isResizing?: boolean;
}
}
// History/Undo types
export interface HistoryState<T> {
past: T[];
present: T;
future: T[];
}
export interface UndoableAction<T> {
type: string;
payload: T;
timestamp: number;
undo: () => void;
redo: () => void;
}
// Multi-user sync types
export namespace Sync {
export interface UserCursor {
userId: string;
userName: string;
color: string;
position: Position;
draggedItem?: DraggableItem;
}
export interface SyncMessage {
type: 'DRAG_START' | 'DRAG_MOVE' | 'DRAG_END' | 'STATE_UPDATE';
userId: string;
timestamp: number;
data: any;
}
export interface CollaborationState {
users: Map<string, UserCursor>;
localUserId: string;
isConnected: boolean;
}
}
// Utility types
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
export type RequireAtLeastOne<T, Keys extends keyof T = keyof T> =
Pick<T, Exclude<keyof T, Keys>> &
{
[K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>>;
}[Keys];
// Hook return types
export interface UseDragDropReturn {
isDragging: boolean;
activeId: string | null;
overId: string | null;
handleDragStart: (event: DragStartEvent) => void;
handleDragOver: (event: DragOverEvent) => void;
handleDragEnd: (event: DragEndEvent) => void;
handleDragCancel: (event: DragCancelEvent) => void;
}
export interface UseDroppableReturn {
isOver: boolean;
setNodeRef: (element: HTMLElement | null) => void;
active: Active | null;
over: Over | null;
}
export interface UseDraggableReturn {
attributes: Record<string, any>;
listeners: Record<string, any>;
setNodeRef: (element: HTMLElement | null) => void;
transform: { x: number; y: number; scaleX: number; scaleY: number } | null;
isDragging: boolean;
}
// Re-export commonly used dnd-kit types
export type {
Active,
Over,
DragStartEvent,
DragEndEvent,
DragOverEvent,
DragCancelEvent,
} from '@dnd-kit/core';import React, { useState, useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
// File type with additional metadata
interface FileWithMeta extends File {
id: string;
preview?: string;
progress: number;
status: 'pending' | 'uploading' | 'success' | 'error';
error?: string;
}
// File Dropzone Component
export function FileDropzone() {
const [files, setFiles] = useState<FileWithMeta[]>([]);
// Handle file drop
const onDrop = useCallback((acceptedFiles: File[], rejectedFiles: any[]) => {
// Process accepted files
const newFiles: FileWithMeta[] = acceptedFiles.map((file) => ({
...file,
id: `${file.name}-${Date.now()}`,
preview: file.type.startsWith('image/')
? URL.createObjectURL(file)
: undefined,
progress: 0,
status: 'pending' as const,
} as FileWithMeta));
setFiles((prev) => [...prev, ...newFiles]);
// Handle rejected files
if (rejectedFiles.length > 0) {
const errors = rejectedFiles.map((rejection) => {
const errors = rejection.errors.map((e: any) => e.message).join(', ');
return `${rejection.file.name}: ${errors}`;
});
alert(`Some files were rejected:\n${errors.join('\n')}`);
}
// Start upload simulation
newFiles.forEach((file) => {
simulateUpload(file);
});
}, []);
// Configure dropzone
const {
getRootProps,
getInputProps,
isDragActive,
isDragAccept,
isDragReject,
open,
} = useDropzone({
onDrop,
accept: {
'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp'],
'application/pdf': ['.pdf'],
'text/*': ['.txt', '.md', '.csv'],
'application/vnd.openxmlformats-officedocument.*': ['.docx', '.xlsx'],
},
maxSize: 10 * 1024 * 1024, // 10MB
maxFiles: 10,
multiple: true,
});
// Simulate file upload with progress
const simulateUpload = (file: FileWithMeta) => {
// Update status to uploading
updateFileStatus(file.id, 'uploading');
let progress = 0;
const interval = setInterval(() => {
progress += Math.random() * 30;
if (progress >= 100) {
progress = 100;
clearInterval(interval);
// Randomly succeed or fail (90% success rate)
if (Math.random() > 0.1) {
updateFileStatus(file.id, 'success');
} else {
updateFileStatus(file.id, 'error', 'Upload failed');
}
}
updateFileProgress(file.id, Math.min(progress, 100));
}, 500);
};
// Update file status
const updateFileStatus = (
id: string,
status: FileWithMeta['status'],
error?: string
) => {
setFiles((prev) =>
prev.map((file) =>
file.id === id ? { ...file, status, error } : file
)
);
};
// Update file progress
const updateFileProgress = (id: string, progress: number) => {
setFiles((prev) =>
prev.map((file) =>
file.id === id ? { ...file, progress } : file
)
);
};
// Remove file
const removeFile = (id: string) => {
setFiles((prev) => {
const file = prev.find((f) => f.id === id);
if (file?.preview) {
URL.revokeObjectURL(file.preview);
}
return prev.filter((f) => f.id !== id);
});
};
// Retry failed upload
const retryUpload = (file: FileWithMeta) => {
updateFileStatus(file.id, 'pending');
updateFileProgress(file.id, 0);
simulateUpload(file);
};
// Clear all files
const clearAll = () => {
files.forEach((file) => {
if (file.preview) {
URL.revokeObjectURL(file.preview);
}
});
setFiles([]);
};
return (
<div className="dropzone-container">
<h2>File Upload</h2>
{/* Dropzone Area */}
<div
{...getRootProps()}
className={`dropzone ${isDragActive ? 'drag-active' : ''} ${
isDragAccept ? 'drag-accept' : ''
} ${isDragReject ? 'drag-reject' : ''}`}
>
<input {...getInputProps()} />
<div className="dropzone-content">
{isDragActive ? (
<>
{isDragAccept && (
<div className="drop-message accept">
<span className="drop-icon">📥</span>
<p>Drop files here to upload</p>
</div>
)}
{isDragReject && (
<div className="drop-message reject">
<span className="drop-icon">🚫</span>
<p>Some files are not accepted</p>
</div>
)}
</>
) : (
<div className="default-message">
<span className="upload-icon">☁️</span>
<h3>Drag & drop files here</h3>
<p className="help-text">or click to browse</p>
<div className="file-info">
<span>Accepted: Images, PDFs, Documents</span>
<span>Max size: 10MB per file</span>
<span>Max files: 10</span>
</div>
<button
type="button"
onClick={open}
className="browse-btn"
>
Browse Files
</button>
</div>
)}
</div>
</div>
{/* File List */}
{files.length > 0 && (
<div className="file-list">
<div className="file-list-header">
<h3>Files ({files.length})</h3>
<button onClick={clearAll} className="clear-btn">
Clear All
</button>
</div>
{files.map((file) => (
<FileItem
key={file.id}
file={file}
onRemove={() => removeFile(file.id)}
onRetry={() => retryUpload(file)}
/>
))}
</div>
)}
{/* Upload Summary */}
{files.length > 0 && (
<UploadSummary files={files} />
)}
</div>
);
}
// Individual File Item
function FileItem({
file,
onRemove,
onRetry,
}: {
file: FileWithMeta;
onRemove: () => void;
onRetry: () => void;
}) {
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};
const getFileIcon = (type: string) => {
if (type.startsWith('image/')) return '🖼️';
if (type === 'application/pdf') return '📄';
if (type.startsWith('text/')) return '📝';
return '📎';
};
return (
<div className={`file-item status-${file.status}`}>
{/* Preview or Icon */}
<div className="file-preview">
{file.preview ? (
<img src={file.preview} alt={file.name} />
) : (
<span className="file-icon">{getFileIcon(file.type)}</span>
)}
</div>
{/* File Info */}
<div className="file-info">
<div className="file-name">{file.name}</div>
<div className="file-meta">
<span className="file-size">{formatFileSize(file.size)}</span>
{file.status === 'success' && (
<span className="success-badge">✓ Uploaded</span>
)}
{file.status === 'error' && (
<span className="error-badge">✗ {file.error}</span>
)}
</div>
{/* Progress Bar */}
{file.status === 'uploading' && (
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${file.progress}%` }}
/>
<span className="progress-text">{Math.round(file.progress)}%</span>
</div>
)}
</div>
{/* Actions */}
<div className="file-actions">
{file.status === 'error' && (
<button
onClick={onRetry}
className="action-btn retry"
aria-label="Retry upload"
>
🔄
</button>
)}
<button
onClick={onRemove}
className="action-btn remove"
aria-label="Remove file"
>
✕
</button>
</div>
</div>
);
}
// Upload Summary
function UploadSummary({ files }: { files: FileWithMeta[] }) {
const stats = {
pending: files.filter((f) => f.status === 'pending').length,
uploading: files.filter((f) => f.status === 'uploading').length,
success: files.filter((f) => f.status === 'success').length,
error: files.filter((f) => f.status === 'error').length,
};
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
return (
<div className="upload-summary">
<div className="stat">
<span className="stat-label">Total Size:</span>
<span className="stat-value">
{(totalSize / (1024 * 1024)).toFixed(2)} MB
</span>
</div>
{stats.pending > 0 && (
<div className="stat">
<span className="stat-label">Pending:</span>
<span className="stat-value">{stats.pending}</span>
</div>
)}
{stats.uploading > 0 && (
<div className="stat">
<span className="stat-label">Uploading:</span>
<span className="stat-value">{stats.uploading}</span>
</div>
)}
{stats.success > 0 && (
<div className="stat success">
<span className="stat-label">Completed:</span>
<span className="stat-value">{stats.success}</span>
</div>
)}
{stats.error > 0 && (
<div className="stat error">
<span className="stat-label">Failed:</span>
<span className="stat-value">{stats.error}</span>
</div>
)}
</div>
);
}
// Styles
const styles = `
.dropzone-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.dropzone {
border: 2px dashed var(--color-border);
border-radius: var(--radius-lg);
padding: 3rem;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background: var(--color-white);
}
.dropzone:hover {
border-color: var(--color-primary);
background: var(--color-primary-50);
}
.dropzone.drag-active {
border-color: var(--color-primary);
background: var(--color-primary-50);
transform: scale(1.02);
}
.dropzone.drag-accept {
border-color: var(--color-success);
background: var(--color-success-50);
}
.dropzone.drag-reject {
border-color: var(--color-danger);
background: var(--color-danger-50);
animation: shake 0.5s;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
.dropzone-content {
pointer-events: none;
}
.drop-message {
animation: fadeIn 0.3s;
}
.drop-icon {
font-size: 3rem;
display: block;
margin-bottom: 1rem;
}
.upload-icon {
font-size: 4rem;
display: block;
margin-bottom: 1rem;
}
.help-text {
color: var(--color-text-secondary);
margin: 0.5rem 0 1.5rem;
}
.file-info {
display: flex;
gap: 1rem;
justify-content: center;
margin: 1rem 0;
font-size: 0.875rem;
color: var(--color-text-tertiary);
}
.browse-btn {
margin-top: 1rem;
padding: 0.75rem 1.5rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s;
}
.browse-btn:hover {
background: var(--color-primary-600);
transform: translateY(-2px);
}
.file-list {
margin-top: 2rem;
background: var(--color-white);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 1rem;
}
.file-list-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 1rem;
border-bottom: 1px solid var(--color-border);
margin-bottom: 1rem;
}
.file-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem;
background: var(--color-gray-50);
border-radius: var(--radius-md);
margin-bottom: 0.5rem;
transition: all 0.2s;
}
.file-item:hover {
background: var(--color-gray-100);
}
.file-preview {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-white);
border-radius: var(--radius-sm);
overflow: hidden;
}
.file-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.file-icon {
font-size: 1.5rem;
}
.file-info {
flex: 1;
}
.file-name {
font-weight: 500;
margin-bottom: 0.25rem;
}
.file-meta {
display: flex;
gap: 1rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.progress-bar {
position: relative;
height: 4px;
background: var(--color-gray-200);
border-radius: 2px;
margin-top: 0.5rem;
overflow: hidden;
}
.progress-fill {
position: absolute;
height: 100%;
background: var(--color-primary);
transition: width 0.3s ease;
}
.progress-text {
position: absolute;
right: 0;
top: -20px;
font-size: 0.75rem;
color: var(--color-text-tertiary);
}
.file-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.25rem 0.5rem;
background: transparent;
border: none;
cursor: pointer;
font-size: 1.25rem;
transition: all 0.2s;
}
.action-btn:hover {
transform: scale(1.1);
}
.upload-summary {
display: flex;
gap: 2rem;
padding: 1rem;
margin-top: 1rem;
background: var(--color-gray-50);
border-radius: var(--radius-md);
font-size: 0.875rem;
}
.stat {
display: flex;
gap: 0.5rem;
}
.stat-label {
color: var(--color-text-secondary);
}
.stat-value {
font-weight: 600;
}
.stat.success .stat-value {
color: var(--color-success);
}
.stat.error .stat-value {
color: var(--color-danger);
}
`;import React, { useState } from 'react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
DragOverlay,
DragStartEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
rectSortingStrategy,
} from '@dnd-kit/sortable';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
// Widget interface
interface Widget {
id: string;
title: string;
content: React.ReactNode;
size: 'small' | 'medium' | 'large';
color: string;
icon: string;
}
// Grid item component
function GridItem({
widget,
isDragging,
}: {
widget: Widget;
isDragging?: boolean;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging: localIsDragging,
} = useSortable({ id: widget.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging || localIsDragging ? 0.5 : 1,
gridColumn: widget.size === 'large' ? 'span 2' : 'span 1',
gridRow: widget.size === 'large' ? 'span 2' : 'span 1',
};
return (
<div
ref={setNodeRef}
style={style}
className={`grid-item size-${widget.size}`}
{...attributes}
{...listeners}
>
<div
className="widget"
style={{ background: `linear-gradient(135deg, ${widget.color}22, ${widget.color}44)` }}
>
<div className="widget-header">
<span className="widget-icon">{widget.icon}</span>
<h3 className="widget-title">{widget.title}</h3>
<button className="widget-menu" aria-label="Widget menu">
⋮
</button>
</div>
<div className="widget-content">
{widget.content}
</div>
{/* Resize handle */}
<button
className="resize-handle"
aria-label="Resize widget"
onClick={(e) => {
e.stopPropagation();
// Handle resize
}}
>
⤡
</button>
</div>
</div>
);
}
// Main grid component
export function GridReorder() {
const [widgets, setWidgets] = useState<Widget[]>([
{
id: 'widget-1',
title: 'Analytics',
content: (
<div className="chart-placeholder">
<div className="bar" style={{ height: '60%' }}></div>
<div className="bar" style={{ height: '80%' }}></div>
<div className="bar" style={{ height: '40%' }}></div>
<div className="bar" style={{ height: '90%' }}></div>
<div className="bar" style={{ height: '70%' }}></div>
</div>
),
size: 'large',
color: '#3b82f6',
icon: '📊',
},
{
id: 'widget-2',
title: 'Users',
content: <div className="metric">1,234</div>,
size: 'small',
color: '#10b981',
icon: '👥',
},
{
id: 'widget-3',
title: 'Revenue',
content: <div className="metric">$54,321</div>,
size: 'small',
color: '#f59e0b',
icon: '💰',
},
{
id: 'widget-4',
title: 'Activity',
content: (
<div className="activity-list">
<div className="activity-item">User signed up</div>
<div className="activity-item">New order received</div>
<div className="activity-item">Payment processed</div>
</div>
),
size: 'medium',
color: '#8b5cf6',
icon: '📈',
},
{
id: 'widget-5',
title: 'Tasks',
content: (
<div className="task-progress">
<div className="progress-item">
<span>In Progress</span>
<span>5</span>
</div>
<div className="progress-item">
<span>Completed</span>
<span>12</span>
</div>
</div>
),
size: 'small',
color: '#ef4444',
icon: '✓',
},
{
id: 'widget-6',
title: 'Calendar',
content: (
<div className="mini-calendar">
<div className="calendar-header">November 2024</div>
<div className="calendar-grid">
{[...Array(30)].map((_, i) => (
<div key={i} className="calendar-day">
{i + 1}
</div>
))}
</div>
</div>
),
size: 'medium',
color: '#ec4899',
icon: '📅',
},
]);
const [activeId, setActiveId] = useState<string | null>(null);
const [columns, setColumns] = useState(4);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 10,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
function handleDragStart(event: DragStartEvent) {
setActiveId(event.active.id as string);
}
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (over && active.id !== over.id) {
setWidgets((items) => {
const oldIndex = items.findIndex((item) => item.id === active.id);
const newIndex = items.findIndex((item) => item.id === over.id);
return arrayMove(items, oldIndex, newIndex);
});
}
setActiveId(null);
}
// Responsive columns
const handleColumnChange = (newColumns: number) => {
setColumns(newColumns);
};
const activeWidget = activeId
? widgets.find((w) => w.id === activeId)
: null;
return (
<div className="grid-container">
<div className="grid-header">
<h2>Dashboard Grid</h2>
<div className="grid-controls">
<div className="column-selector">
<label>Columns:</label>
<button
onClick={() => handleColumnChange(2)}
className={columns === 2 ? 'active' : ''}
aria-label="2 columns"
>
2
</button>
<button
onClick={() => handleColumnChange(3)}
className={columns === 3 ? 'active' : ''}
aria-label="3 columns"
>
3
</button>
<button
onClick={() => handleColumnChange(4)}
className={columns === 4 ? 'active' : ''}
aria-label="4 columns"
>
4
</button>
</div>
<button className="add-widget-btn">
+ Add Widget
</button>
</div>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext
items={widgets}
strategy={rectSortingStrategy}
>
<div
className="widget-grid"
style={{
gridTemplateColumns: `repeat(${columns}, 1fr)`,
}}
>
{widgets.map((widget) => (
<GridItem key={widget.id} widget={widget} />
))}
</div>
</SortableContext>
<DragOverlay>
{activeWidget && (
<GridItem widget={activeWidget} isDragging />
)}
</DragOverlay>
</DndContext>
{/* Instructions */}
<div className="grid-instructions">
<p>
<strong>Drag widgets</strong> to reorder •
<strong> Resize</strong> using corner handle •
<strong> Keyboard</strong>: Tab to focus, Space to grab, Arrows to move
</p>
</div>
</div>
);
}
// Styles
const styles = `
.grid-container {
padding: 2rem;
background: var(--color-gray-50);
min-height: 100vh;
}
.grid-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.grid-controls {
display: flex;
gap: 1rem;
align-items: center;
}
.column-selector {
display: flex;
gap: 0.5rem;
align-items: center;
}
.column-selector label {
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.column-selector button {
padding: 0.5rem 0.75rem;
background: var(--color-white);
border: 1px solid var(--color-border);
cursor: pointer;
transition: all 0.2s;
}
.column-selector button:hover {
background: var(--color-gray-100);
}
.column-selector button.active {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.add-widget-btn {
padding: 0.5rem 1rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s;
}
.add-widget-btn:hover {
background: var(--color-primary-600);
}
.widget-grid {
display: grid;
gap: 1rem;
grid-auto-rows: minmax(120px, 1fr);
grid-auto-flow: dense;
}
.grid-item {
cursor: grab;
transition: transform 0.2s, opacity 0.2s;
}
.grid-item:active {
cursor: grabbing;
}
.widget {
height: 100%;
background: var(--color-white);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: 1rem;
display: flex;
flex-direction: column;
position: relative;
transition: all 0.2s;
}
.widget:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.widget-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.widget-icon {
font-size: 1.25rem;
}
.widget-title {
flex: 1;
font-size: 0.875rem;
font-weight: 600;
margin: 0;
}
.widget-menu {
padding: 0.25rem;
background: transparent;
border: none;
cursor: pointer;
color: var(--color-text-tertiary);
}
.widget-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.resize-handle {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
width: 1.5rem;
height: 1.5rem;
background: transparent;
border: none;
cursor: nwse-resize;
color: var(--color-text-tertiary);
opacity: 0;
transition: opacity 0.2s;
}
.widget:hover .resize-handle {
opacity: 1;
}
/* Widget content styles */
.metric {
font-size: 2rem;
font-weight: bold;
color: var(--color-text-primary);
}
.chart-placeholder {
display: flex;
gap: 0.5rem;
align-items: flex-end;
height: 100px;
width: 100%;
}
.bar {
flex: 1;
background: var(--color-primary-400);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
.activity-list {
width: 100%;
font-size: 0.813rem;
}
.activity-item {
padding: 0.5rem;
border-bottom: 1px solid var(--color-border);
}
.task-progress {
width: 100%;
}
.progress-item {
display: flex;
justify-content: space-between;
padding: 0.5rem;
font-size: 0.875rem;
}
.mini-calendar {
width: 100%;
font-size: 0.75rem;
}
.calendar-header {
text-align: center;
font-weight: 600;
margin-bottom: 0.5rem;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.calendar-day {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-gray-50);
border-radius: 2px;
font-size: 0.625rem;
}
.grid-instructions {
margin-top: 2rem;
padding: 1rem;
background: var(--color-white);
border-radius: var(--radius-md);
text-align: center;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
/* Responsive */
@media (max-width: 768px) {
.widget-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
.grid-item.size-large {
grid-column: span 2;
}
}
@media (max-width: 480px) {
.widget-grid {
grid-template-columns: 1fr !important;
}
.grid-item.size-large,
.grid-item.size-medium {
grid-column: span 1;
grid-row: span 1;
}
}
/* Drag overlay */
.grid-item[aria-grabbed="true"] {
opacity: 0.5;
}
/* Focus styles */
.grid-item:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
`;import React, { useState, useMemo } from 'react';
import {
DndContext,
DragOverlay,
closestCorners,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragStartEvent,
DragOverEvent,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
horizontalListSortingStrategy,
} from '@dnd-kit/sortable';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
// Types
interface Task {
id: string;
title: string;
description: string;
priority: 'low' | 'medium' | 'high' | 'urgent';
assignee?: string;
tags: string[];
columnId: string;
}
interface Column {
id: string;
title: string;
wipLimit?: number;
color: string;
}
// Draggable Task Card
function TaskCard({ task, isDragging }: { task: Task; isDragging?: boolean }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({
id: task.id,
data: {
type: 'task',
task,
},
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={`task-card priority-${task.priority}`}
{...attributes}
{...listeners}
>
<div className="task-header">
<h4 className="task-title">{task.title}</h4>
<span className={`priority-indicator priority-${task.priority}`} />
</div>
<p className="task-description">{task.description}</p>
<div className="task-footer">
{task.assignee && (
<div className="assignee">
<img
src={`https://ui-avatars.com/api/?name=${task.assignee}&size=24`}
alt={task.assignee}
title={task.assignee}
/>
</div>
)}
<div className="tags">
{task.tags.map((tag) => (
<span key={tag} className="tag">
{tag}
</span>
))}
</div>
</div>
</div>
);
}
// Droppable Column
function BoardColumn({
column,
tasks,
isOver,
}: {
column: Column;
tasks: Task[];
isOver?: boolean;
}) {
const {
setNodeRef,
attributes,
listeners,
transform,
transition,
} = useSortable({
id: column.id,
data: {
type: 'column',
column,
},
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const isOverLimit = column.wipLimit && tasks.length >= column.wipLimit;
return (
<div
ref={setNodeRef}
style={style}
className={`board-column ${isOver ? 'column-over' : ''} ${
isOverLimit ? 'over-limit' : ''
}`}
>
<div
className="column-header"
style={{ borderTopColor: column.color }}
>
<button
className="column-drag-handle"
{...attributes}
{...listeners}
aria-label={`Drag to reorder ${column.title} column`}
>
⋮⋮
</button>
<h3 className="column-title">{column.title}</h3>
<span className="task-count">
{tasks.length}
{column.wipLimit && ` / ${column.wipLimit}`}
</span>
</div>
<SortableContext
items={tasks.map((t) => t.id)}
strategy={verticalListSortingStrategy}
>
<div className="tasks-container">
{tasks.map((task) => (
<TaskCard key={task.id} task={task} />
))}
{tasks.length === 0 && (
<div className="empty-column">
Drop tasks here
</div>
)}
</div>
</SortableContext>
<button className="add-task-btn">
+ Add Task
</button>
</div>
);
}
// Main Kanban Board
export function KanbanBoard() {
const [columns] = useState<Column[]>([
{ id: 'backlog', title: 'Backlog', color: '#94a3b8' },
{ id: 'todo', title: 'To Do', color: '#60a5fa', wipLimit: 5 },
{ id: 'inprogress', title: 'In Progress', color: '#fbbf24', wipLimit: 3 },
{ id: 'review', title: 'Review', color: '#a78bfa', wipLimit: 2 },
{ id: 'done', title: 'Done', color: '#34d399' },
]);
const [tasks, setTasks] = useState<Task[]>([
{
id: 'task-1',
title: 'Setup project repository',
description: 'Initialize git repo and add initial files',
priority: 'high',
assignee: 'John Doe',
tags: ['setup', 'git'],
columnId: 'done',
},
{
id: 'task-2',
title: 'Design database schema',
description: 'Create ERD and define relationships',
priority: 'urgent',
assignee: 'Jane Smith',
tags: ['backend', 'database'],
columnId: 'inprogress',
},
{
id: 'task-3',
title: 'Implement authentication',
description: 'Add JWT-based auth system',
priority: 'high',
assignee: 'John Doe',
tags: ['backend', 'security'],
columnId: 'todo',
},
{
id: 'task-4',
title: 'Create landing page',
description: 'Design and implement homepage',
priority: 'medium',
tags: ['frontend', 'design'],
columnId: 'todo',
},
{
id: 'task-5',
title: 'Write API documentation',
description: 'Document all API endpoints',
priority: 'low',
tags: ['documentation'],
columnId: 'backlog',
},
]);
const [activeId, setActiveId] = useState<string | null>(null);
// Group tasks by column
const tasksByColumn = useMemo(() => {
const map: Record<string, Task[]> = {};
columns.forEach((col) => {
map[col.id] = tasks.filter((task) => task.columnId === col.id);
});
return map;
}, [tasks, columns]);
// Sensors for drag detection
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 10,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// Find container for task
function findContainer(id: string) {
if (columns.find((col) => col.id === id)) {
return id;
}
const task = tasks.find((t) => t.id === id);
return task?.columnId;
}
// Drag handlers
function handleDragStart(event: DragStartEvent) {
const { active } = event;
setActiveId(active.id as string);
}
function handleDragOver(event: DragOverEvent) {
const { active, over } = event;
if (!over) return;
const activeContainer = findContainer(active.id as string);
const overContainer = findContainer(over.id as string);
if (!activeContainer || !overContainer || activeContainer === overContainer) {
return;
}
setTasks((prev) => {
const activeTask = prev.find((t) => t.id === active.id);
if (!activeTask) return prev;
// Check WIP limit
const overColumn = columns.find((c) => c.id === overContainer);
if (overColumn?.wipLimit) {
const tasksInColumn = prev.filter((t) => t.columnId === overContainer);
if (tasksInColumn.length >= overColumn.wipLimit) {
// Show warning
console.warn(`Column ${overColumn.title} has reached its WIP limit`);
return prev;
}
}
// Move task to new column
return prev.map((task) =>
task.id === activeTask.id
? { ...task, columnId: overContainer }
: task
);
});
}
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over) {
setActiveId(null);
return;
}
const activeContainer = findContainer(active.id as string);
const overContainer = findContainer(over.id as string);
if (!activeContainer || !overContainer) {
setActiveId(null);
return;
}
if (activeContainer === overContainer) {
// Reorder within same column
const containerTasks = tasksByColumn[overContainer];
const oldIndex = containerTasks.findIndex((t) => t.id === active.id);
const newIndex = containerTasks.findIndex((t) => t.id === over.id);
if (oldIndex !== newIndex) {
const newTasks = arrayMove(containerTasks, oldIndex, newIndex);
setTasks((prev) => [
...prev.filter((t) => t.columnId !== overContainer),
...newTasks,
]);
}
}
setActiveId(null);
}
const activeTask = activeId ? tasks.find((t) => t.id === activeId) : null;
return (
<div className="kanban-board">
<h2>Project Board</h2>
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<SortableContext
items={columns.map((c) => c.id)}
strategy={horizontalListSortingStrategy}
>
<div className="columns-container">
{columns.map((column) => (
<BoardColumn
key={column.id}
column={column}
tasks={tasksByColumn[column.id] || []}
/>
))}
</div>
</SortableContext>
<DragOverlay>
{activeTask && <TaskCard task={activeTask} isDragging />}
</DragOverlay>
</DndContext>
</div>
);
}
// Styles
const styles = `
.kanban-board {
padding: 2rem;
background: var(--color-gray-50);
min-height: 100vh;
}
.columns-container {
display: flex;
gap: 1.5rem;
overflow-x: auto;
padding-bottom: 2rem;
}
.board-column {
flex: 0 0 320px;
background: var(--color-white);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
display: flex;
flex-direction: column;
max-height: calc(100vh - 120px);
}
.board-column.column-over {
background: var(--color-primary-50);
box-shadow: var(--shadow-md);
}
.board-column.over-limit {
background: var(--color-red-50);
}
.column-header {
padding: 1rem;
border-top: 3px solid;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.column-drag-handle {
cursor: grab;
padding: 0.25rem;
background: transparent;
border: none;
color: var(--color-text-tertiary);
}
.column-title {
flex: 1;
font-size: 1rem;
font-weight: 600;
margin: 0;
}
.task-count {
background: var(--color-gray-100);
padding: 0.25rem 0.5rem;
border-radius: var(--radius-full);
font-size: 0.875rem;
font-weight: 500;
}
.tasks-container {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.task-card {
background: var(--color-white);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1rem;
cursor: grab;
transition: all 0.2s;
}
.task-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.task-card.priority-urgent {
border-left: 3px solid var(--color-red-500);
}
.task-card.priority-high {
border-left: 3px solid var(--color-orange-500);
}
.task-card.priority-medium {
border-left: 3px solid var(--color-yellow-500);
}
.task-card.priority-low {
border-left: 3px solid var(--color-blue-500);
}
.task-header {
display: flex;
justify-content: space-between;
align-items: start;
margin-bottom: 0.5rem;
}
.task-title {
font-size: 0.875rem;
font-weight: 600;
margin: 0;
flex: 1;
}
.priority-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
}
.priority-indicator.priority-urgent {
background: var(--color-red-500);
}
.priority-indicator.priority-high {
background: var(--color-orange-500);
}
.priority-indicator.priority-medium {
background: var(--color-yellow-500);
}
.priority-indicator.priority-low {
background: var(--color-blue-500);
}
.task-description {
font-size: 0.813rem;
color: var(--color-text-secondary);
margin: 0.5rem 0;
line-height: 1.4;
}
.task-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.75rem;
}
.assignee img {
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid var(--color-white);
}
.tags {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.tag {
background: var(--color-gray-100);
color: var(--color-text-secondary);
padding: 0.125rem 0.375rem;
border-radius: var(--radius-sm);
font-size: 0.75rem;
}
.empty-column {
padding: 2rem;
text-align: center;
color: var(--color-text-tertiary);
border: 2px dashed var(--color-border);
border-radius: var(--radius-md);
margin: 0.5rem;
}
.add-task-btn {
margin: 0.5rem;
padding: 0.75rem;
background: transparent;
border: 1px dashed var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-secondary);
cursor: pointer;
transition: all 0.2s;
}
.add-task-btn:hover {
background: var(--color-gray-50);
border-color: var(--color-primary);
color: var(--color-primary);
}
`;import React, { useState } from 'react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
// Item interface
interface TodoItem {
id: string;
text: string;
completed: boolean;
priority: 'low' | 'medium' | 'high';
}
// Sortable item component
function SortableItem({ item }: { item: TodoItem }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: item.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={`sortable-item priority-${item.priority} ${item.completed ? 'completed' : ''}`}
>
{/* Drag handle */}
<button
className="drag-handle"
{...attributes}
{...listeners}
aria-label={`Drag to reorder ${item.text}`}
>
<svg width="20" height="20" viewBox="0 0 20 20">
<g fill="currentColor">
<circle cx="7" cy="5" r="1.5" />
<circle cx="13" cy="5" r="1.5" />
<circle cx="7" cy="10" r="1.5" />
<circle cx="13" cy="10" r="1.5" />
<circle cx="7" cy="15" r="1.5" />
<circle cx="13" cy="15" r="1.5" />
</g>
</svg>
</button>
{/* Checkbox */}
<input
type="checkbox"
checked={item.completed}
onChange={() => {/* Toggle completion */}}
aria-label={`Mark ${item.text} as ${item.completed ? 'incomplete' : 'complete'}`}
/>
{/* Item text */}
<span className="item-text">{item.text}</span>
{/* Priority badge */}
<span className={`priority-badge priority-${item.priority}`}>
{item.priority}
</span>
{/* Alternative move buttons (for accessibility) */}
<div className="alternative-controls">
<button
onClick={() => {/* Move up logic */}}
aria-label={`Move ${item.text} up`}
className="move-btn"
>
↑
</button>
<button
onClick={() => {/* Move down logic */}}
aria-label={`Move ${item.text} down`}
className="move-btn"
>
↓
</button>
</div>
</div>
);
}
// Main sortable list component
export function SortableList() {
const [items, setItems] = useState<TodoItem[]>([
{ id: '1', text: 'Complete project documentation', completed: false, priority: 'high' },
{ id: '2', text: 'Review pull requests', completed: true, priority: 'medium' },
{ id: '3', text: 'Update dependencies', completed: false, priority: 'low' },
{ id: '4', text: 'Write unit tests', completed: false, priority: 'high' },
{ id: '5', text: 'Deploy to staging', completed: false, priority: 'medium' },
]);
// Configure sensors for keyboard and pointer
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // 8px movement required to start drag
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// Handle drag end
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (over && active.id !== over.id) {
setItems((items) => {
const oldIndex = items.findIndex((item) => item.id === active.id);
const newIndex = items.findIndex((item) => item.id === over.id);
const newItems = arrayMove(items, oldIndex, newIndex);
// Announce change to screen readers
announceReorder(items[oldIndex], oldIndex + 1, newIndex + 1);
return newItems;
});
}
}
// Screen reader announcement
function announceReorder(item: TodoItem, fromPosition: number, toPosition: number) {
const announcement = `${item.text} moved from position ${fromPosition} to position ${toPosition}`;
const liveRegion = document.getElementById('drag-drop-announcements');
if (liveRegion) {
liveRegion.textContent = announcement;
}
}
return (
<div className="sortable-list-container">
<h2>Todo List (Drag to Reorder)</h2>
{/* Screen reader instructions */}
<div id="drag-instructions" className="sr-only">
To reorder items: Press Tab to navigate to an item's drag handle,
press Space or Enter to lift the item, use Arrow keys to move it,
and press Space or Enter again to drop it in the new position.
Press Escape to cancel the drag operation.
</div>
{/* Live region for announcements */}
<div
id="drag-drop-announcements"
aria-live="assertive"
aria-atomic="true"
className="sr-only"
/>
{/* Sortable list */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={items}
strategy={verticalListSortingStrategy}
>
<div
role="list"
aria-label="Sortable todo list"
aria-describedby="drag-instructions"
>
{items.map((item, index) => (
<div
key={item.id}
role="listitem"
aria-setsize={items.length}
aria-posinset={index + 1}
>
<SortableItem item={item} />
</div>
))}
</div>
</SortableContext>
</DndContext>
{/* Summary */}
<div className="list-summary">
<p>Total: {items.length} items</p>
<p>Completed: {items.filter(i => i.completed).length}</p>
<p>High Priority: {items.filter(i => i.priority === 'high').length}</p>
</div>
</div>
);
}
// Styles (using CSS-in-JS or external stylesheet)
const styles = `
.sortable-list-container {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
}
.sortable-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
margin-bottom: 0.5rem;
background: var(--color-white);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
transition: all 0.2s ease;
}
.sortable-item:hover {
box-shadow: var(--shadow-sm);
}
.sortable-item.completed {
opacity: 0.6;
}
.sortable-item.completed .item-text {
text-decoration: line-through;
}
.drag-handle {
cursor: grab;
padding: 0.5rem;
background: transparent;
border: none;
color: var(--color-text-tertiary);
transition: all 0.2s;
}
.drag-handle:hover {
background: var(--color-gray-100);
border-radius: var(--radius-sm);
}
.drag-handle:active {
cursor: grabbing;
}
.item-text {
flex: 1;
font-size: 1rem;
}
.priority-badge {
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.priority-low {
background: var(--color-blue-100);
color: var(--color-blue-700);
}
.priority-medium {
background: var(--color-yellow-100);
color: var(--color-yellow-700);
}
.priority-high {
background: var(--color-red-100);
color: var(--color-red-700);
}
.alternative-controls {
display: flex;
gap: 0.25rem;
}
.move-btn {
padding: 0.25rem 0.5rem;
background: var(--color-gray-100);
border: 1px solid var(--color-gray-300);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.2s;
}
.move-btn:hover {
background: var(--color-gray-200);
}
.move-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.list-summary {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
display: flex;
gap: 2rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
/* Accessibility: Screen reader only content */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* Focus styles */
*:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Dragging state */
.sortable-item[aria-grabbed="true"] {
box-shadow: var(--shadow-lg);
transform: scale(1.02);
}
/* Mobile responsive */
@media (max-width: 640px) {
.sortable-item {
padding: 0.75rem;
}
.drag-handle {
padding: 0.75rem;
}
.alternative-controls {
display: flex; /* Always show on mobile */
}
}
@media (min-width: 641px) {
.alternative-controls {
display: none; /* Hide on desktop, show on hover/focus */
}
.sortable-item:hover .alternative-controls,
.sortable-item:focus-within .alternative-controls {
display: flex;
}
}
`;skill: "implementing-drag-drop"
version: "1.0"
domain: "frontend"
base_outputs:
- path: "src/components/**/*{Sortable,Draggable,Droppable,DragDrop,Kanban}*.{tsx,ts,jsx,js}"
must_contain:
- "@dnd-kit/core"
- "DndContext"
- "useSensor"
description: "Drag-and-drop components using dnd-kit library with proper sensor configuration"
- path: "src/hooks/**/*{Drag,Drop,Sortable}*.{ts,tsx}"
must_contain:
- "useSortable"
- "setNodeRef"
description: "Custom hooks for drag-and-drop functionality with proper refs and transforms"
- path: "src/**/*.{tsx,ts,jsx,js}"
must_contain:
- "aria-label"
- "role="
description: "Accessibility attributes for drag-and-drop interactions (ARIA labels and roles)"
conditional_outputs:
maturity:
starter:
- path: "src/components/**/Sortable*.{tsx,jsx}"
must_contain:
- "verticalListSortingStrategy"
- "arrayMove"
- "onDragEnd"
description: "Basic sortable list component with vertical sorting"
- path: "src/**/*.{tsx,ts,jsx,js}"
must_contain:
- "PointerSensor"
- "KeyboardSensor"
description: "Basic pointer and keyboard sensor configuration for accessibility"
intermediate:
- path: "src/components/**/{Kanban,Board}*.{tsx,jsx}"
must_contain:
- "DragOverlay"
- "onDragOver"
- "onDragStart"
description: "Multi-container drag-drop with kanban board pattern and drag overlay"
- path: "src/components/**/{Dropzone,FileUpload}*.{tsx,jsx}"
must_contain:
- "accept"
- "FileWithMeta"
- "progress"
description: "File dropzone component with upload progress and file validation"
- path: "src/**/*.{tsx,ts,jsx,js}"
must_contain:
- "aria-live"
- "aria-describedby"
description: "Advanced accessibility with live regions for screen reader announcements"
advanced:
- path: "src/components/**/Grid*.{tsx,jsx}"
must_contain:
- "rectSortingStrategy"
- "grid"
description: "2D grid layout with rect sorting strategy for dashboard widgets"
- path: "src/hooks/**/use{DragDrop,Sortable}*.{ts,tsx}"
must_contain:
- "useMemo"
- "useCallback"
description: "Optimized custom hooks with memoization for performance"
- path: "src/**/*.{tsx,ts,jsx,js}"
must_contain:
- "virtualScroll"
- "throttle"
description: "Performance optimizations with virtualization and throttling for large lists"
- path: "src/utils/**/drag*.{ts,js}"
must_contain:
- "calculateDropPosition"
- "collision"
description: "Custom collision detection and drop position calculation utilities"
frontend_framework:
react:
- path: "src/**/*.{tsx,jsx}"
must_contain:
- "useState"
- "useEffect"
- "@dnd-kit"
description: "React components with dnd-kit hooks and state management"
- path: "src/**/*.{tsx,jsx}"
must_contain:
- "useSortable"
- "CSS.Transform.toString"
description: "dnd-kit sortable hooks with CSS transform utilities"
vue:
- path: "src/**/*.vue"
must_contain:
- "ref"
- "computed"
description: "Vue components with reactive drag-drop state (if Vue-compatible library used)"
styling:
css:
- path: "src/**/*.css"
must_contain:
- ".drag-handle"
- "cursor: grab"
- "opacity"
description: "CSS for drag handles, cursor states, and dragging visual feedback"
- path: "src/**/*.css"
must_contain:
- "transform"
- "transition"
description: "CSS transforms and transitions for smooth drag animations"
styled_components:
- path: "src/**/*.{tsx,jsx,ts,js}"
must_contain:
- "styled"
- "css`"
- "isDragging"
description: "Styled-components with dynamic styling based on drag state"
tailwind:
- path: "src/**/*.{tsx,jsx}"
must_contain:
- "className="
- "cursor-grab"
- "opacity-50"
description: "Tailwind classes for drag states (grab cursor, opacity, hover effects)"
design_tokens:
- path: "src/**/*.{css,tsx,jsx}"
must_contain:
- "var(--color-"
- "var(--shadow-"
- "var(--radius-"
description: "Design token variables for consistent theming (colors, shadows, borders)"
scaffolding:
- path: "src/types/drag-drop.ts"
reason: "TypeScript type definitions for drag state, events, and configuration"
- path: "src/utils/drag-calculations.ts"
reason: "Utility functions for drop position calculation and collision detection"
- path: "src/hooks/useDragDrop.ts"
reason: "Custom hook abstracting common drag-and-drop logic and state management"
- path: "src/components/DragHandle/index.tsx"
reason: "Reusable drag handle component with proper accessibility and visual affordance"
- path: "src/config/dnd-config.ts"
reason: "Centralized dnd-kit configuration (sensors, modifiers, animations)"
metadata:
primary_blueprints: ["dashboard", "frontend"]
contributes_to:
- "Drag-and-drop interactions"
- "Sortable lists and tables"
- "Kanban boards"
- "File upload zones"
- "Reorderable grids"
- "Dashboard widget management"
- "Interactive spatial organization"
- "Touch-friendly UI manipulation"
validation_notes:
- "All drag-drop components MUST include keyboard navigation support"
- "Screen reader announcements required via aria-live regions"
- "Touch support required for mobile (long press activation)"
- "Drag handles should use ⋮⋮ or similar visual indicator"
- "Alternative non-drag UI must be provided for accessibility"
- "CSS transforms preferred over position changes for performance"
- "Virtualization required for lists with >100 items"
- "All sensor configurations must include both pointer and keyboard"
key_patterns:
sortable_list:
files: ["SortableList.tsx", "useSortable.ts"]
must_include: ["verticalListSortingStrategy", "arrayMove", "drag-handle"]
kanban_board:
files: ["KanbanBoard.tsx", "TaskCard.tsx", "BoardColumn.tsx"]
must_include: ["DragOverlay", "onDragOver", "closestCorners", "wipLimit"]
file_dropzone:
files: ["FileDropzone.tsx", "useFileUpload.ts"]
must_include: ["accept", "maxSize", "FileWithMeta", "progress"]
grid_reorder:
files: ["GridLayout.tsx", "GridItem.tsx"]
must_include: ["rectSortingStrategy", "display: grid", "responsive"]
dependencies:
required:
- "@dnd-kit/core"
- "@dnd-kit/sortable"
- "@dnd-kit/utilities"
optional:
- "@dnd-kit/modifiers"
- "react-virtual"
- "framer-motion"
accessibility_requirements:
critical:
- "Keyboard navigation (Space/Enter to grab, Arrow keys to move)"
- "Screen reader announcements for all drag events"
- "Focus management during drag operations"
- "Alternative UI (move up/down buttons) for non-drag interaction"
- "ARIA attributes (aria-grabbed, aria-dropeffect, role)"
recommended:
- "Visual focus indicators with high contrast"
- "Drag instructions in sr-only element"
- "Live region with aria-live='assertive'"
- "Clear visual feedback for valid/invalid drop zones"
performance_thresholds:
small_list: "<50 items - standard implementation"
medium_list: "50-100 items - add memoization and throttling"
large_list: ">100 items - implement virtual scrolling"
mobile_considerations:
- "Long press (250-300ms) to initiate drag"
- "Touch target minimum 44px for handles"
- "Prevent scroll during drag operation"
- "Handle gesture conflicts with native scrolling"
- "Test on actual mobile devices, not just browser emulation"
Accessibility for Drag-and-Drop
Table of Contents
- Core Principles
- Keyboard Navigation
- Screen Reader Support
- Alternative UI Patterns
- ARIA Patterns
- Testing Guidelines
Core Principles
The Accessibility Challenge
Drag-and-drop is inherently visual and mouse-centric, creating barriers for:
- Keyboard-only users
- Screen reader users
- Users with motor disabilities
- Touch device users with assistive tech
Universal Design Approach
Always provide: 1. Full keyboard navigation 2. Clear announcements for screen readers 3. Alternative UI methods (buttons, forms) 4. Visual feedback for all states 5. Sufficient time for interactions
Keyboard Navigation
Standard Key Mappings
// Recommended keyboard scheme for dnd-kit
const keyboardCoordinates = {
start: ['Space', 'Enter'], // Pick up item
cancel: ['Escape'], // Cancel drag
end: ['Space', 'Enter'], // Drop item
up: ['ArrowUp', 'w', 'W'], // Move up
down: ['ArrowDown', 's', 'S'], // Move down
left: ['ArrowLeft', 'a', 'A'], // Move left
right: ['ArrowRight', 'd', 'D'], // Move right
};Implementation with dnd-kit
import { KeyboardSensor, useSensor } from '@dnd-kit/core';
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
function AccessibleDragDrop() {
const keyboardSensor = useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
});
return (
<DndContext sensors={[keyboardSensor]}>
{/* Draggable content */}
</DndContext>
);
}Custom Keyboard Navigation
// Enhanced keyboard handler with visual feedback
function useKeyboardDragDrop(items, onReorder) {
const [activeIndex, setActiveIndex] = useState(-1);
const [isGrabbed, setIsGrabbed] = useState(false);
const handleKeyDown = (e: KeyboardEvent) => {
if (!items.length) return;
switch (e.key) {
case 'Tab':
// Allow normal tab navigation when not dragging
if (!isGrabbed) return;
e.preventDefault();
break;
case ' ':
case 'Enter':
e.preventDefault();
if (activeIndex === -1) {
setActiveIndex(0);
} else if (!isGrabbed) {
// Pick up item
setIsGrabbed(true);
announceToScreenReader(`Grabbed ${items[activeIndex].label}. Use arrow keys to move.`);
} else {
// Drop item
setIsGrabbed(false);
announceToScreenReader(`Dropped ${items[activeIndex].label} at position ${activeIndex + 1}.`);
}
break;
case 'Escape':
if (isGrabbed) {
e.preventDefault();
setIsGrabbed(false);
announceToScreenReader('Drag cancelled.');
}
break;
case 'ArrowUp':
case 'ArrowDown':
e.preventDefault();
if (isGrabbed) {
const newIndex = e.key === 'ArrowUp'
? Math.max(0, activeIndex - 1)
: Math.min(items.length - 1, activeIndex + 1);
if (newIndex !== activeIndex) {
onReorder(arrayMove(items, activeIndex, newIndex));
setActiveIndex(newIndex);
announceToScreenReader(`Moved to position ${newIndex + 1} of ${items.length}.`);
}
} else {
// Navigate without dragging
const newIndex = e.key === 'ArrowUp'
? Math.max(0, activeIndex - 1)
: Math.min(items.length - 1, activeIndex + 1);
setActiveIndex(newIndex);
}
break;
}
};
return { activeIndex, isGrabbed, handleKeyDown };
}Screen Reader Support
Live Region Announcements
// Announcement component for screen readers
function DragDropAnnouncements({ children }) {
return (
<>
{children}
{/* Live region for announcements */}
<div
id="dnd-announcements"
aria-live="assertive"
aria-atomic="true"
className="sr-only"
style={{
position: 'absolute',
left: '-10000px',
width: '1px',
height: '1px',
overflow: 'hidden',
}}
/>
</>
);
}
// Helper function to announce
function announceToScreenReader(message: string) {
const element = document.getElementById('dnd-announcements');
if (element) {
element.textContent = message;
// Clear after announcement
setTimeout(() => {
element.textContent = '';
}, 1000);
}
}dnd-kit Accessibility Features
import { Announcements } from '@dnd-kit/core';
// Custom announcement messages
const announcements: Announcements = {
onDragStart(id) {
return `Picked up draggable item ${id}. Press arrow keys to move, space to drop, escape to cancel.`;
},
onDragOver(id, overId) {
if (overId) {
return `Draggable item ${id} is over droppable area ${overId}.`;
}
return `Draggable item ${id} is no longer over a droppable area.`;
},
onDragEnd(id, overId) {
if (overId) {
return `Draggable item ${id} was dropped over droppable area ${overId}.`;
}
return `Draggable item ${id} was dropped.`;
},
onDragCancel(id) {
return `Dragging was cancelled. Draggable item ${id} was dropped.`;
},
};
// Use in DndContext
<DndContext announcements={announcements}>
{/* Content */}
</DndContext>Alternative UI Patterns
Move Buttons Pattern
Provide explicit move buttons as an alternative to drag-and-drop.
function AccessibleListItem({ item, index, onMove, totalItems }) {
return (
<div className="list-item" role="listitem">
<div className="item-content">{item.content}</div>
{/* Alternative controls */}
<div className="item-controls" role="group" aria-label="Reorder controls">
<button
onClick={() => onMove(index, index - 1)}
disabled={index === 0}
aria-label={`Move ${item.label} up`}
title="Move up"
>
↑
</button>
<button
onClick={() => onMove(index, index + 1)}
disabled={index === totalItems - 1}
aria-label={`Move ${item.label} down`}
title="Move down"
>
↓
</button>
<select
aria-label={`Move ${item.label} to position`}
value={index}
onChange={(e) => onMove(index, parseInt(e.target.value))}
>
{Array.from({ length: totalItems }, (_, i) => (
<option key={i} value={i}>
Position {i + 1}
</option>
))}
</select>
</div>
</div>
);
}Context Menu Pattern
Right-click or long-press menu for reordering.
function ContextMenuReorder({ item, onAction }) {
const [menuOpen, setMenuOpen] = useState(false);
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setMenuPosition({ x: e.clientX, y: e.clientY });
setMenuOpen(true);
};
return (
<>
<div
onContextMenu={handleContextMenu}
role="button"
tabIndex={0}
aria-haspopup="true"
aria-expanded={menuOpen}
>
{item.content}
</div>
{menuOpen && (
<div
className="context-menu"
style={{ left: menuPosition.x, top: menuPosition.y }}
role="menu"
>
<button role="menuitem" onClick={() => onAction('moveUp')}>
Move Up
</button>
<button role="menuitem" onClick={() => onAction('moveDown')}>
Move Down
</button>
<button role="menuitem" onClick={() => onAction('moveToTop')}>
Move to Top
</button>
<button role="menuitem" onClick={() => onAction('moveToBottom')}>
Move to Bottom
</button>
</div>
)}
</>
);
}ARIA Patterns
Essential ARIA Attributes
// Draggable item
<div
role="button"
tabIndex={0}
aria-roledescription="sortable"
aria-describedby="drag-instructions"
aria-grabbed={isDragging}
aria-dropeffect={canDrop ? "move" : "none"}
aria-label={`${item.label}, position ${index + 1} of ${total}`}
>
{item.content}
</div>
// Drag instructions (hidden but available to screen readers)
<div id="drag-instructions" className="sr-only">
Press space or enter to start dragging.
Use arrow keys to move the item.
Press space or enter again to drop.
Press escape to cancel.
</div>Drop Zone ARIA
function DropZone({ isActive, canDrop, children }) {
return (
<div
role="region"
aria-dropeffect={canDrop ? "move" : "none"}
aria-busy={isActive}
aria-label="Drop zone"
aria-describedby={isActive ? "drop-active" : "drop-inactive"}
>
{children}
<span id="drop-active" className="sr-only">
Drop zone active. Release to drop here.
</span>
<span id="drop-inactive" className="sr-only">
Drop zone available.
</span>
</div>
);
}List Reordering Pattern
// Accessible sortable list
<div
role="list"
aria-label="Sortable task list"
aria-describedby="list-instructions"
>
{items.map((item, index) => (
<div
key={item.id}
role="listitem"
aria-setsize={items.length}
aria-posinset={index + 1}
tabIndex={activeIndex === index ? 0 : -1}
>
{/* Item content */}
</div>
))}
</div>
<div id="list-instructions" className="sr-only">
This is a reorderable list.
Press Tab to focus an item, then press Space to grab it.
Use arrow keys to move the item.
Press Space again to drop it.
</div>Testing Guidelines
Manual Testing Checklist
## Keyboard Navigation
- [ ] Can reach all draggable items with Tab key
- [ ] Can initiate drag with Space/Enter
- [ ] Can move items with arrow keys
- [ ] Can cancel drag with Escape
- [ ] Can drop items with Space/Enter
- [ ] Focus visible at all times
- [ ] No keyboard traps
## Screen Reader Testing
- [ ] All items have descriptive labels
- [ ] Drag start announced
- [ ] Movement announced with position
- [ ] Drop location announced
- [ ] Instructions available
- [ ] Live region updates work
## Alternative UI
- [ ] Move buttons functional
- [ ] Position selector works
- [ ] Context menu accessible
- [ ] All alternatives keyboard accessibleAutomated Testing
// Jest + Testing Library example
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('drag and drop is keyboard accessible', async () => {
const user = userEvent.setup();
render(<SortableList items={items} />);
// Focus first item
const firstItem = screen.getByRole('button', { name: /item 1/i });
await user.tab();
expect(firstItem).toHaveFocus();
// Start drag
await user.keyboard(' ');
expect(firstItem).toHaveAttribute('aria-grabbed', 'true');
// Move down
await user.keyboard('{ArrowDown}');
// Drop
await user.keyboard(' ');
expect(firstItem).toHaveAttribute('aria-grabbed', 'false');
// Verify announcement
expect(screen.getByRole('status')).toHaveTextContent(/dropped/i);
});Screen Reader Testing Tools
Windows:
- NVDA (free, recommended for testing)
- JAWS (commercial, widely used)
macOS:
- VoiceOver (built-in, Cmd+F5)
Linux:
- Orca (free, GNOME)
Browser Extensions:
- ChromeVox (Chrome)
- Screen Reader Simulator
Testing Script
#!/bin/bash
# accessibility-test.sh
echo "🔍 Testing Drag-and-Drop Accessibility"
# Run automated tests
npm test -- --coverage drag-drop.test
# Check ARIA attributes
echo "Checking ARIA attributes..."
grep -r "aria-" ./src/components/drag-drop/
# Validate keyboard handlers
echo "Checking keyboard support..."
grep -r "onKeyDown\|handleKey" ./src/components/drag-drop/
# Check for alternative UI
echo "Checking alternative UI..."
grep -r "button.*move\|Move.*button" ./src/components/drag-drop/
echo "✅ Accessibility check complete"Best Practices Summary
Do's
- ✅ Provide full keyboard navigation
- ✅ Include clear screen reader announcements
- ✅ Offer alternative UI methods
- ✅ Test with actual assistive technology
- ✅ Document keyboard shortcuts
- ✅ Use semantic HTML and ARIA correctly
Don'ts
- ❌ Rely solely on drag-and-drop
- ❌ Hide important functionality behind drag
- ❌ Forget escape key handling
- ❌ Ignore focus management
- ❌ Skip screen reader testing
- ❌ Use placeholder text as labels
Quick Implementation Checklist
// Minimum viable accessible drag-and-drop
const AccessibleDragDrop = () => {
// ✅ 1. Keyboard sensor
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor)
);
// ✅ 2. Announcements
const announcements = {
onDragStart: (id) => `Picked up ${id}`,
onDragEnd: (id, overId) => `Dropped ${id} at ${overId}`,
};
// ✅ 3. ARIA attributes
const ariaAttributes = {
role: 'button',
tabIndex: 0,
'aria-roledescription': 'sortable',
'aria-grabbed': isDragging,
};
// ✅ 4. Alternative UI
const alternativeControls = (
<button onClick={handleMoveWithoutDrag}>
Move without dragging
</button>
);
// ✅ 5. Instructions
const instructions = (
<div className="sr-only">
Press space to grab, arrows to move, space to drop
</div>
);
return (
<DndContext sensors={sensors} announcements={announcements}>
{instructions}
{/* Draggable content with ARIA */}
{alternativeControls}
</DndContext>
);
};Drag-and-Drop Patterns
Table of Contents
Sortable Lists
Vertical List Pattern
Most common drag-and-drop pattern for priority ordering, task lists, and sequential content.
Implementation with dnd-kit:
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable';
function VerticalSortableList({ items, onReorder }) {
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
function handleDragEnd(event) {
const { active, over } = event;
if (active.id !== over.id) {
const oldIndex = items.findIndex(item => item.id === active.id);
const newIndex = items.findIndex(item => item.id === over.id);
onReorder(arrayMove(items, oldIndex, newIndex));
}
}
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={items} strategy={verticalListSortingStrategy}>
{/* Sortable items here */}
</SortableContext>
</DndContext>
);
}Horizontal List Pattern
Used for tab reordering, carousel management, and timeline elements.
Key Differences:
- Use
horizontalListSortingStrategyinstead of vertical - Adjust drag handle positioning (typically on edges)
- Consider touch scrolling conflicts on mobile
import { horizontalListSortingStrategy } from '@dnd-kit/sortable';
// In SortableContext:
<SortableContext items={items} strategy={horizontalListSortingStrategy}>
{/* Horizontal items */}
</SortableContext>Drag Handle Pattern
Provides explicit drag affordance while keeping content interactive.
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
function SortableItem({ id, children }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div ref={setNodeRef} style={style}>
<button
className="drag-handle"
{...attributes}
{...listeners}
aria-label="Drag to reorder"
>
⋮⋮
</button>
{children}
</div>
);
}Grid Layouts
2D Grid Pattern
For dashboard widgets, image galleries, and card layouts.
import { rectSortingStrategy } from '@dnd-kit/sortable';
function GridLayout({ items, columns = 3 }) {
return (
<SortableContext items={items} strategy={rectSortingStrategy}>
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap: '1rem'
}}
>
{items.map(item => (
<SortableGridItem key={item.id} {...item} />
))}
</div>
</SortableContext>
);
}Masonry Layout Pattern
For Pinterest-style layouts with variable heights.
Considerations:
- Calculate positions dynamically
- Handle reflow on drop
- Optimize for performance with many items
// Use custom collision detection for masonry
function masonryCollisionDetection(args) {
// Custom logic to handle variable heights
// Consider item positions and sizes
return closestCenter(args);
}Nested Containers
Parent-Child Dragging
For tree structures, nested lists, and hierarchical content.
function NestedSortable({ items, depth = 0 }) {
const maxDepth = 3; // Prevent infinite nesting
return (
<SortableContext items={items}>
{items.map(item => (
<div key={item.id} style={{ marginLeft: depth * 20 }}>
<SortableItem {...item} />
{item.children && depth < maxDepth && (
<NestedSortable items={item.children} depth={depth + 1} />
)}
</div>
))}
</SortableContext>
);
}Auto-Scrolling
Edge Detection Pattern
Automatically scroll when dragging near container edges.
import { AutoScrollActivator } from '@dnd-kit/auto-scroll';
function ScrollableList() {
const autoScrollOptions = {
canScroll: (element) => true,
threshold: {
x: 0.2, // 20% from edge
y: 0.2,
},
maxSpeed: 10,
acceleration: 10,
};
return (
<DndContext autoScroll={autoScrollOptions}>
{/* Scrollable content */}
</DndContext>
);
}Viewport Scrolling
For full-page draggable interfaces.
// Enable window scrolling during drag
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // Prevent accidental drags
},
})
);
// Auto-scroll viewport
useEffect(() => {
if (isDragging) {
// Custom scroll logic based on cursor position
}
}, [isDragging, cursorPosition]);Multi-Select Dragging
Selection State Pattern
Allow dragging multiple items simultaneously.
function MultiSelectDraggable() {
const [selectedIds, setSelectedIds] = useState(new Set());
function handleDragStart(event) {
const { active } = event;
if (!selectedIds.has(active.id)) {
// If dragging unselected item, clear selection
setSelectedIds(new Set([active.id]));
}
// Otherwise drag all selected items
}
function handleDragEnd(event) {
const { active, over } = event;
if (over && selectedIds.size > 0) {
// Move all selected items relative to drop position
const itemsToMove = Array.from(selectedIds);
// Reorder logic here
}
}
return (
<DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
{/* Multi-selectable items */}
</DndContext>
);
}Visual Feedback for Multi-Select
function MultiSelectItem({ id, isSelected, isDragging }) {
const style = {
backgroundColor: isSelected ? 'var(--color-primary-100)' : 'white',
opacity: isDragging && isSelected ? 0.5 : 1,
border: isSelected ? '2px solid var(--color-primary)' : '1px solid var(--color-border)',
};
return <div style={style}>{/* Content */}</div>;
}Common Patterns Summary
Pattern Selection Guide
| Use Case | Pattern | Key Considerations |
|---|---|---|
| Task lists | Vertical sortable | Drag handles, keyboard nav |
| Tabs | Horizontal sortable | Touch scrolling conflicts |
| Dashboard | Grid layout | Responsive columns |
| File browser | Nested containers | Depth limits |
| Kanban | Multi-container | Drop zones, auto-scroll |
| Batch operations | Multi-select | Visual selection state |
Performance Tips
1. Virtualization: For lists >100 items 2. Throttling: Limit drag event frequency 3. Memoization: Prevent unnecessary re-renders 4. CSS Transforms: Use transform, not position 5. Will-change: Hint browser about animations
Accessibility Requirements
Every pattern must include:
- Keyboard navigation support
- Screen reader announcements
- Focus management
- Alternative UI options
- Clear visual feedback
Mobile Considerations
- Long press (300ms) to initiate drag
- Prevent scroll during drag
- Larger touch targets (44px minimum)
- Handle gesture conflicts
- Test on actual devices
File Upload Dropzone Implementation
Table of Contents
- Basic Dropzone
- Visual Feedback States
- File Type Validation
- Multi-File Handling
- Progress Indicators
- Advanced Features
Basic Dropzone
Core Implementation
import { useDropzone } from 'react-dropzone';
function BasicDropzone({ onFilesAdded }) {
const {
getRootProps,
getInputProps,
isDragActive,
isDragAccept,
isDragReject,
acceptedFiles,
rejectedFiles
} = useDropzone({
accept: {
'image/*': ['.png', '.jpg', '.jpeg', '.gif'],
'application/pdf': ['.pdf']
},
maxFiles: 10,
maxSize: 5 * 1024 * 1024, // 5MB
onDrop: (acceptedFiles, rejectedFiles) => {
onFilesAdded(acceptedFiles);
if (rejectedFiles.length > 0) {
handleRejectedFiles(rejectedFiles);
}
}
});
return (
<div
{...getRootProps()}
className={`dropzone
${isDragActive ? 'dropzone--active' : ''}
${isDragAccept ? 'dropzone--accept' : ''}
${isDragReject ? 'dropzone--reject' : ''}
`}
>
<input {...getInputProps()} />
{isDragActive ? (
<p>Drop the files here...</p>
) : (
<p>Drag 'n' drop files here, or click to select</p>
)}
<div className="dropzone-info">
<span>Accepted: Images, PDFs</span>
<span>Max size: 5MB</span>
<span>Max files: 10</span>
</div>
</div>
);
}Native HTML5 Implementation
function NativeDropzone({ onFilesAdded }) {
const [dragActive, setDragActive] = useState(false);
const [dragCounter, setDragCounter] = useState(0);
const dropRef = useRef<HTMLDivElement>(null);
const handleDrag = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDragIn = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragCounter(prev => prev + 1);
if (e.dataTransfer?.items && e.dataTransfer.items.length > 0) {
setDragActive(true);
}
};
const handleDragOut = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragCounter(prev => prev - 1);
if (dragCounter === 1) {
setDragActive(false);
}
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
setDragCounter(0);
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
const files = Array.from(e.dataTransfer.files);
onFilesAdded(files);
e.dataTransfer.clearData();
}
};
useEffect(() => {
const div = dropRef.current;
if (!div) return;
div.addEventListener('dragenter', handleDragIn);
div.addEventListener('dragleave', handleDragOut);
div.addEventListener('dragover', handleDrag);
div.addEventListener('drop', handleDrop);
return () => {
div.removeEventListener('dragenter', handleDragIn);
div.removeEventListener('dragleave', handleDragOut);
div.removeEventListener('dragover', handleDrag);
div.removeEventListener('drop', handleDrop);
};
}, []);
return (
<div
ref={dropRef}
className={`native-dropzone ${dragActive ? 'drag-active' : ''}`}
>
<input
type="file"
id="file-input"
multiple
onChange={(e) => {
if (e.target.files) {
onFilesAdded(Array.from(e.target.files));
}
}}
style={{ display: 'none' }}
/>
<label htmlFor="file-input" className="dropzone-label">
{dragActive ? (
<div className="drag-active-content">
<span>📥</span>
<p>Release to upload</p>
</div>
) : (
<div className="default-content">
<span>📁</span>
<p>Drag files here or click to browse</p>
</div>
)}
</label>
</div>
);
}Visual Feedback States
State-Based Styling
/* Base dropzone styles */
.dropzone {
border: 2px dashed var(--drop-zone-border);
border-radius: var(--drop-zone-border-radius);
padding: var(--drop-zone-padding);
background: var(--drop-zone-bg);
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
min-height: 200px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
/* Hovering with files */
.dropzone--active {
border-color: var(--drop-zone-border-active);
background: var(--drop-zone-bg-active);
transform: scale(1.02);
box-shadow: var(--shadow-lg);
}
/* Valid files being dragged */
.dropzone--accept {
border-color: var(--color-success);
background: var(--color-success-50);
}
/* Invalid files being dragged */
.dropzone--reject {
border-color: var(--color-danger);
background: var(--color-danger-50);
animation: shake 0.5s;
}
/* Shake animation for rejection */
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
/* File type indicators */
.file-type-indicator {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.875rem;
}
.file-type-indicator--image {
background: var(--color-blue-100);
color: var(--color-blue-700);
}
.file-type-indicator--pdf {
background: var(--color-red-100);
color: var(--color-red-700);
}
.file-type-indicator--video {
background: var(--color-purple-100);
color: var(--color-purple-700);
}Enhanced Visual Feedback Component
function EnhancedDropzone({ onFilesAdded, accept, maxSize }) {
const [isDragging, setIsDragging] = useState(false);
const [isValidating, setIsValidating] = useState(false);
const [validationStatus, setValidationStatus] = useState<'valid' | 'invalid' | null>(null);
const validateDraggedItems = (e: DragEvent) => {
if (!e.dataTransfer?.items) return null;
setIsValidating(true);
const items = Array.from(e.dataTransfer.items);
const hasValidFiles = items.some(item => {
if (item.kind !== 'file') return false;
const type = item.type;
if (accept) {
return Object.keys(accept).some(acceptType => {
if (acceptType === '*/*') return true;
return type.match(new RegExp(acceptType.replace('*', '.*')));
});
}
return true;
});
setValidationStatus(hasValidFiles ? 'valid' : 'invalid');
setIsValidating(false);
return hasValidFiles;
};
return (
<div
className={`enhanced-dropzone
${isDragging ? 'dragging' : ''}
${validationStatus === 'valid' ? 'valid' : ''}
${validationStatus === 'invalid' ? 'invalid' : ''}
${isValidating ? 'validating' : ''}
`}
onDragEnter={(e) => {
e.preventDefault();
setIsDragging(true);
validateDraggedItems(e);
}}
onDragLeave={(e) => {
e.preventDefault();
if (e.currentTarget === e.target) {
setIsDragging(false);
setValidationStatus(null);
}
}}
onDragOver={(e) => {
e.preventDefault();
}}
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
setValidationStatus(null);
const files = Array.from(e.dataTransfer.files);
onFilesAdded(files);
}}
>
{/* Visual indicators */}
<div className="dropzone-indicators">
{isDragging && (
<div className="drag-indicator">
{isValidating && <span className="spinner" />}
{validationStatus === 'valid' && <span className="checkmark">✓</span>}
{validationStatus === 'invalid' && <span className="cross">✗</span>}
</div>
)}
</div>
{/* Content */}
<div className="dropzone-content">
{isDragging ? (
<>
{validationStatus === 'valid' && <p>Drop files to upload</p>}
{validationStatus === 'invalid' && <p>Invalid file type</p>}
{isValidating && <p>Checking files...</p>}
</>
) : (
<p>Drag and drop files here</p>
)}
</div>
</div>
);
}File Type Validation
Comprehensive Validation
interface FileValidationRules {
accept?: Record<string, string[]>;
maxSize?: number;
minSize?: number;
maxFiles?: number;
validator?: (file: File) => boolean | Promise<boolean>;
}
function useFileValidation(rules: FileValidationRules) {
const validateFile = async (file: File): Promise<{
valid: boolean;
errors: string[];
}> => {
const errors: string[] = [];
// Type validation
if (rules.accept) {
const isValidType = Object.entries(rules.accept).some(([type, extensions]) => {
if (type === '*/*') return true;
// Check MIME type
if (file.type.match(new RegExp(type.replace('*', '.*')))) {
return true;
}
// Check extension
const fileName = file.name.toLowerCase();
return extensions.some(ext => fileName.endsWith(ext));
});
if (!isValidType) {
errors.push(`File type not accepted: ${file.type || 'unknown'}`);
}
}
// Size validation
if (rules.maxSize && file.size > rules.maxSize) {
errors.push(`File too large: ${formatFileSize(file.size)} (max: ${formatFileSize(rules.maxSize)})`);
}
if (rules.minSize && file.size < rules.minSize) {
errors.push(`File too small: ${formatFileSize(file.size)} (min: ${formatFileSize(rules.minSize)})`);
}
// Custom validation
if (rules.validator) {
try {
const isValid = await rules.validator(file);
if (!isValid) {
errors.push('File failed custom validation');
}
} catch (error) {
errors.push(`Validation error: ${error.message}`);
}
}
return {
valid: errors.length === 0,
errors
};
};
const validateFiles = async (files: File[]): Promise<{
accepted: File[];
rejected: Array<{ file: File; errors: string[] }>;
}> => {
// Check max files
if (rules.maxFiles && files.length > rules.maxFiles) {
return {
accepted: [],
rejected: files.map(file => ({
file,
errors: [`Too many files. Maximum allowed: ${rules.maxFiles}`]
}))
};
}
const results = await Promise.all(
files.map(async (file) => {
const validation = await validateFile(file);
return { file, ...validation };
})
);
return {
accepted: results.filter(r => r.valid).map(r => r.file),
rejected: results.filter(r => !r.valid).map(r => ({
file: r.file,
errors: r.errors
}))
};
};
return { validateFile, validateFiles };
}Image-Specific Validation
async function validateImage(file: File): Promise<boolean> {
return new Promise((resolve) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
// Check dimensions
const maxWidth = 4000;
const maxHeight = 4000;
const minWidth = 100;
const minHeight = 100;
if (img.width > maxWidth || img.height > maxHeight) {
console.error(`Image too large: ${img.width}x${img.height}`);
resolve(false);
} else if (img.width < minWidth || img.height < minHeight) {
console.error(`Image too small: ${img.width}x${img.height}`);
resolve(false);
} else {
resolve(true);
}
};
img.onerror = () => {
URL.revokeObjectURL(url);
resolve(false);
};
img.src = url;
});
}Multi-File Handling
File List Management
interface FileWithMeta extends File {
id: string;
status: 'pending' | 'uploading' | 'success' | 'error';
progress: number;
error?: string;
preview?: string;
}
function MultiFileDropzone() {
const [files, setFiles] = useState<FileWithMeta[]>([]);
const addFiles = (newFiles: File[]) => {
const filesWithMeta: FileWithMeta[] = newFiles.map(file => ({
...file,
id: generateId(),
status: 'pending',
progress: 0,
preview: file.type.startsWith('image/')
? URL.createObjectURL(file)
: undefined
}));
setFiles(prev => [...prev, ...filesWithMeta]);
};
const removeFile = (id: string) => {
setFiles(prev => {
const file = prev.find(f => f.id === id);
if (file?.preview) {
URL.revokeObjectURL(file.preview);
}
return prev.filter(f => f.id !== id);
});
};
const updateFileStatus = (id: string, updates: Partial<FileWithMeta>) => {
setFiles(prev =>
prev.map(f => f.id === id ? { ...f, ...updates } : f)
);
};
return (
<div className="multi-file-dropzone">
<BasicDropzone onFilesAdded={addFiles} />
<div className="file-list">
{files.map(file => (
<FileItem
key={file.id}
file={file}
onRemove={() => removeFile(file.id)}
onRetry={() => uploadFile(file)}
/>
))}
</div>
{files.length > 0 && (
<div className="file-actions">
<button onClick={() => uploadAllFiles(files)}>
Upload All
</button>
<button onClick={() => setFiles([])}>
Clear All
</button>
</div>
)}
</div>
);
}File Item Component
function FileItem({ file, onRemove, onRetry }) {
return (
<div className={`file-item status-${file.status}`}>
{/* Preview */}
{file.preview && (
<div className="file-preview">
<img src={file.preview} alt={file.name} />
</div>
)}
{/* File icon for non-images */}
{!file.preview && (
<div className="file-icon">
{getFileIcon(file.type)}
</div>
)}
{/* File info */}
<div className="file-info">
<div className="file-name">{file.name}</div>
<div className="file-size">{formatFileSize(file.size)}</div>
{/* Status indicator */}
<div className="file-status">
{file.status === 'uploading' && (
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${file.progress}%` }}
/>
</div>
)}
{file.status === 'success' && <span className="success">✓ Uploaded</span>}
{file.status === 'error' && (
<span className="error">✗ {file.error}</span>
)}
</div>
</div>
{/* Actions */}
<div className="file-actions">
{file.status === 'error' && (
<button onClick={onRetry} aria-label="Retry upload">
🔄
</button>
)}
<button onClick={onRemove} aria-label="Remove file">
✗
</button>
</div>
</div>
);
}Progress Indicators
Upload Progress Tracking
function useFileUpload() {
const uploadFile = async (
file: FileWithMeta,
onProgress: (progress: number) => void
): Promise<void> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append('file', file);
// Track upload progress
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const progress = (e.loaded / e.total) * 100;
onProgress(progress);
}
});
// Handle completion
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
reject(new Error(`Upload failed: ${xhr.status}`));
}
});
// Handle errors
xhr.addEventListener('error', () => {
reject(new Error('Upload failed'));
});
// Send request
xhr.open('POST', '/api/upload');
xhr.send(formData);
});
};
const uploadWithProgress = async (
file: FileWithMeta,
updateProgress: (id: string, progress: number) => void
) => {
try {
await uploadFile(file, (progress) => {
updateProgress(file.id, progress);
});
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
};
return { uploadFile, uploadWithProgress };
}Progress UI Components
function CircularProgress({ progress, size = 60 }) {
const circumference = 2 * Math.PI * 20;
const offset = circumference - (progress / 100) * circumference;
return (
<svg width={size} height={size} className="circular-progress">
<circle
cx={size / 2}
cy={size / 2}
r="20"
fill="none"
stroke="var(--color-gray-300)"
strokeWidth="4"
/>
<circle
cx={size / 2}
cy={size / 2}
r="20"
fill="none"
stroke="var(--color-primary)"
strokeWidth="4"
strokeDasharray={circumference}
strokeDashoffset={offset}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
style={{ transition: 'stroke-dashoffset 0.3s' }}
/>
<text
x={size / 2}
y={size / 2}
textAnchor="middle"
dominantBaseline="middle"
fontSize="12"
>
{Math.round(progress)}%
</text>
</svg>
);
}Advanced Features
Paste from Clipboard
function PasteableDropzone({ onFilesAdded }) {
useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => {
const items = Array.from(e.clipboardData?.items || []);
const files: File[] = [];
for (const item of items) {
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) files.push(file);
} else if (item.type === 'text/html') {
// Extract images from HTML
const html = await new Promise<string>(resolve => {
item.getAsString(resolve);
});
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const images = doc.querySelectorAll('img');
for (const img of images) {
const response = await fetch(img.src);
const blob = await response.blob();
const file = new File([blob], 'pasted-image.png', { type: blob.type });
files.push(file);
}
}
}
if (files.length > 0) {
onFilesAdded(files);
}
};
document.addEventListener('paste', handlePaste);
return () => document.removeEventListener('paste', handlePaste);
}, [onFilesAdded]);
return (
<div className="pasteable-dropzone">
<p>Drag files here or paste from clipboard (Ctrl/Cmd+V)</p>
</div>
);
}Camera Capture
function CameraDropzone({ onFilesAdded }) {
const [showCamera, setShowCamera] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const startCamera = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' }
});
if (videoRef.current) {
videoRef.current.srcObject = stream;
setShowCamera(true);
}
} catch (error) {
console.error('Camera access denied:', error);
}
};
const capturePhoto = () => {
if (!videoRef.current || !canvasRef.current) return;
const video = videoRef.current;
const canvas = canvasRef.current;
const context = canvas.getContext('2d');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
context?.drawImage(video, 0, 0);
canvas.toBlob((blob) => {
if (blob) {
const file = new File([blob], `photo-${Date.now()}.jpg`, {
type: 'image/jpeg'
});
onFilesAdded([file]);
stopCamera();
}
}, 'image/jpeg', 0.9);
};
const stopCamera = () => {
const stream = videoRef.current?.srcObject as MediaStream;
stream?.getTracks().forEach(track => track.stop());
setShowCamera(false);
};
return (
<div className="camera-dropzone">
<BasicDropzone onFilesAdded={onFilesAdded} />
<button onClick={startCamera} className="camera-button">
📷 Take Photo
</button>
{showCamera && (
<div className="camera-modal">
<video ref={videoRef} autoPlay playsInline />
<canvas ref={canvasRef} style={{ display: 'none' }} />
<div className="camera-controls">
<button onClick={capturePhoto}>Capture</button>
<button onClick={stopCamera}>Cancel</button>
</div>
</div>
)}
</div>
);
}#!/usr/bin/env node
/**
* Calculate Drop Position Utility
* Determines valid drop zones and insertion indices for drag-and-drop operations
*/
// Calculate the drop position based on cursor position
function calculateDropPosition(cursor, elements, orientation = 'vertical') {
if (!elements || elements.length === 0) {
return { index: 0, closestElement: null };
}
const distances = elements.map((element, index) => {
const rect = element.getBoundingClientRect();
const center = orientation === 'vertical'
? rect.top + rect.height / 2
: rect.left + rect.width / 2;
const cursorPosition = orientation === 'vertical' ? cursor.y : cursor.x;
const distance = Math.abs(center - cursorPosition);
return { index, distance, center, element };
});
// Sort by distance to find closest element
distances.sort((a, b) => a.distance - b.distance);
const closest = distances[0];
// Determine if cursor is before or after the closest element
const cursorPos = orientation === 'vertical' ? cursor.y : cursor.x;
const insertIndex = cursorPos < closest.center ? closest.index : closest.index + 1;
return {
index: Math.min(insertIndex, elements.length),
closestElement: closest.element,
distance: closest.distance,
};
}
// Calculate drop zone for nested containers
function calculateDropZone(cursor, containers) {
const zones = [];
containers.forEach(container => {
const rect = container.getBoundingClientRect();
// Check if cursor is within container bounds
if (cursor.x >= rect.left &&
cursor.x <= rect.right &&
cursor.y >= rect.top &&
cursor.y <= rect.bottom) {
// Calculate distance to center for priority
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const distance = Math.sqrt(
Math.pow(cursor.x - centerX, 2) +
Math.pow(cursor.y - centerY, 2)
);
zones.push({
container,
rect,
distance,
area: rect.width * rect.height,
});
}
});
if (zones.length === 0) return null;
// Sort by smallest area first (most specific container)
zones.sort((a, b) => a.area - b.area);
return zones[0].container;
}
// Calculate grid position for 2D layouts
function calculateGridPosition(cursor, gridContainer, columns) {
const rect = gridContainer.getBoundingClientRect();
const relativeX = cursor.x - rect.left;
const relativeY = cursor.y - rect.top;
const columnWidth = rect.width / columns;
const column = Math.floor(relativeX / columnWidth);
const row = Math.floor(relativeY / (rect.height / Math.ceil(gridContainer.children.length / columns)));
const index = row * columns + column;
return {
index: Math.min(index, gridContainer.children.length),
row,
column,
position: { x: column * columnWidth, y: row * (rect.height / columns) },
};
}
// Check if drop is valid based on rules
function validateDrop(draggedItem, dropTarget, rules = {}) {
const {
maxItems = Infinity,
allowedTypes = [],
rejectedTypes = [],
customValidator = null,
} = rules;
// Check max items constraint
if (dropTarget.children && dropTarget.children.length >= maxItems) {
return { valid: false, reason: 'Container is full' };
}
// Check type constraints
if (allowedTypes.length > 0 && !allowedTypes.includes(draggedItem.type)) {
return { valid: false, reason: 'Item type not allowed' };
}
if (rejectedTypes.length > 0 && rejectedTypes.includes(draggedItem.type)) {
return { valid: false, reason: 'Item type rejected' };
}
// Run custom validation if provided
if (customValidator) {
const customResult = customValidator(draggedItem, dropTarget);
if (!customResult.valid) {
return customResult;
}
}
return { valid: true, reason: null };
}
// Calculate auto-scroll speed based on cursor position
function calculateAutoScroll(cursor, container, threshold = 50) {
const rect = container.getBoundingClientRect();
const scrollSpeed = { x: 0, y: 0 };
const maxSpeed = 15;
// Horizontal scrolling
if (cursor.x < rect.left + threshold) {
// Scroll left
const distance = rect.left + threshold - cursor.x;
scrollSpeed.x = -Math.min((distance / threshold) * maxSpeed, maxSpeed);
} else if (cursor.x > rect.right - threshold) {
// Scroll right
const distance = cursor.x - (rect.right - threshold);
scrollSpeed.x = Math.min((distance / threshold) * maxSpeed, maxSpeed);
}
// Vertical scrolling
if (cursor.y < rect.top + threshold) {
// Scroll up
const distance = rect.top + threshold - cursor.y;
scrollSpeed.y = -Math.min((distance / threshold) * maxSpeed, maxSpeed);
} else if (cursor.y > rect.bottom - threshold) {
// Scroll down
const distance = cursor.y - (rect.bottom - threshold);
scrollSpeed.y = Math.min((distance / threshold) * maxSpeed, maxSpeed);
}
return scrollSpeed;
}
// Find the nearest valid drop target
function findNearestDropTarget(cursor, dropTargets, maxDistance = 100) {
let nearest = null;
let minDistance = maxDistance;
dropTargets.forEach(target => {
const rect = target.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const distance = Math.sqrt(
Math.pow(cursor.x - centerX, 2) +
Math.pow(cursor.y - centerY, 2)
);
if (distance < minDistance) {
minDistance = distance;
nearest = target;
}
});
return { target: nearest, distance: minDistance };
}
// Handle edge cases for drop position
function handleDropEdgeCases(dropPosition, container, item) {
const adjustedPosition = { ...dropPosition };
// Don't allow dropping item on itself
if (item && container.contains(item)) {
const itemIndex = Array.from(container.children).indexOf(item);
if (adjustedPosition.index > itemIndex) {
adjustedPosition.index--;
}
}
// Ensure index is within bounds
adjustedPosition.index = Math.max(0, Math.min(adjustedPosition.index, container.children.length));
return adjustedPosition;
}
// Main function for CLI usage
if (require.main === module) {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === '--help') {
console.log(`
Calculate Drop Position Utility
Usage:
node calculate_drop_position.js [command] [options]
Commands:
position <x> <y> <orientation> Calculate drop position for cursor at (x,y)
validate <item-type> <max-items> Validate if drop is allowed
autoscroll <x> <y> <threshold> Calculate auto-scroll speed
Examples:
node calculate_drop_position.js position 100 200 vertical
node calculate_drop_position.js validate card 10
node calculate_drop_position.js autoscroll 50 400 30
Options:
--help Show this help message
`);
process.exit(0);
}
// Example output for demonstration
const command = args[0];
switch (command) {
case 'position':
console.log(JSON.stringify({
index: 3,
closestElement: 'element-3',
distance: 15.5,
}, null, 2));
break;
case 'validate':
console.log(JSON.stringify({
valid: true,
reason: null,
}, null, 2));
break;
case 'autoscroll':
console.log(JSON.stringify({
x: 0,
y: 10,
}, null, 2));
break;
default:
console.error(`Unknown command: ${command}`);
process.exit(1);
}
}
// Export functions for use in other scripts
module.exports = {
calculateDropPosition,
calculateDropZone,
calculateGridPosition,
validateDrop,
calculateAutoScroll,
findNearestDropTarget,
handleDropEdgeCases,
};Related skills
How it compares
Use implementing-drag-drop for accessible React reordering with dnd-kit; consider HTML5 drag APIs only for trivial cases without sortable lists or touch requirements.
FAQ
Which drag-and-drop library does this skill use?
It uses dnd-kit as the primary library, described as modern, accessible, and performant with a ~10KB core and zero dependencies.
Does it support keyboard and screen-reader accessibility?
Yes. It provides keyboard navigation (Space/Enter to grab, arrows to move) and screen-reader announcements, with a validate_accessibility.js script.