npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

nuxt-freeform

v0.1.0-alpha.6

Published

Desktop-like drag & drop for Nuxt - Lasso selection, grid layout, drop zones, fully customizable

Downloads

688

Readme

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.

Freeform

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

Lists

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

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 |

Drag Handle

By default, the entire item is draggable. Add data-freeform-handle to restrict dragging to a specific element:

<FreeformItem :item="item">
  <template #default="{ dragging }">
    <div class="flex items-center gap-2">
      <span data-freeform-handle class="cursor-grab">⠿</span>
      <span>{{ item.id }}</span>
    </div>
  </template>
</FreeformItem>

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