freeform
nuxt-freeform

Desktop-like drag & drop for Nuxt. Lasso selection, reorder, drop into containers - zero dependencies, pure Vue magic.

nuxt-freeform

npm versionnpm downloadsLicenseNuxtGitHub starsnuxt.care

Desktop-like drag & drop for Nuxt/Vue. Lasso selection, reorder, drop into containers - all with sensible defaults.

There's no Nuxt module for drag & drop on nuxt.com/modules - until now.

Learn more

Documentation

Modes

Desktop

Lasso selection, multi-select, drag & drop - just like your OS file manager.

Desktop Mode

Freeform

Free positioning on a canvas - arrange items anywhere you want.

Freeform Mode

Lists

Drag between multiple lists - perfect for Kanban boards and task management.

Lists Mode

Features

  • Zero Dependencies - Pure Vue magic, no third-party drag & drop libraries
  • Lasso Selection - Select multiple items with a selection rectangle, just like on your desktop
  • Drag & Drop - Reorder items or drop into containers/folders
  • Multi-Select - Ctrl/Cmd+Click to toggle selection, drag multiple items at once
  • Zero Config - Works out of the box with sensible defaults
  • Fully Customizable - Override any visual via slots
  • CSS Variables - Easy theming with CSS custom properties
  • SSR Safe - Proper hydration support for Nuxt
  • TypeScript - Full type support with generics

Installation

npx nuxi module add nuxt-freeform

Or manually:

pnpm add nuxt-freeform
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['nuxt-freeform']
})

Quick Start

The simplest example - just 15 lines of code:

<script setup>
const items = ref([
  { id: 'Folder A', type: 'container' },
  { id: 'Folder B', type: 'container' },
  { id: 'Item 1' },
  { id: 'Item 2' },
  { id: 'Item 3' },
])

function onDropInto(droppedItems, container, accepted) {
  if (!accepted) return
  // Remove items from list (they're now "inside" the folder)
  items.value = items.value.filter(i => !droppedItems.some(d => d.id === i.id))
}
</script>

<template>
  <TheFreeform v-model="items" @drop-into="onDropInto" class="flex flex-wrap gap-3 p-4">
    <FreeformItem v-for="item in items" :key="item.id" :item="item" />
    <FreeformPlaceholder />
  </TheFreeform>
</template>

You get:

  • Drag to reorder (automatic via v-model)
  • Drop into folders (items with type: 'container')
  • Default ghost, placeholder, and item styling
  • Selection states

Components

TheFreeform

The main container that manages all drag & drop state.

<TheFreeform
  v-model="items"
  :disabled="false"
  :manual-reorder="false"
  @select="onSelect"
  @drag-start="onDragStart"
  @drag-end="onDragEnd"
  @drop-into="onDropInto"
  @reorder="onReorder"
>
  <!-- items go here -->
</TheFreeform>
PropTypeDefaultDescription
modelValueFreeformItemData[]requiredItems array (v-model)
disabledbooleanfalseDisable all interactions
manualReorderbooleanfalseDon't auto-reorder, handle manually

FreeformItem

Individual draggable item. Automatically registers with the parent TheFreeform.

<FreeformItem
  :item="item"
  :disabled="false"
  :as-drop-zone="false"
  :accept="acceptFn"
>
  <template #default="{ selected, dragging, dropTarget, dropAccepted }">
    <!-- custom content -->
  </template>
</FreeformItem>
PropTypeDefaultDescription
itemFreeformItemDatarequiredItem data
disabledbooleanfalseDisable dragging for this item
asDropZonebooleanfalseForce this item to be a drop target
accept(items) => boolean-Validate if drop is allowed

Slot Props:

PropTypeDescription
itemobjectThe item data
selectedbooleanItem is selected
draggingbooleanItem is being dragged
dropTargetbooleanItem is a drop target (hovering)
dropAcceptedbooleanDrop would be accepted

FreeformPlaceholder

Shows where dragged items will land. Automatically sizes to match the dragged item.

