├── .eslintrc.json ├── .gitignore ├── README.md ├── components.json ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.js ├── prettier.config.js ├── public ├── next.svg └── vercel.svg ├── src ├── app │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ └── page.tsx ├── components │ ├── image-uploader.tsx │ └── ui │ │ ├── button.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ └── sonner.tsx └── lib │ └── utils.ts ├── tailwind.config.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Simple Image Uploader 2 | 3 | [shadcn/ui](https://ui.shadcn.com/) is an amazing ui library but doesn't have an image uploader component. So I created one! 4 | 5 | It has image validation built in and customizable styles with [tailwindcss](https://tailwindcss.com/) 6 | 7 | The image is validated inside a shadcn Form using zod, react-drop-zone and react-hook-form. 8 | 9 | ### Caveats 10 | 11 | - This component only works on client components and should not be used directly from a Nextjs page or layout component 12 | - You need to be on NodeJs 20.x to use the File Interface which zod uses to verify the user uploads an image. If you can't update to Node 20.x you can skip that validation and use the native browser validation using required on the Input component. 13 | 14 | ### Demo 15 | 16 | https://simple-image-uploader-bice.vercel.app/ 17 | 18 | ### Svelte version 19 | 20 | https://svelte-image-uploader.vercel.app/ 21 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-image-uploader", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@hookform/resolvers": "^3.3.4", 13 | "@radix-ui/react-label": "^2.0.2", 14 | "@radix-ui/react-slot": "^1.0.2", 15 | "@vercel/analytics": "^1.3.1", 16 | "class-variance-authority": "^0.7.0", 17 | "clsx": "^2.1.0", 18 | "lucide-react": "^0.363.0", 19 | "next": "14.1.3", 20 | "next-themes": "^0.3.0", 21 | "react": "^18", 22 | "react-dom": "^18", 23 | "react-dropzone": "^14.2.3", 24 | "react-hook-form": "^7.51.1", 25 | "sonner": "^1.4.41", 26 | "tailwind-merge": "^2.2.2", 27 | "tailwindcss-animate": "^1.0.7", 28 | "zod": "^3.22.4" 29 | }, 30 | "devDependencies": { 31 | "@types/node": "^20", 32 | "@types/react": "^18", 33 | "@types/react-dom": "^18", 34 | "autoprefixer": "^10.0.1", 35 | "eslint": "^8", 36 | "eslint-config-next": "14.1.3", 37 | "postcss": "^8", 38 | "prettier": "^3.2.5", 39 | "prettier-plugin-tailwindcss": "^0.5.12", 40 | "tailwindcss": "^3.3.0", 41 | "typescript": "^5" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ["prettier-plugin-tailwindcss"], 3 | }; 4 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cheveniko/simple-image-uploader/47cb9eadbe743dcc3a6a593e9146e8365ea4d512/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 222.2 84% 4.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 84% 4.9%; 15 | 16 | --primary: 222.2 47.4% 11.2%; 17 | --primary-foreground: 210 40% 98%; 18 | 19 | --secondary: 210 40% 96.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | 22 | --muted: 210 40% 96.1%; 23 | --muted-foreground: 215.4 16.3% 46.9%; 24 | 25 | --accent: 210 40% 96.1%; 26 | --accent-foreground: 222.2 47.4% 11.2%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 210 40% 98%; 30 | 31 | --border: 214.3 31.8% 91.4%; 32 | --input: 214.3 31.8% 91.4%; 33 | --ring: 222.2 84% 4.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | 42 | --card: 222.2 84% 4.9%; 43 | --card-foreground: 210 40% 98%; 44 | 45 | --popover: 222.2 84% 4.9%; 46 | --popover-foreground: 210 40% 98%; 47 | 48 | --primary: 210 40% 98%; 49 | --primary-foreground: 222.2 47.4% 11.2%; 50 | 51 | --secondary: 217.2 32.6% 17.5%; 52 | --secondary-foreground: 210 40% 98%; 53 | 54 | --muted: 217.2 32.6% 17.5%; 55 | --muted-foreground: 215 20.2% 65.1%; 56 | 57 | --accent: 217.2 32.6% 17.5%; 58 | --accent-foreground: 210 40% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 210 40% 98%; 62 | 63 | --border: 217.2 32.6% 17.5%; 64 | --input: 217.2 32.6% 17.5%; 65 | --ring: 212.7 26.8% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import { Analytics } from "@vercel/analytics/react"; 4 | import "./globals.css"; 5 | import { Toaster } from "sonner"; 6 | 7 | const inter = Inter({ subsets: ["latin"] }); 8 | 9 | export const metadata: Metadata = { 10 | title: "Simple Image Uploader", 11 | description: 12 | "shadcn inpired image uploader component built with shadcn/ui, react-drop-zone and zod", 13 | }; 14 | 15 | export default function RootLayout({ 16 | children, 17 | }: Readonly<{ 18 | children: React.ReactNode; 19 | }>) { 20 | return ( 21 | 22 | 23 | {children} 24 | 25 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { ImageUploader } from "@/components/image-uploader"; 2 | 3 | export default function Home() { 4 | return ( 5 |
6 |
7 |

8 | Simple Image Uploader 9 |

10 |

11 | Image uploader component built with{" "} 12 | 17 | shadcn/ui 18 | {" "} 19 | 24 | react-dropzone 25 | {" "} 26 | and{" "} 27 | 32 | zod 33 | 34 |

35 | 36 |
37 |
38 |

39 | Built by{" "} 40 | 45 | @Cheveniko 🇪🇨 46 | 47 |

48 | 53 | Svelte version 54 | 60 | 64 | 68 | 69 | 70 | 75 | Code 76 | 77 | 78 | 79 | 80 |
81 |
82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /src/components/image-uploader.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | "use client"; 3 | import React from "react"; 4 | import { useForm } from "react-hook-form"; 5 | import { zodResolver } from "@hookform/resolvers/zod"; 6 | import { useDropzone } from "react-dropzone"; 7 | import { z } from "zod"; 8 | import { Input } from "./ui/input"; 9 | import { 10 | Form, 11 | FormControl, 12 | FormField, 13 | FormItem, 14 | FormLabel, 15 | FormMessage, 16 | } from "./ui/form"; 17 | import { Button } from "./ui/button"; 18 | import { ImagePlus } from "lucide-react"; 19 | import { toast } from "sonner"; 20 | 21 | export const ImageUploader: React.FC = () => { 22 | const [preview, setPreview] = React.useState(""); 23 | 24 | const formSchema = z.object({ 25 | image: z 26 | //Rest of validations done via react dropzone 27 | .instanceof(File) 28 | .refine((file) => file.size !== 0, "Please upload an image"), 29 | }); 30 | 31 | const form = useForm>({ 32 | resolver: zodResolver(formSchema), 33 | mode: "onBlur", 34 | defaultValues: { 35 | image: new File([""], "filename"), 36 | }, 37 | }); 38 | 39 | const onDrop = React.useCallback( 40 | (acceptedFiles: File[]) => { 41 | const reader = new FileReader(); 42 | try { 43 | reader.onload = () => setPreview(reader.result); 44 | reader.readAsDataURL(acceptedFiles[0]); 45 | form.setValue("image", acceptedFiles[0]); 46 | form.clearErrors("image"); 47 | } catch (error) { 48 | setPreview(null); 49 | form.resetField("image"); 50 | } 51 | }, 52 | [form], 53 | ); 54 | 55 | const { getRootProps, getInputProps, isDragActive, fileRejections } = 56 | useDropzone({ 57 | onDrop, 58 | maxFiles: 1, 59 | maxSize: 1000000, 60 | accept: { "image/png": [], "image/jpg": [], "image/jpeg": [] }, 61 | }); 62 | 63 | const onSubmit = (values: z.infer) => { 64 | console.log(values); 65 | toast.success(`Image uploaded successfully 🎉 ${values.image.name}`); 66 | }; 67 | 68 | return ( 69 |
70 | 71 | ( 75 | 76 | 81 |

82 | Upload your image 83 | 90 |

91 |
92 | 93 |
97 | {preview && ( 98 | Uploaded image 103 | )} 104 | 107 | 108 | {isDragActive ? ( 109 |

Drop the image!

110 | ) : ( 111 |

Click here or drag an image to upload it

112 | )} 113 |
114 |
115 | 116 | {fileRejections.length !== 0 && ( 117 |

118 | Image must be less than 1MB and of type png, jpg, or jpeg 119 |

120 | )} 121 |
122 |
123 | )} 124 | /> 125 | 132 | 133 | 134 | ); 135 | }; 136 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /src/components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { Slot } from "@radix-ui/react-slot" 4 | import { 5 | Controller, 6 | ControllerProps, 7 | FieldPath, 8 | FieldValues, 9 | FormProvider, 10 | useFormContext, 11 | } from "react-hook-form" 12 | 13 | import { cn } from "@/lib/utils" 14 | import { Label } from "@/components/ui/label" 15 | 16 | const Form = FormProvider 17 | 18 | type FormFieldContextValue< 19 | TFieldValues extends FieldValues = FieldValues, 20 | TName extends FieldPath = FieldPath 21 | > = { 22 | name: TName 23 | } 24 | 25 | const FormFieldContext = React.createContext( 26 | {} as FormFieldContextValue 27 | ) 28 | 29 | const FormField = < 30 | TFieldValues extends FieldValues = FieldValues, 31 | TName extends FieldPath = FieldPath 32 | >({ 33 | ...props 34 | }: ControllerProps) => { 35 | return ( 36 | 37 | 38 | 39 | ) 40 | } 41 | 42 | const useFormField = () => { 43 | const fieldContext = React.useContext(FormFieldContext) 44 | const itemContext = React.useContext(FormItemContext) 45 | const { getFieldState, formState } = useFormContext() 46 | 47 | const fieldState = getFieldState(fieldContext.name, formState) 48 | 49 | if (!fieldContext) { 50 | throw new Error("useFormField should be used within ") 51 | } 52 | 53 | const { id } = itemContext 54 | 55 | return { 56 | id, 57 | name: fieldContext.name, 58 | formItemId: `${id}-form-item`, 59 | formDescriptionId: `${id}-form-item-description`, 60 | formMessageId: `${id}-form-item-message`, 61 | ...fieldState, 62 | } 63 | } 64 | 65 | type FormItemContextValue = { 66 | id: string 67 | } 68 | 69 | const FormItemContext = React.createContext( 70 | {} as FormItemContextValue 71 | ) 72 | 73 | const FormItem = React.forwardRef< 74 | HTMLDivElement, 75 | React.HTMLAttributes 76 | >(({ className, ...props }, ref) => { 77 | const id = React.useId() 78 | 79 | return ( 80 | 81 |
82 | 83 | ) 84 | }) 85 | FormItem.displayName = "FormItem" 86 | 87 | const FormLabel = React.forwardRef< 88 | React.ElementRef, 89 | React.ComponentPropsWithoutRef 90 | >(({ className, ...props }, ref) => { 91 | const { error, formItemId } = useFormField() 92 | 93 | return ( 94 |