Skip to content

Flo0806/nuxt-freeform

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

43 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

nuxt-freeform

npm version npm downloads License Nuxt GitHub stars nuxt.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>
Prop Type Default Description
modelValue FreeformItemData[] required Items array (v-model)
disabled boolean false Disable all interactions
manualReorder boolean false Don'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>
Prop Type Default Description
item FreeformItemData required Item data
disabled boolean false Disable dragging for this item
asDropZone boolean false Force this item to be a drop target
accept (items) => boolean - Validate if drop is allowed

Slot Props:

Prop Type Description
item object The item data
selected boolean Item is selected
dragging boolean Item is being dragged
dropTarget boolean Item is a drop target (hovering)
dropAccepted boolean Drop 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>
Prop Type Default Description
id string auto-generated Unique zone identifier
accept (items) => boolean - Validate if drop is allowed

Slot Props:

Prop Type Description
isOver boolean Items are being dragged over this zone
isAccepted boolean Drop 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

Event Payload Description
update:modelValue items[] Items array changed (reorder)
select items[] Selection changed
drag-start items[] Drag operation started
drag-move items[], position Dragging (with cursor position)
drag-end items[] Drag operation ended
drop-into items[], container, accepted Items dropped into a container
drop-to-zone items[], zoneId, index, containerId Items dropped to external zone
reorder fromIndex, toIndex Items 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

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Sponsor this project

Packages

 
 
 

Languages