Skip to content

Framework Integration

Problem

Implement framework integration in a production-friendly way with @vielzeug/dragit while keeping setup and cleanup explicit.

Runnable Example

The snippet below is copy-paste runnable in a TypeScript project with @vielzeug/dragit installed.

Use Dragit from framework components by attaching the behavior when the DOM element mounts and cleaning it up when the component unmounts.

File Drop Zone

tsx
import { useEffect, useRef, useState } from 'react';
import { createDropZone } from '@vielzeug/dragit';

interface FileDropZoneProps {
  onFiles: (files: File[]) => void;
  accept?: string[];
  disabled?: boolean;
}

export function FileDropZone({ onFiles, accept = [], disabled = false }: FileDropZoneProps) {
  const ref = useRef<HTMLDivElement>(null);
  const [hovered, setHovered] = useState(false);

  useEffect(() => {
    if (!ref.current) return;

    const zone = createDropZone({
      element: ref.current,
      accept,
      disabled: () => disabled,
      onDrop: onFiles,
      onHoverChange: setHovered,
    });

    return () => zone.destroy();
  }, [accept, disabled, onFiles]);

  return (
    <div ref={ref} className={`dropzone ${hovered ? 'drag-over' : ''}`} role="button" tabIndex={0}>
      Drop files here
    </div>
  );
}
vue
<script setup lang="ts">
import { onUnmounted, ref, watchEffect } from 'vue';
import { createDropZone, type DropZoneOptions } from '@vielzeug/dragit';

const emit = defineEmits<{ files: [File[]] }>();
const el = ref<HTMLElement | null>(null);
const hovered = ref(false);
const zone = ref<ReturnType<typeof createDropZone> | null>(null);

const options: Omit<DropZoneOptions, 'element'> = {
  accept: ['image/*'],
  onDrop: (files) => emit('files', files),
};

watchEffect(() => {
  zone.value?.destroy();
  if (!el.value) return;

  zone.value = createDropZone({
    ...options,
    element: el.value,
    onHoverChange: (value) => {
      hovered.value = value;
      options.onHoverChange?.(value);
    },
  });
});

onUnmounted(() => zone.value?.destroy());
</script>

<template>
  <div ref="el" :class="['dropzone', { 'drag-over': hovered }]">Drop images here</div>
</template>
svelte
<script lang="ts">
  import { onDestroy } from 'svelte';
  import { createDropZone } from '@vielzeug/dragit';

  export let accept: string[] = [];
  export let onFiles: (files: File[]) => void;

  let hovered = false;
  let zone: ReturnType<typeof createDropZone> | null = null;

  function init(node: HTMLDivElement) {
    zone = createDropZone({
      element: node,
      accept,
      onDrop: onFiles,
      onHoverChange: (value) => {
        hovered = value;
      },
    });

    return {
      destroy: () => zone?.destroy(),
    };
  }

  onDestroy(() => zone?.destroy());
</script>

<div use:init class:drag-over={hovered} class="dropzone">
  <slot>Drop files here</slot>
</div>

Sortable List

tsx
import { useEffect, useRef } from 'react';
import { createSortable } from '@vielzeug/dragit';

interface Item {
  id: string;
  label: string;
}

interface SortableListProps {
  items: Item[];
  onReorder: (ids: string[]) => void;
}

export function SortableList({ items, onReorder }: SortableListProps) {
  const ref = useRef<HTMLUListElement>(null);
  const sortableRef = useRef<ReturnType<typeof createSortable> | null>(null);

  useEffect(() => {
    if (!ref.current) return;

    sortableRef.current = createSortable({
      container: ref.current,
      onReorder,
    });

    return () => sortableRef.current?.destroy();
  }, [onReorder]);

  useEffect(() => {
    sortableRef.current?.refresh();
  }, [items]);

  return (
    <ul ref={ref}>
      {items.map((item) => (
        <li key={item.id} data-sort-id={item.id}>
          {item.label}
        </li>
      ))}
    </ul>
  );
}
vue
<script setup lang="ts">
import { onUnmounted, ref, watch } from 'vue';
import { createSortable, type SortableOptions } from '@vielzeug/dragit';

const props = defineProps<{ items: { id: string; label: string }[] }>();
const emit = defineEmits<{ reorder: [string[]] }>();
const container = ref<HTMLElement | null>(null);
let sortable: ReturnType<typeof createSortable> | null = null;

watch(
  container,
  (el) => {
    sortable?.destroy();
    if (!el) return;

    sortable = createSortable({
      ...({ onReorder: (ids: string[]) => emit('reorder', ids) } satisfies Omit<SortableOptions, 'container'>),
      container: el,
    });
  },
  { immediate: true },
);

watch(
  () => props.items,
  () => sortable?.refresh(),
  { deep: true },
);

onUnmounted(() => sortable?.destroy());
</script>

<template>
  <ul ref="container">
    <li v-for="item in items" :key="item.id" :data-sort-id="item.id">
      {{ item.label }}
    </li>
  </ul>
</template>
svelte
<script lang="ts">
  import { afterUpdate, onDestroy } from 'svelte';
  import { createSortable } from '@vielzeug/dragit';

  export let items: { id: string; label: string }[] = [];
  export let onReorder: (ids: string[]) => void;

  let container: HTMLUListElement;
  let sortable: ReturnType<typeof createSortable> | null = null;

  $: if (container && !sortable) {
    sortable = createSortable({ container, onReorder });
  }

  afterUpdate(() => sortable?.refresh());
  onDestroy(() => sortable?.destroy());
</script>

<ul bind:this={container}>
  {#each items as item (item.id)}
    <li data-sort-id={item.id}>{item.label}</li>
  {/each}
</ul>

For a Web Components example, see Web Component with craftit.

Expected Output

  • The example runs without type errors in a standard TypeScript setup.
  • The main flow produces the behavior described in the recipe title.

Common Pitfalls

  • Forgetting cleanup/dispose calls can leak listeners or stale state.
  • Skipping explicit typing can hide integration issues until runtime.
  • Not handling error branches makes examples harder to adapt safely.