paint-brush
Building a Logo Creation App with Next.js, Shadcn/ui, and Lucide Reactby@ljaviertovar
165 reads

Building a Logo Creation App with Next.js, Shadcn/ui, and Lucide React

by L Javier TovarAugust 15th, 2024
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

In this tutorial, we’ll develop a basic app for creating logos using Next.js, shadcn/ui,html2canvas-pro, and Lucide React icons. We will assume you have a basic understanding of Next.JS and shadCN/ui components.
featured image - Building a Logo Creation App with Next.js, Shadcn/ui, and Lucide React
L Javier Tovar HackerNoon profile picture


In this tutorial, we’ll develop a basic app for creating logos using Next.js, shadcn/ui, html2canvas-pro, and Lucide React icons. We’ll assume you have a basic understanding of Next.js and shadcn/ui components, as detailed explanations can be found in their respective documentation. Our focus will be on implementing the core features of the logo builder app.

Setting Up

We will create a new Next.js project with TypeScript and /src folder and Shadcn/ui, you can use the configuration of your choice.


pnpm create next-app@latest my-app --typescript --tailwind --eslint


We install the dependencies that we will need in the project:


pnpm install html2canvas-pro react-best-gradient-color-picker lucide-react



Now, let's install Shadcn/ui components:


pnpm dlx shadcn-ui@latest add button dialog resizable scroll-area slider tooltip


After that, we will create the following structure for the project:


...
└── 📁src
    └── 📁app
        └── favicon.ico
        └── globals.css
        └── layout.tsx
        └── page.tsx
    └── 📁components
        └── 📁background-controller
            └── color-picker.tsx
            └── index.tsx
        └── edit-panel.tsx
        └── header.tsx
        └── 📁icon-controller
            └── color-picker.tsx
            └── custom-icon.tsx
            └── icon-list.tsx
            └── icons-modal.tsx
            └── index.tsx
        └── mobile-nav.tsx
        └── preview.tsx
        └── side-nav.tsx
        └── 📁ui
            └── button.tsx
            └── dialog.tsx
            └── resizable.tsx
            └── scroll-area.tsx
            └── slider.tsx
            └── tooltip.tsx
    └── 📁constants
        └── icons.ts
    └── 📁context
        └── index.tsx
    └── 📁lib
        └── utils.ts
    └── 📁styles
        └── patterns.module.css
    └── types.d.tsx
...

Building the App

Creating the context

First, let’s create a context to manage the state related to icon properties and the background.


types.d.ts :

export interface ContextApp {
 icon: string
 iconSize: number
 iconColor: string
 iconBorderWidth: number
 iconRotate: number
 bgRounded: number
 bgPadding: number
 bgColor: string
}


context/index.ts :

'use client'

import { ReactNode, createContext, useContext, useEffect, useState } from 'react' // Import necessary dependencies from React
// Import the ContextApp type from the types file
import { ContextApp } from '@/types.d' 

// Initial state with default values
const initialState = {
  icon: 'Shell',
  iconSize: 420,
  iconColor: '#000000',
  iconBorderWidth: 1,
  iconRotate: 0,
  bgRounded: 250,
  bgPadding: 0,
  bgColor: '#f59e0b',
}

// Create the context with an empty object and the setValues function typed
const AppContext = createContext({} as { values: ContextApp; setValues: (values: ContextApp) => void })

// Context provider component
export function AppWrapper({ children }: { children: ReactNode }) {
  // State to store the context values
  const [values, setValues] = useState({} as ContextApp)

  useEffect(() => {
    // Try to get saved values from localStorage and set them to state
    const saved = localStorage.getItem('logobuilder')
    if (saved) {
      setValues(JSON.parse(saved)) 
    } else {
      setValues(initialState) // If no saved values, set the initial state
    }
  }, [])

  // Provide the values and setValues function to the children components
  return <AppContext.Provider value={{ values, setValues }}>{children}</AppContext.Provider>
}

// Custom hook to use the app context
export function useAppContext() {
  return useContext(AppContext)
}


This component establishes a global context using React’s Context API to manage state related to icon properties and background settings. User configurations are saved in localStorage to persist their progress.

Building the layout


app/page.tsx :

