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.
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
...
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
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>
)
}
EditPanel
uses the ResizablePanel
and ResizablePanelGroup
components from shadcn/ui
to create resizable and collapsible panels.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.
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
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/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
/>
)
}
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.
That’s it! 🎉
💎**
🧑💻
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:
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 𝕏.