<FreeformPlaceholder>
  <template #default="{ count, size }">
    <div class="my-placeholder">{{ count }} items</div>
  </template>
</FreeformPlaceholder>

FreeformSelection

Wraps TheFreeform to enable lasso selection.

<FreeformSelection @select="onSelect">
  <TheFreeform v-model="items">
    <!-- ... -->
  </TheFreeform>

  <template #lasso="{ selectedCount }">
    <div class="selection-box">
      <span class="badge">{{ selectedCount }}</span>
    </div>
  </template>
</FreeformSelection>

FreeformDropZone

Enables cross-list drag & drop between multiple TheFreeform instances.

<FreeformDropZone id="list-a" :accept="acceptFn">
  <template #default="{ isOver, isAccepted }">
    <div :class="{ 'bg-green-100': isOver && isAccepted, 'bg-red-100': isOver && !isAccepted }">
      <TheFreeform v-model="listA" drop-zone-id="list-a" @drop-to-zone="onDropToZone">
        <!-- items -->
      </TheFreeform>
    </div>
  </template>
</FreeformDropZone>
PropTypeDefaultDescription
idstringauto-generatedUnique zone identifier
accept(items) => boolean-Validate if drop is allowed

Slot Props:

PropTypeDescription
isOverbooleanItems are being dragged over this zone
isAcceptedbooleanDrop would be accepted

Hierarchical Accept

When using FreeformDropZone with containers inside, the accept logic is hierarchical:

  • Zone accept: Only checked for direct drops into the zone
  • Container accept: Checked when dropping into a container inside the zone

This allows patterns like "zone accepts only cards, but cards (containers) accept controls":

<FreeformDropZone id="dashboard" :accept="acceptOnlyCards">
  <TheFreeform v-model="cards" drop-zone-id="dashboard">
    <FreeformItem
      v-for="card in cards"
      :item="card"
      :accept="acceptOnlyControls"
    />
  </TheFreeform>
</FreeformDropZone>
// Zone accepts only cards directly
function acceptOnlyCards(items) {
  return items.every(i => i.type === 'card')
}

// Containers (cards) accept only controls
function acceptOnlyControls(items) {
  return items.every(i => i.type === 'control')
}

Items dragged to a container bypass the zone's accept - only the container's accept is checked.

Examples

File Manager

<script setup>
interface FileItem {
  id: string
  name: string
  icon: string
  type?: 'container'
}

const files = ref<FileItem[]>([
  { id: '1', name: 'Documents', icon: '📁', type: 'container' },
  { id: '2', name: 'Photos', icon: '📁', type: 'container' },
  { id: '3', name: 'readme.md', icon: '📝' },
  { id: '4', name: 'photo.jpg', icon: '🖼️' },
])

function onDropInto(items: FileItem[], folder: FileItem, accepted: boolean) {
  if (!accepted) return
  files.value = files.value.filter(f => !items.some(i => i.id === f.id))
  console.log(`Moved ${items.map(i => i.name).join(', ')} to ${folder.name}`)
}
</script>

<template>
  <TheFreeform v-model="files" @drop-into="onDropInto" class="flex flex-wrap gap-4 p-6">
    <FreeformItem v-for="file in files" :key="file.id" :item="file">
      <template #default="{ selected, dropTarget, dropAccepted }">
        <div
          class="flex flex-col items-center p-4 rounded-lg cursor-pointer"
          :class="{
            'bg-blue-100 ring-2 ring-blue-500': selected,
            'bg-green-100 ring-2 ring-green-500': dropTarget && dropAccepted,
            'bg-red-100 ring-2 ring-red-500': dropTarget && !dropAccepted,
          }"
        >
          <span class="text-4xl">{{ file.icon }}</span>
          <span class="mt-2 text-sm">{{ file.name }}</span>
        </div>
      </template>
    </FreeformItem>
    <FreeformPlaceholder />
  </TheFreeform>
</template>

With Lasso Selection