import { cookies } from 'next/headers' // Import cookies from Next.js headers

...

export default function HomePage() {
 // Retrieve the layout and collapsed state from cookies
 const layout = cookies().get('react-resizable-panels:layout')
 const collapsed = cookies().get('react-resizable-panels:collapsed')

 // Parse the layout and collapsed values from cookies, default to undefined if not present
 const defaultLayout = layout ? JSON.parse(layout.value) : undefined
 const defaultCollapsed = collapsed ? JSON.parse(collapsed.value) : undefined

 return (
  <div className='flex flex-col'>
   <EditPanel
    defaultLayout={defaultLayout}
    defaultCollapsed={defaultCollapsed}
    navCollapsedSize={4}
   />
  </div>
 )
}


The HomePage component utilizes cookies to persist the layout state and collapsed state of the resizable panels in the EditPanel.

The component retrieves cookies for the layout (react-resizable-panels:layout) and the collapsed state (react-resizable-panels:collapsed) using cookies().get() from Next.js.


components/edit-panel.tsx :

'use client'

/* all imports */

interface Props {
 defaultLayout: number[] | undefined
 defaultCollapsed?: boolean
 navCollapsedSize: number
}

export default function EditPanel({ defaultLayout = [20, 80], defaultCollapsed = false, navCollapsedSize }: Props) {
 // State to track if the side navigation is collapsed
 const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)
 // State to track the currently selected option (Icon or Background)
 const [selectedOption, setSelectedOption] = useState('Icon')

 // Access global context values
 const { values } = useAppContext()

 // Effect to save the current values to localStorage whenever they change
 useEffect(() => {
  if (Object.keys(values).length) {
   localStorage.setItem('logobuilder', JSON.stringify({ ...values }))
  }
 }, [values])

 return (
   <div className='hidden md:block'>
    <TooltipProvider delayDuration={0}>
     <ResizablePanelGroup
      direction='horizontal'
      onLayout={(sizes: number[]) => {
       // Save layout sizes to cookies for persistence
       document.cookie = `react-resizable-panels:layout=${JSON.stringify(sizes)}`
      }}
      className='h-screen fixed items-stretch'
     >
      <ResizablePanel
       defaultSize={defaultLayout[0]}
       collapsedSize={navCollapsedSize}
       collapsible={true}
       minSize={8}
       maxSize={12}
       onCollapse={() => {
        setIsCollapsed(true)
        // Save collapsed state to cookies for persistence
        document.cookie = `react-resizable-panels:collapsed=${JSON.stringify(true)}`
       }}
       onExpand={() => {
        setIsCollapsed(false)
        // Save expanded state to cookies for persistence
        document.cookie = `react-resizable-panels:collapsed=${JSON.stringify(false)}`
       }}
       className={cn(isCollapsed && 'min-w-[72px] transition-all duration-300 ease-in-out')}
      >
       <SideNav
        isCollapsed={isCollapsed}
        setSelectedOption={setSelectedOption}
        options={[
         {
          id: 1,
          title: 'Icon',
          icon: PencilRuler,
         },
         {
          id: 2,
          title: 'Background',
          icon: Image,
         },
        ]}
       />
      </ResizablePanel>
      <ResizableHandle
       withHandle
       className='border-[1px]'
      />
      <ResizablePanel defaultSize={defaultLayout[1]}>
       <main className='w-full flex flex-col md:flex-row'>
        {selectedOption === 'Icon' && <IconController />}
        {selectedOption === 'Background' && <BackgroundController />}
        <Preview />
       </main>
      </ResizablePanel>
     </ResizablePanelGroup>
    </TooltipProvider>
   </div>
 )
}


  • The EditPanel uses the ResizablePanel and ResizablePanelGroup components from shadcn/ui to create resizable and collapsible panels.
  • When the layout or collapsed state changes, these new states are saved back to cookies, ensuring persistence across page reloads and sessions.

Icon controller

components/icon-controller.tsx

