shadimage
supabase
blockdropzone-nuxtjssource: ok · hit

Dropzone (File Upload) for Nuxt and Supabase

Displays a control for easier uploading of files directly to Supabase Storage.

npx shadcn add "https://supabase.com/ui/r/dropzone-nuxtjs.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
@supabase/supabase-js@latest@vueuse/corelucide-vue-next

Registry dependencies

button

Files4 · source cached 10/14/2026, 11:42:57 AM

registry/default/dropzone/nuxtjs/app/components/dropzone.vue
<script setup lang="ts">
import { provide, inject } from 'vue'
import { cn } from '@/lib/utils'

interface File {
  name: string
  errors: Array<{ message: string }>
  size: number
  lastModified?: number
  type?: string
  preview?: string
}

export interface DropzoneProps {
  className?: string
  isDragActive?: boolean
  isDragReject?: boolean
  isSuccess?: boolean
  maxFiles?: number
  maxFileSize?: number
  inputRef?: HTMLInputElement | null
  setFiles: (files: File[]) => void
  onUpload: () => Promise<void>
  loading?: boolean
  successes: string[]
  errors: Array<{ name: string; message: string }>
  files: File[]
  getRootProps: (options?: Record<string, unknown>) => Record<string, unknown>
  getInputProps: () => Record<string, unknown>
}

export const formatBytes = (
  bytes: number,
  decimals = 2,
  size?: 'bytes' | 'KB' | 'MB' | 'GB' | 'TB' | 'PB' | 'EB' | 'ZB' | 'YB'
) => {
  const k = 1000
  const dm = decimals < 0 ? 0 : decimals
  const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']

  if (!bytes || bytes < 0) return size ? `0 ${size}` : '0 bytes'

  const i = size
    ? Math.max(0, sizes.indexOf(size))
    : Math.max(0, Math.min(sizes.length - 1, Math.floor(Math.log(bytes) / Math.log(k))))

  return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}

const DropzoneContext = Symbol('DropzoneContext')

export function useDropzoneContext() {
  const ctx = inject<DropzoneProps>(DropzoneContext)
  if (!ctx) throw new Error('useDropzoneContext must be used within <Dropzone>')
  return ctx
}

const props = defineProps<DropzoneProps>()

provide(DropzoneContext, props)
</script>

<template>
  <div
    v-bind="
      props.getRootProps({
        class: cn(
          'border-2 border-gray-300 rounded-lg p-6 text-center bg-card transition-colors duration-300 text-foreground',
          props.className,
          props.isSuccess ? 'border-solid' : 'border-dashed',
          props.isDragActive && 'border-primary bg-primary/10',
          ((props.isDragActive && props.isDragReject) ||
            (props.errors.length > 0 && !props.isSuccess) ||
            props.files.some((f) => f.errors.length !== 0)) &&
            'border-destructive bg-destructive/10'
        ),
      })
    "
  >
    <input v-bind="props.getInputProps()" />
    <slot />
  </div>
</template>
registry/default/dropzone/nuxtjs/app/components/dropzone-empty-state.vue
<script setup lang="ts">
import { Upload } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import { useDropzoneContext, formatBytes } from './dropzone.vue'

const props = defineProps<{ className?: string }>()

const { maxFiles, maxFileSize, inputRef, isSuccess } = useDropzoneContext()
</script>

<template>
  <div v-if="!isSuccess" :class="cn('flex flex-col items-center gap-y-2', props.className)">
    <Upload size="20" class="text-muted-foreground" aria-hidden="true" />

    <p class="text-sm">
      Upload{{ maxFiles > 1 ? ` ${maxFiles}` : '' }} file{{ maxFiles !== 1 ? 's' : '' }}
    </p>

    <div class="flex flex-col items-center gap-y-1">
      <p class="text-xs text-muted-foreground">
        Drag and drop or
        <button
          type="button"
          class="underline cursor-pointer hover:text-foreground"
          @click="inputRef?.click()"
        >
          select file{{ maxFiles !== 1 ? 's' : '' }}
        </button>
        to upload
      </p>

      <p v-if="maxFileSize !== Infinity" class="text-xs text-muted-foreground">
        Maximum file size: {{ formatBytes(maxFileSize) }}
      </p>
    </div>
  </div>
</template>
registry/default/dropzone/nuxtjs/app/components/dropzone-content.vue
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { CheckCircle, File, Loader2, X } from 'lucide-vue-next'
import { formatBytes, useDropzoneContext } from './dropzone.vue'
import { computed } from 'vue';