<script setup>
const items = ref([
  { id: '1', name: 'Item 1' },
  { id: '2', name: 'Item 2' },
  { id: '3', name: 'Item 3' },
])

const selected = ref([])

function onSelect(items) {
  selected.value = items
}
</script>

<template>
  <FreeformSelection @select="onSelect">
    <TheFreeform v-model="items" class="flex flex-wrap gap-3 p-4 min-h-[300px]">
      <FreeformItem v-for="item in items" :key="item.id" :item="item" />
      <FreeformPlaceholder />
    </TheFreeform>

    <template #lasso="{ selectedCount }">
      <div class="border border-blue-500 bg-blue-500/10 rounded relative">
        <span
          v-if="selectedCount"
          class="absolute -top-2 -right-2 bg-blue-500 text-white text-xs rounded-full px-2"
        >
          {{ selectedCount }}
        </span>
      </div>
    </template>
  </FreeformSelection>
</template>

Custom Accept Function

Prevent certain drops (e.g., folders into folders):

<script setup>
const items = ref([
  { id: '1', name: 'Folder', type: 'container' },
  { id: '2', name: 'File.txt' },
])

// Only accept non-container items
function acceptFiles(draggedItems) {
  return draggedItems.every(item => item.type !== 'container')
}
</script>

<template>
  <TheFreeform v-model="items">
    <FreeformItem
      v-for="item in items"
      :key="item.id"
      :item="item"
      :accept="item.type === 'container' ? acceptFiles : undefined"
    />
    <FreeformPlaceholder />
  </TheFreeform>
</template>

Custom Ghost

<TheFreeform v-model="items">
  <FreeformItem v-for="item in items" :key="item.id" :item="item" />
  <FreeformPlaceholder />

  <template #drag-ghost="{ items, count }">
    <div class="bg-white shadow-xl rounded-lg p-4 flex items-center gap-3">
      <span class="text-2xl">{{ items[0]?.icon }}</span>
      <div>
        <div class="font-medium">{{ items[0]?.name }}</div>
        <div v-if="count > 1" class="text-sm text-gray-500">
          +{{ count - 1 }} more
        </div>
      </div>
    </div>
  </template>
</TheFreeform>

CSS Variables

Customize the default styling with CSS variables:

.my-freeform {
  /* Primary color (selection, placeholder) */
  --freeform-color-primary: #3b82f6;
  --freeform-color-primary-light: #dbeafe;

  /* Success color (drop accepted) */
  --freeform-color-success: #22c55e;
  --freeform-color-success-light: #dcfce7;

  /* Danger color (drop rejected) */
  --freeform-color-danger: #ef4444;
  --freeform-color-danger-light: #fee2e2;

  /* Neutral colors */
  --freeform-color-neutral: #f3f4f6;
  --freeform-color-text: #374151;
}

Events

EventPayloadDescription
update:modelValueitems[]Items array changed (reorder)
selectitems[]Selection changed
drag-startitems[]Drag operation started
drag-moveitems[], positionDragging (with cursor position)
drag-enditems[]Drag operation ended
drop-intoitems[], container, acceptedItems dropped into a container
drop-to-zoneitems[], zoneId, index, containerIdItems dropped to external zone
reorderfromIndex, toIndexItems reordered

TypeScript

Extend FreeformItemData with your own properties:

import type { FreeformItemData } from 'nuxt-freeform'

interface MyItem extends FreeformItemData {
  name: string
  icon: string
  size?: number
}

const items = ref<MyItem[]>([
  { id: '1', name: 'File', icon: '📄', size: 1024 }
])

Development

# Install dependencies
pnpm install

# Start playground
pnpm dev

# Build
pnpm build

# Lint
pnpm lint

# Test
pnpm test

Inspiration

This module was inspired by the Angular library ngx-explorer-dnd and brings the same desktop-like drag & drop experience to the Vue/Nuxt ecosystem.

License

MIT


Made with ♥️ by Flo0806 · Creator of nuxt.care

Support

If you like this module, give it a ⭐!

Buy Me A Coffee