export default function IconControls() {
 const { values, setValues } = useAppContext()

 // FallbackSlider component to be used when values are not loaded yet
 const FallbackSlider = () => {
  return (
   <Slider
    defaultValue={[0]}
    min={40}
    max={500}
    step={1}
   />
  )
 }

 return (
  <ScrollArea className='h-[calc(100vh-80px)]'>
   <section className='p-4 w-full md:w-[340px] border-r-2 space-y-6'>
    <h2 className='font-semibold tracking-tight text-xl'>Customize your Icon</h2>

    {/* Icon Selection */}
    <div className='space-y-2'>
     <div className='w-full flex justify-between gap-4'>
      <label className='font-semibold leading-none tracking-tight'>Icon</label>
      <span className='text-sm text-muted-foreground'>{values.icon}</span>
     </div>
     <IconsModal />
    </div>

    {/* Icon Size Control */}
    <div className='space-y-2'>
     <div className='w-full flex justify-between gap-4'>
      <label className='font-semibold leading-none tracking-tight'>Size</label>
      <span className='text-sm text-muted-foreground'>{values.iconSize} px</span>
     </div>
     {Object.keys(values).length ? (
      <Slider
       defaultValue={[values.iconSize]}
       min={40}
       max={500}
       step={1}
       onValueChange={(value: any[]) => setValues({ ...values, iconSize: value[0] })}
      />
     ) : (
      <FallbackSlider />
     )}
    </div>

    {/* Icon Border Width Control */}
    <div className='space-y-2'>
     <div className='w-full flex justify-between gap-4'>
      <label className='font-semibold leading-none tracking-tight'>Border Width</label>
      <span className='text-sm text-muted-foreground'>{values.iconBorderWidth} px</span>
     </div>
     {Object.keys(values).length ? (
      <Slider
       defaultValue={[values.iconBorderWidth]}
       min={1}
       max={4}
       step={1}
       onValueChange={(value: any[]) => setValues({ ...values, iconBorderWidth: value[0] })}
      />
     ) : (
      <FallbackSlider />
     )}
    </div>

    {/* Icon Rotation Control */}
    <div className='space-y-2'>
     <div className='w-full flex justify-between gap-4'>
      <label className='font-semibold leading-none tracking-tight'>Rotate</label>
      <span className='text-sm text-muted-foreground'>{values.iconRotate} °</span>
     </div>
     {Object.keys(values).length ? (
      <Slider
       defaultValue={[values.iconRotate]}
       min={-180}
       max={180}
       step={1}
       onValueChange={(value: any[]) => setValues({ ...values, iconRotate: value[0] })}
      />
     ) : (
      <FallbackSlider />
     )}
    </div>

    {/* Icon Color Control */}
    <div className='space-y-2'>
     <div className='w-full flex justify-between gap-4'>
      <label className='font-semibold leading-none tracking-tight'>Color</label>
     </div>
     <ColorPickerController />
    </div>
   </section>
  </ScrollArea>
 )
}


The IconControls component provides a user interface for customizing various properties of an icon. It uses a combination of sliders, modals, and color pickers to allow users to adjust the icon's size, border width, rotation, and color.

The component also leverages a global context to manage and persist these customization values.



IconController


icon-controller/icon-list.tsx :

/* all imports */

import { iconNames } from '@/constants/icons'

interface Props {
 setOpenDialog: (value: boolean) => void 
}

export default function IconList({ setOpenDialog }: Props) {
 const [visibleIcons, setVisibleIcons] = useState<string[]>([]) 
// Reference for the IntersectionObserver
 const observer = useRef<IntersectionObserver | null>(null) 

 const { values, setValues } = useAppContext()

 // Callback for observing the last icon element
 const lastIconElementRef = useCallback(
  (node: HTMLElement | null) => {
   // Disconnect any existing observer
   if (observer.current) observer.current.disconnect() 
   // Create a new IntersectionObserver
   observer.current = new IntersectionObserver(entries => {
    // If the last icon is visible and there are more icons to load, update the visible icons
    if (entries[0].isIntersecting && visibleIcons.length < iconNames.length) {
     setVisibleIcons(prevIcons => [
      ...prevIcons,
      ...iconNames.slice(prevIcons.length, prevIcons.length + 20)
     ])
    }
   })

   // Observe the node if it exists
   if (node) observer.current.observe(node) 
  },
  [visibleIcons.length, iconNames, setVisibleIcons]
 )

 useEffect(() => {
  // Load initial set of icons
  if (iconNames.length) setVisibleIcons(iconNames.slice(0, 20))
 }, [iconNames])

 return (
  <div className='grid grid-cols-6 gap-4 py-4'>
   {visibleIcons.map((icon, index) => {
    if (visibleIcons.length === index + 1) {
     return (
      <div
       ref={lastIconElementRef}
       key={icon}
      >
       <Button
        title={icon}
        aria-label={icon}
        variant='ghost'
        className='h-9 w-9 p-1 border shadow-lg'
        onClick={() => {
         setValues({ ...values, icon })
         setOpenDialog(false)
        }}
       >
        <CustomIcon name={icon} /> 
       </Button>
      </div>
     )
    } else {
     // Render button without observer reference
     return (
      <Button
       key={icon}
       title={icon}
       aria-label={icon}
       variant='ghost'
       className='h-9 w-9 p-1 border shadow-lg'
       onClick={() => {
        setValues({ ...values, icon })
        setOpenDialog(false)
       }}
      >
       <CustomIcon name={icon} />
      </Button>
     )
    }
   })}
  </div>
 )
}