const props = defineProps<{ className?: string }>()

const {
  files,
  setFiles,
  onUpload,
  loading,
  successes,
  errors,
  maxFileSize,
  maxFiles,
  isSuccess,
} = useDropzoneContext()

const exceedMaxFiles = computed(() => files.length > maxFiles)

const fileErrorMessages = computed(() => new Map(errors.map(e => [e.name, e.message])))

function formatFileErrors(file: typeof files[number]) {
  if (!file.errors || file.errors.length === 0) return ''
  return file.errors
    .map(e =>
      e.message.startsWith('File is larger than')
        ? `File is larger than ${formatBytes(maxFileSize)} (Size: ${formatBytes(file.size)})`
        : e.message
    )
    .join(', ')
}

function handleRemoveFile(filename: string) {
  setFiles(files.filter(f => f.name !== filename))
}
</script>

<template>
  <div :class="cn('flex flex-col', props.className)">
    <!-- Success State -->
    <div v-if="isSuccess" class="flex flex-row items-center gap-x-2 justify-center" role="status" aria-live="polite">
      <CheckCircle size="16" class="text-primary" aria-hidden="true"/>
      <p class="text-primary text-sm">
        Successfully uploaded {{ files.length }} file{{ files.length > 1 ? 's' : '' }}
      </p>
    </div>

    <!-- File list -->
    <template v-else>
      <div
        v-for="(file, idx) in files"
        :key="file?.name + '-' + file?.lastModified + '-' + file?.size || file?.name + '-' + idx"
        class="flex items-center gap-x-4 border-b py-2 first:mt-4 last:mb-4"
      >
        <div v-if="file.type.startsWith('image/')"
             class="h-10 w-10 rounded border overflow-hidden shrink-0 bg-muted flex items-center justify-center">
          <img :src="file.preview" :alt="file.name" class="object-cover" />
        </div>

        <div v-else class="h-10 w-10 rounded border bg-muted flex items-center justify-center">
          <File size="18" aria-hidden="true" />
        </div>

        <div class="shrink grow flex flex-col items-start truncate">
          <p class="text-sm truncate max-w-full" :title="file.name">
            {{ file.name }}
          </p>

          <!-- ARIA live region for status messages -->
          <div aria-live="polite" aria-atomic="true">
            <!-- Errors -->
            <p v-if="file.errors.length > 0" class="text-xs text-destructive">
              {{ formatFileErrors(file) }}
            </p>

            <!-- Uploading -->
            <p v-else-if="loading" class="text-xs text-muted-foreground">Uploading file...</p>

            <!-- Failed -->
            <p v-else-if="fileErrorMessages.has(file.name)" class="text-xs text-destructive">
              Failed to upload: {{ fileErrorMessages.get(file.name) }}
            </p>

            <!-- Success -->
            <p v-else-if="successes.includes(file.name)" class="text-xs text-primary">
              Successfully uploaded file
            </p>

            <!-- Normal -->
            <p v-else class="text-xs text-muted-foreground">
              {{ formatBytes(file.size) }}
            </p>
          </div>
        </div>

        <Button
          v-if="!loading && !successes.includes(file.name)"
          aria-label="Remove file Button"
          size="icon"
          variant="link"
          class="shrink-0 text-muted-foreground hover:text-foreground"
          @click="handleRemoveFile(file.name)"
        >
          <X />
        </Button>
      </div>

      <!-- Too many files -->
      <p v-if="exceedMaxFiles" class="text-sm text-left mt-2 text-destructive">
        You may upload only up to {{ maxFiles }} files, please remove
        {{ files.length - maxFiles }} file(s).
      </p>

      <!-- Upload button -->
      <div v-if="files.length > 0 && !exceedMaxFiles" class="mt-2">
        <Button
          variant="outline"
          :disabled="files.some(f => f.errors.length) || loading"
          @click="onUpload"
        >
          <Loader2 v-if="loading" class="mr-2 h-4 w-4 animate-spin" />
          <template v-if="loading">Uploading...</template>
          <template v-else>Upload files</template>
        </Button>
      </div>
    </template>
  </div>
</template>
registry/default/dropzone/nuxtjs/app/composables/useSupabaseUpload.ts
import { useDropZone } from '@vueuse/core'
import { computed, onUnmounted, ref, watch } from 'vue'

// @ts-ignore
import { createClient } from '@/lib/supabase/client'

const supabase = createClient()

