Making a image picker with shadcn, zod, and react

February 19, 2024

While working on my personal project Notey, I built a feature where the user could choose a background image for each of their workspace.

In this post, we're going to recreate an image picker that looks like this:

image

It was built with the following:

  • Shadcn UI
  • Tailwindcss
  • React hook forms
  • Unsplash API
  • zod

Let's get started!

First, we need to install a package (Let's just assume that you're already working on a project with React and TailwindCSS):

npm i unsplash-js

We're going to fetch random images from unsplash. Although the images a free to use, we must adhere to their guidelines. I'm not going to talk about this deeply in this article. If you're planning to use unsplash in your project, please adhere to their guidelines :).

Next, we'll create a file called unsplash.ts

import { createApi } from "unsplash-js"
 
export const unsplash = createApi({
  // you can get this key in your unsplash project's dashboard
  accessKey: process.env.NEXT_PUBLIC_UNSPLASH_ACCESS_KEY!,
  fetch: fetch
})

We will be able to extract useful APIs from unsplash now.

Let's create the server action, form, and form schema. First, let's create a schema file where we will use zod to validate the form.

import * as z from "zod"
 
export const CreateWorkspaceSchema = z.object({
  name: z.string().min(1, {
    message: "Please enter a name"
  }),
  image: z.string().min(1, {
    message: "Please select a cover image"
  })
})

...and create the form. We will need to install a couple components from shadcn:

npx shadcn-ui@latest add radio-group form input

Now let's create the form:

"use client"
 
import { useForm } from "react-hook-form"
import { useEffect, useState } from "react"
import { useFormStatus } from "react-dom"
 
import Image from "next/image"
import Link from "next/link"
 
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
 
import { CreateWorkspaceSchema } from "@/schemas/schema"
import { unsplash } from "@/lib/unsplash"
 
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
 
import { Check, Loader2 } from "lucide-react"
 
 
export const WorkspaceForm = () => {
 
  const form = useForm<z.infer<typeof CreateWorkspaceSchema>>({
    resolver: zodResolver(CreateWorkspaceSchema),
    defaultValues: {
      name: "",
      image: ""
    }
  })
 
  const [unsplashImages, setUnsplashImages] = useState<Array<Record<string, any>>>([])
  const [isLoading, setIsLoading] = useState(true)
  const [selectedImage, setSelectedImage] = useState(null)
 
  const { pending } = useFormStatus()
 
  useEffect(() => {
 
    const fetchUnsplashImages = async () => {
      try {
        const result = await unsplash.photos.getRandom({
          collectionIds: ["317099"],
          count: 6
        })
 
        if (result && result.response) {
          const newUnsplashImages = (result.response as Array<Record<string, any>>)
          setUnsplashImages(newUnsplashImages)
        } else {
          console.log("Failed to retrieve images")
        }
      } catch (error) {
        console.log(error)
      } finally {
        setIsLoading(false)
      }
    }
    fetchUnsplashImages();
  }, [])
 
 
  if (isLoading) {
    return (
      <div className="flex items-center justify-center">
        <Loader2 className="text-black h-5 w-5 animate-spin" />
      </div>
    )
  }
 
  return (
    <Form {...form}>
      <form>
        <FormField
          control={form.control}
          name="image"
          render={({ field }) => (
            <>
              <RadioGroup className="grid grid-cols-3 gap-2 max-h-fit" onValueChange={field.onChange}>
                {
                  unsplashImages.map((image) => (
                    <FormItem
                      key={image.id}
                      className="aspect-video flex items-center justify-center relative group"
                    >
                      <FormControl>
                        <div className="w-full h-full">
                          <RadioGroupItem value={image.id} className="z-50 hidden relative h-full w-full rounded-none border-none" id={image.id} />
                          <FormLabel
                            htmlFor={image.id}
                            onClick={() => {
                              if (pending) return
                              setSelectedImage(image.id)
                            }}
                          >
                            <Image
                              fill
                              alt="unsplash image"
                              className="object-cover rounded-sm cursor-pointer"
                              src={image.urls.thumb}
                            />
                          </FormLabel>
                          {
                            selectedImage === image.id && (
                              <div className="absolute inset-y-0 h-full w-full bg-black/30 flex items-center justify-center rounded-sm">
                                <Check className="h-4 w-4 text-white" />
                              </div>
                            )
                          }
                          <Link
                            href={image.links.html}
                            target="_blank"
                            className="absolute bottom-0 truncate w-full text-[11px] text-white bg-black/30 px-1 py-[0.5px] opacity-0 group-hover:opacity-100 hover:underline"
                          >
                            {image.user.name}
                          </Link>
                        </div>
                      </FormControl>
                    </FormItem>
                  ))
                }
              </RadioGroup>
              <FormMessage />
            </>
          )}
        />
        {/* Form for the workspace name goes here! */}
      </form>
    </Form>
  )
}

All we have to do now is to create an onSubmit function which will call a server action, creating and storing a new workspace in our database.

I had a really hard time implementing the image picker functionality. First, I used the Input component from shadcn. I changed the type to radio. However it didn't work.

While skimming through the docs, I noticed that there was a component called radio-group. It solved the issue I was having for 3 hours right away.

Please read the unsplash guidelines carefully when using their APIs!