This component, IconList, allows users to select an icon from a list and load more icons as they scroll. It uses context to manage and update the selected icon and employs an Intersection Observer to load more icons when the user reaches the end of the list.


IconList


To display all the icons, I extracted their names directly from the Lucide React library and created the following component to render each icon.


icon-controller/custom-icon.tsx :

import { icons } from 'lucide-react'

interface Props {
 name: string
 color?: string
 size?: number
 strokeWidth?: number
}

export default function CustomIcon({ name, color = 'currentColor', size = 24, strokeWidth = 1 }: Props) {
 const LucideIcon = icons[name as keyof typeof icons]
 if (!LucideIcon) return null

 return (
  <LucideIcon
   size={size}
   color={color}
   strokeWidth={strokeWidth}
  />
 )
}


The customIcon component allows you to render a specific icon from the lucide-react library with customizable properties such as name, color, size, and stroke width.

Background controller

background-controller/index.tsx :

/* all imports */

export default function BackgroundController() {
 // Use the context to get and set background values
 const { values, setValues } = useAppContext()

 return (
  <ScrollArea className='h-[calc(100vh-80px)]'>
   <section className='p-4 w-full md:w-[340px] border-r-2 space-y-6'>
    <h2 className='font-semibold tracking-tight text-xl'>Customize your Background</h2>

    {/* Rounded corner slider */}
    <div className='space-y-2'>
     <div className='w-full flex justify-between gap-4'>
      <label className='font-semibold leading-none tracking-tight'>Rounded</label>
      <span className='text-sm text-muted-foreground'>{values.bgRounded} px</span>
     </div>

     {/* Slider to adjust the rounded corners of the background */}
     <Slider
      defaultValue={[values.bgRounded]}
      min={0}
      max={250}
      step={1}
      onValueChange={(value: any[]) => setValues({ ...values, bgRounded: value[0] })}
     />
    </div>

    {/* Padding slider */}
    <div className='space-y-2'>
     <div className='w-full flex justify-between gap-4'>
      <label className='font-semibold leading-none tracking-tight'>Padding</label>
      <span className='text-sm text-muted-foreground'>{values.bgPadding} px</span>
     </div>

     {/* Slider to adjust the padding of the background */}
     <Slider
      defaultValue={[values.bgPadding]}
      min={0}
      max={100}
      step={1}
      onValueChange={(value: any[]) => setValues({ ...values, bgPadding: value[0] })}
     />
    </div>

    {/* Color picker */}
    <div className='space-y-2'>
     <div className='w-full flex justify-between gap-4'>
      <label className='font-semibold leading-none tracking-tight'>Color</label>
     </div>
     
     {/* Component to pick a background color */}
     <ColorPickerController
      hidecontrols={false}
      hidepresets={false}
     />
    </div>
   </section>
  </ScrollArea>
 )
}


The BackgroundController component allows users to customize background properties, including rounded corners, padding, and color. Sliders are used to adjust these properties, and a color picker is provided for background color.


background-controller/color-picker.tsx :

/* all imports */

interface Props {
 hidecontrols?: boolean
 hidepresets?: boolean
}