export interface FileWithPreview extends File {
  preview?: string
  errors: { code: string; message: string }[]
}

export type UseSupabaseUploadOptions = {
  bucketName: string
  path?: string
  allowedMimeTypes?: string[]
  maxFileSize?: number
  maxFiles?: number
  cacheControl?: number
  upsert?: boolean
}

function validateFileType(file: File, allowedTypes: string[]) {
  if (!allowedTypes.length) return []
  const isValid = allowedTypes.some((t) =>
    t.endsWith('/*') ? file.type.startsWith(t.replace('/*', '')) : file.type === t
  )
  return isValid ? [] : [{ code: 'invalid-type', message: 'Invalid file type' }]
}

function validateFileSize(file: File, maxSize: number) {
  return file.size > maxSize
    ? [{ code: 'file-too-large', message: `File is larger than allowed size` }]
    : []
}

export function useSupabaseUpload(options: UseSupabaseUploadOptions) {
  const {
    bucketName,
    path,
    allowedMimeTypes = [],
    maxFileSize = Number.POSITIVE_INFINITY,
    maxFiles = 1,
    cacheControl = 3600,
    upsert = false,
  } = options

  const files = ref<FileWithPreview[]>([])
  const loading = ref(false)
  const errors = ref<{ name: string; message: string }[]>([])
  const successes = ref<string[]>([])

  const isSuccess = computed(() => {
    if (!errors.value.length && !successes.value.length) return false
    return !errors.value.length && successes.value.length === files.value.length
  })

  const dropZoneRef = ref<HTMLElement | null>(null)

  const { isOverDropZone } = useDropZone(dropZoneRef, {
    onDrop(droppedFiles: File[] | null) {
      if (!droppedFiles) return

      const newFiles: FileWithPreview[] = droppedFiles.map((file) => ({
        ...(file as FileWithPreview),
        preview: URL.createObjectURL(file),
        errors: [
          ...validateFileType(file, allowedMimeTypes),
          ...validateFileSize(file, maxFileSize),
        ],
      }))

      files.value = [...files.value, ...newFiles]
    },
  })

  const onUpload = async () => {
    loading.value = true

    try {
      const filesWithErrors = errors.value.map((e) => e.name)

      const filesToUpload =
        filesWithErrors.length > 0
          ? files.value.filter(
              (f) => filesWithErrors.includes(f.name) || !successes.value.includes(f.name)
            )
          : files.value

      const responses = await Promise.all(
        filesToUpload.map(async (file) => {
          const { error } = await supabase.storage
            .from(bucketName)
            .upload(path ? `${path}/${file.name}` : file.name, file, {
              cacheControl: cacheControl.toString(),
              upsert,
            })

          return error
            ? { name: file.name, message: error.message }
            : { name: file.name, message: undefined }
        })
      )

      errors.value = responses.filter(
        (r): r is { name: string; message: string } => r.message !== undefined
      )

      const successful = responses.filter((r) => !r.message).map((r) => r.name)

      successes.value = Array.from(new Set([...successes.value, ...successful]))
    } catch (err) {
      console.error('Upload failed unexpectedly:', err)

      errors.value.push({
        name: 'upload',
        message: 'An unexpected error occurred during upload.',
      })
    } finally {
      loading.value = false
    }
  }

  watch(
    () => files.value.length,
    () => {
      if (!files.value.length) {
        errors.value = []
        successes.value = []
      }

      if (files.value.length > maxFiles) {
        errors.value.push({
          name: 'files',
          message: `You may upload up to ${maxFiles} files`,
        })
      }
    }
  )

  watch(
    files,
    (newFiles, oldFiles) => {
      const newPreviews = new Set(newFiles.map((f) => f.preview))
      oldFiles.forEach((file) => {
        if (file.preview && !newPreviews.has(file.preview)) {
          URL.revokeObjectURL(file.preview)
        }
      })
    },
    { deep: true }
  )

  onUnmounted(() => {
    files.value.forEach((file) => {
      if (file.preview) {
        URL.revokeObjectURL(file.preview)
      }
    })
  })

  return {
    dropZoneRef,
    isOverDropZone,

    files,
    setFiles: (v: FileWithPreview[]) => (files.value = v),

    errors,
    setErrors: (v: { name: string; message: string }[]) => (errors.value = v),

    successes,
    isSuccess,
    loading,
    onUpload,

    maxFileSize,
    maxFiles,
    allowedMimeTypes,
  }
}

More from supabase

Browse all components in this library →