export default function ColorPickerController({ hidecontrols = true, hidepresets = true }: Props) {
 // Use context to get and set the background color value
 const { values, setValues } = useAppContext()
 const [color, setColor] = useState(values.bgColor)

 // Update context value whenever the local color state changes
 useEffect(() => {
  setValues({ ...values, bgColor: color })
 }, [color])

 return (
  // Render the color picker component with current color value and change handler
  <ColorPicker
   value={color}
   onChange={setColor}
   hideControls={hidecontrols}  // Optional prop to hide/show controls
   hidePresets={hidepresets}    // Optional prop to hide/show presets
  />
 )
}


BackgroundController


Preview Section

components/preview.tsx :

/* all imports */

export default function Preview() {
 const { values } = useAppContext()
 const { icon, iconSize, iconColor, iconBorderWidth, iconRotate, bgColor, bgRounded, bgPadding } = values 

 const divRef = useRef<HTMLDivElement | null>(null)

 // Function to handle the download of the image
 const handleDownloadImage = () => {
  const downloadArea = divRef.current

  // Use html2canvas to capture the screenshot of the referenced div
  html2canvas(downloadArea as HTMLDivElement, {
   backgroundColor: null,
  }).then(canvas => {
   const pngImage = canvas.toDataURL('image/png') // Convert the canvas to a PNG image
   const downloadLink = document.createElement('a') // Create a new anchor element
   downloadLink.href = pngImage // Set the href to the PNG image URL
   downloadLink.download = 'logobuilder.png' // Set the download attribute to the desired file name
   downloadLink.click() // Programmatically click the anchor element to trigger the download
  })
 }

 return (
  <div className={`flex-1 p-4  ${Styles.patternDotsMd}`}>
   {/* Download button section */}
   <div className='w-full flex justify-end'>
    <Button
     size={'sm'}
     className='flex gap-2 items-center'
     onClick={() => handleDownloadImage()}
    >
     <Download
      strokeWidth={2}
      className='w-4 h-4'
     />
     Download
    </Button>
   </div>
   
   {/* Preview section */}
   <div className='grid place-content-center h-[calc(100%-52px)]'>
    <div className='bg-white border-2 hover:border-gray-300'>
     <div
      ref={divRef} // Attach the ref to this div
      id='download-area'
      className='h-[500px] w-[500px]'
      style={{
       padding: `${bgPadding}px`,
      }}
     >
      {/* Conditional rendering based on whether values are populated */}
      {!Object.keys(values).length ? (
       <div className='animate-pulse flex justify-center items-center h-full w-full bg-gray-200'>
        <div className='animate-pulse h-4/5 w-4/5  rounded-full bg-gray-300' />
       </div>
      ) : (
       <div
        className='w-full h-full grid place-content-center'
        style={{
         background: bgColor,
         borderRadius: `${bgRounded}px`,
         padding: `${bgPadding}px`,
        }}
       >
        <span style={{ transform: `rotate(${iconRotate}deg)` }}>
         <CustomIcon
          name={icon}
          size={iconSize}
          color={iconColor}
          strokeWidth={iconBorderWidth}
         />
        </span>
       </div>
      )}
     </div>
    </div>
   </div>
  </div>
 )
}


The Preview component displays a customizable icon within a styled area and allows downloading the preview as an image. It uses useRef to reference the DOM element of the preview area and html2canvas to capture an image of this area.

The handleDownloadImage function converts the captured content into a PNG image and downloads the file when the download button is clicked.


Preview


That’s it! 🎉


💎**Demo here**

🧑‍💻 Repo here

Conclusion

You’ve now built the basic features of a logo builder application. From here, you can expand and enhance the app with features like:

  • Adding multiple icons

  • Including image uploads

  • Providing pre-built logo templates

  • Adding text elements

  • Allowing draggable elements within the canvas

  • Adding premium features


Have fun improving and adapting this project to suit your needs!


Reference: https://www.youtube.com/watch?v=XdsZA8wSNDg&pp=ygUObG9nbyBtYWtlciBhcHA%3D


Read more:

Enhancing Password Security and Recovery with Next.js 14 and NextAuth.js


Want to connect with the Author?

Love connecting with friends all around the world on 𝕏.