Origami Moving Cubes Drawing Designer
Inspired by Steve Mould's video: Self-assembling material pops into 3D (YouTube)
Deployed on Vercel: https://cubes.hanl.in/
Real-time Transformation Preview
Create and design with a live preview, ensuring your creations come to life as you envision them.
Synced Brush Cursor
Improve precision and accuracy with a synced brush cursor that's visible in the preview.
*Persistent Brush Settings
Your preferred brush color, size, and opacity settings will remain intact even after reloading the app.
Adaptable Dark and Light Themes
Switch between dark and light themes or use your system settings by default.
Smooth State Transitions
Switch between different states with smooth animations or manually scrub through transitions for precise control.
Optimized Responsive Design
Maximizes screen real estate usage to provide an optimal design experience.
Offline support
Able to load and reload without an active network connection.
Multilanguage support
Responsive Design with CSS aspect-ratio @media query
2023-10-01.12-56-48.mp4
Next.js : React framework with server-side rendering (SSR) and static site generation (SSG).
Single component dark mode toggle
Simple i18n middleware implementation without using 3rd party libraries.
Tailwind CSS : Utility-first CSS framework.
Canvas API: Simple custom drawing implementation.
CSS animations with custom @keyframes
Progressive Web App (PWA) optimized with offline capability.
TypeScript
How to add Internationalization (i18n) to Next.js 13 App Router
Support static site generation (SSG), automatically generated as static HTML + JSON (uses getStaticProps
).
Auto-detect the user's locale by default.
Allow users to select their preferred locale and remember their preference.
Display the default locale without a URL prefix.
Allow users to manually change the locale in the URL and override the detected and saved locales.
Locale Priority
Description
URL locale (/locale)
The locale specified in the URL, e.g. /de or /en/about.
Preferred locale
The user's preferred locale, saved in a cookie.
Detected locale
The user's locale, detected using the accept-language header.
Default locale
The website's default locale.
Scenario
Routing Behavior
User visits the website for the first time and their locale is de.
The user is redirected to /de.
User visits the website and their preferred locale is fr.
The user is redirected to /fr, even if their detected locale is de.
User visits /en and their preferred locale is fr.
The user's preferred locale is changed to en, and they are redirected to /.
User visits /de and their preferred locale is fr.
The user's preferred locale is ignored, and /de is displayed.
Suppose a website has the following configuration:
Default locale: en
Supported locales: en
, de
, fr
A user visits the website for the first time and their locale is de
. The user is redirected to /de
because their detected locale is de
and there is a supported locale for that language.
The user then selects fr
as their preferred locale. The next time the user visits the website, they are redirected to /fr
because their preferred locale is fr
.
The user then visits /en
. Their preferred locale is changed to en
and they are redirected to /
. This is because the user's preferred locale has precedence over the detected locale.
npm i @formatjs/intl-localematcher negotiator
npm i -D @types/negotiator
Configure your default locale (defaultLocale
) and supported locales (locales
)
Server Components: Use getDictionary
to get the translation dictionary t
for the current locale
export const LOCALE_COOKIE = 'locale' as const
export const locales = [ 'en' , 'tw' , 'fr' , 'de' , 'ru' , 'nl' , 'hu' ] as const
export const defaultLocale = 'en' as const
export type Locale = ( typeof locales ) [ number ]
const dictionaries = locales . reduce (
( acc , locale ) => {
return {
...acc ,
[ locale ] : ( ) =>
import ( `./dictionaries/${ locale } .json` ) . then ( ( module ) => module . default ) ,
}
} ,
{ } as Record < Locale , ( ) => Promise < Record < string , string > > >
)
export const getDictionary = ( locale : Locale ) =>
dictionaries [ locale ] ?. ( ) ?? dictionaries [ defaultLocale ] ( )
Handles locale detection, redirects, and rewrites, and sets cookies if manually navigated to /defaultLocale
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { LOCALE_COOKIE , Locale , defaultLocale , locales } from '@/i18n'
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'
function getDetectedLocale ( request : NextRequest ) : string | undefined {
// Negotiator expects plain object so we need to transform headers
const languages = new Negotiator ( { headers : Object . fromEntries ( request . headers ) } )
. languages ( )
// languages = [ '*' ] // no accept-language header
// filter out '*' since match() throw an error
. filter ( ( language ) => language !== '*' )
// replace zh with tw, for example 'zh-TW' -> 'tw-TW'
. map ( ( language ) => {
return language . replace ( 'zh' , 'tw' )
} )
const locale = match ( languages , locales , defaultLocale )
return locale
}
export function middleware ( request : NextRequest ) {
// Priority
// 1. route prefix
// 2. cookie
// 3. accept-language header
// 4. default locale
let locale = request . nextUrl . pathname . split ( '/' ) [ 1 ] as string | undefined
if ( locale === defaultLocale ) {
// redirect /defaultLocale{} -> /{}, and set cookie
const response = NextResponse . redirect (
newUrl ( request . nextUrl . pathname . replace ( `/${ locale } ` , '' ) )
)
response . cookies . set ( LOCALE_COOKIE , locale , {
sameSite : 'strict' ,
maxAge : 31536000 , // 1 year
} )
return response
}
if ( locales . includes ( locale as Locale ) ) {
// render /non-defaultLocale{}, does NOT set cookie
// Frontend sets cookie on language change
return NextResponse . next ( )
}
// check cookie or accept-language header
locale =
( request . cookies . get ( LOCALE_COOKIE ) ?. value as string | undefined ) ||
getDetectedLocale ( request )
if ( locale === defaultLocale ) {
// rewrite /{} -> /defaultLocale{}
return NextResponse . rewrite ( newUrl ( `/${ locale } ${ request . nextUrl . pathname } ` ) )
}
if ( locales . includes ( locale as Locale ) ) {
// redirect /{} -> /non-defaultLocale{}
return NextResponse . redirect ( newUrl ( `/${ locale } ${ request . nextUrl . pathname } ` ) )
} else {
// broken cookie, redirect to default locale
return NextResponse . redirect ( newUrl ( `/${ defaultLocale } ${ request . nextUrl . pathname } ` ) )
}
// handle edge cases
function newUrl ( pathname : string ) {
// remove trailing slash
if ( pathname . endsWith ( '/' ) ) {
pathname = pathname . slice ( 0 , - 1 )
}
const url = request . nextUrl . clone ( )
url . pathname = pathname || '/'
return url
}
}
export const config = {
// Matcher ignoring `/_next/`, `_vercel`, `/api/` and files with a '.' like `favicon.ico`
matcher : [ '/((?!api|_next|_vercel|.*\\..*).*)' ] ,
}
Translations are stored in /src/dictionaries
as JSON files
{
"Origami Moving Cubes Drawing Designer" : " 動態立方輔助設計繪圖工具" ,
"Brush Color" : " 畫筆顏色" ,
"Brush Opacity" : " 畫筆透明度" ,
"Brush Width" : " 畫筆大小" ,
"Preview" : " 預覽" ,
"Transition" : " 轉換" ,
"Bursh Menu Toggle" : " 畫筆選單開關" ,
"Buy me a coffee" : " 請我喝杯咖啡"
}
Client Components: Use DictionaryContext
to get the translation dictionary t
for the current locale
'use client'
import { createContext } from 'react'
export const DictionaryContext = createContext < Record < string , string > > ( { } )
export function DictionaryProvider ( {
t,
children,
} : {
t : Record < string , string >
children : React . ReactNode
} ) {
return < DictionaryContext . Provider value = { t } > { children } < / DictionaryContext . Provider >
}
Move your layout.tsx
and page.tsx
files inside app/
into app/[lang]
, icons (favicon.ico
, icon.png
, apple-icon.png
) and manifest.ts
should stay in app/
.
In the root layout, use generateStaticParams
to statically generate all locale routes at build time (SSG) instead of on-demand at request time (SSR).
import { DictionaryProvider } from '@/context/i18n.context'
import '@/globals.css'
import { Locale , getDictionary , locales } from '@/i18n'
import { Analytics } from '@vercel/analytics/react'
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
export async function generateStaticParams ( ) {
return locales . map ( ( locale ) => ( { lang : locale } ) )
}
In the root layout, set up DictionaryContext
by passing the translation dictionary t
from getDictionary
to DictionaryProvider
.
export default async function RootLayout ( {
children,
params : { lang } ,
} : {
children : React . ReactNode
params : { lang : Locale }
} ) {
const t = await getDictionary ( lang )
return (
< html lang = { lang } suppressHydrationWarning >
< body className = { `bg-gray-100 dark:bg-gray-800 ${ inter . className } ` } >
< DictionaryProvider t = { t } > { children } < / DictionaryProvider >
< Analytics / >
< / body >
< / html >
)
}
You will likely need to implement your own locale selector component so it matches the styling of your site.
This example uses simple HTML <select> and <options> without any external libraries.
Set LOCALE_COOKIE
when the user chooses a new locale.
'use client'
import { LOCALE_COOKIE , defaultLocale } from '@/i18n'
import { Noto_Color_Emoji } from 'next/font/google'
import { useParams , usePathname , useRouter } from 'next/navigation'
import { useState } from 'react'
const noto_color_emoji = Noto_Color_Emoji ( {
weight : '400' ,
subsets : [ 'emoji' ] ,
// fallback: ['var(--font-noto)'],
fallback : [ 'system-ui' , 'arial' ] ,
adjustFontFallback : false ,
} )
export default function LocaleSelect ( ) {
const pathname = usePathname ( )
const params = useParams ( )
const router = useRouter ( )
const [ locale , setLocale ] = useState ( params . lang )
// { params: { lang: 'de' }, pathname: '/de' }
// { params: { lang: 'en' }, pathname: '/' }
// console.log({ params, pathname, defaultLocale, locales })
function onChange ( e : React . ChangeEvent < HTMLSelectElement > ) {
const locale = e . target . value
setLocale ( locale )
const currentLocale = params . lang
const pathnameWithoutLocale = pathname . replace ( `/${ currentLocale } ` , '' )
const pathnameWithNewLocale =
locale === defaultLocale
? pathnameWithoutLocale || '/'
: `/${ locale } ${ pathnameWithoutLocale } `
// console.log({ locale, currentLocale, pathnameWithoutLocale, pathnameWithNewLocale })
document . cookie = `${ LOCALE_COOKIE } =${ locale } ;path=/;max-age=31536000;samesite=strict;`
router . push ( pathnameWithNewLocale )
}
return (
< div className = "" >
< select
value = { locale }
onChange = { onChange }
aria-label = "Locale Select"
name = "choice"
className = { `flex w-[42px] appearance-none space-y-5 rounded-full border border-gray-300 bg-gray-100 p-1.5 px-2.5 text-lg outline-none focus:border-gray-500 dark:border-gray-600 dark:bg-gray-800 dark:focus:border-gray-400 ${ noto_color_emoji . className } ` }
>
< option value = "en" > 🇺🇸 English < / option >
< option value = "tw" > 🇹🇼 Taiwan < / option >
< option value = "de" > 🇩🇪 Deutsch < / option >
< option value = "fr" > 🇫🇷 Français < / option >
< option value = "nl" > 🇳🇱 Nederlands < / option >
< option value = "hu" > 🇭🇺 Magyar < / option >
< option value = "ru" > 🇷🇺 Русский < / option >
< / select >
< / div >
)
}
Implementing a Dark Mode Toggle to Next.js 13 App Router and TailwindCSS
Does not flash the wrong mode on page load. The website should immediately render in the light or dark mode that matches the user's system settings.
Adds or removes the dark
class for Tailwind CSS and also sets the CSS color-scheme
property for scrollbars on the <html>
element.
The toggle button allows the user to override the system settings. The toggle button has three states: Light
, System
, and Dark
, with System
as the default.
If the toggle is set to System
, the page should live update if the system settings are changed.
Theme selection is persisted through page changes and separate sessions using local storage.
Does not block rendering of the UI until the React app is hydrated and renders. Only a few lines of JavaScript that set the light/dark mode attribute on the <html>
element should block rendering.
Does not cause any terminal or browser errors. This includes errors during the server component render and the rendering of any of the React components in the browser.
The first time the light/dark mode toggle is rendered on the client, it should already be in the correct state. Since this cannot be determined on the server, the toggle component may flash briefly on the client.
Set up tailwind.config.ts
to toggle dark mode manually
https://tailwindcss.com/docs/dark-mode#toggling-dark-mode-manually
Add suppressHydrationWarning
to the <html>
element
This property only applies one level deep to just the <html>
element, so it won't block hydration warnings on other elements.
The HydrationWarning
is caused by the few lines of JavaScript that add the dark
class to the <html>
element according to client settings before first rendering. This is used to prevent the wrong theme from flashing on page load.
< html lang = { lang } suppressHydrationWarning >
Modify the styling to match your site.
This example is styled to match the toggle on the Official Next.js website , which is located in the bottom right corner.
All styling is done using Tailwind CSS classes and inline SVG for easy modification.
'use client'
import { useEffect , useState } from 'react'
// localStorage.getItem("isDarkMode") === null
// localStorage.getItem("isDarkMode") === 'true'
// localStorage.getItem("isDarkMode") === 'false'
// localStorage.removeItem("isDarkMode")
// localStorage.setItem("isDarkMode", 'true')
// localStorage.setItem("isDarkMode", 'false')
const toggleDarkClass = ( isDark : boolean ) => {
if ( isDark ) {
document . documentElement . classList . add ( 'dark' )
document . documentElement . style . colorScheme = 'dark'
} else {
document . documentElement . classList . remove ( 'dark' )
document . documentElement . style . colorScheme = ''
}
}
function DarkModeToggle ( ) {
const [ isDarkMode , setIsDarkMode ] = useState < string | null > (
global . localStorage ?. getItem ( 'isDarkMode' ) || null
)
useEffect ( ( ) => {
if ( isDarkMode === null ) {
localStorage . removeItem ( 'isDarkMode' )
// check system preference
const darkModeQuery = window . matchMedia ( '(prefers-color-scheme: dark)' )
const listener = ( e : MediaQueryListEvent | MediaQueryList ) => {
toggleDarkClass ( e . matches )
}
listener ( darkModeQuery )
darkModeQuery . addEventListener ( 'change' , listener )
return ( ) => {
darkModeQuery . removeEventListener ( 'change' , listener )
}
} else {
localStorage . setItem ( 'isDarkMode' , isDarkMode )
toggleDarkClass ( isDarkMode === 'true' )
}
} , [ isDarkMode ] )
const toggleDarkMode = ( ) => {
if ( isDarkMode === 'false' ) {
setIsDarkMode ( null )
} else if ( isDarkMode === 'true' ) {
setIsDarkMode ( 'false' )
} else if ( isDarkMode === null ) {
setIsDarkMode ( 'true' )
}
}
const setDarkMode = ( e : React . ChangeEvent < HTMLInputElement > ) => {
if ( e . target . value === 'system' ) {
setIsDarkMode ( null )
} else if ( e . target . value === 'light' ) {
setIsDarkMode ( 'false' )
} else if ( e . target . value === 'dark' ) {
setIsDarkMode ( 'true' )
}
}
const buttonText = ( ) => {
switch ( isDarkMode ) {
case 'true' :
return 'Dark'
case null :
return 'System'
case 'false' :
return 'Light'
}
}
const [ mounted , setMounted ] = useState ( false )
useEffect ( ( ) => {
setMounted ( true )
} , [ ] )
if ( ! mounted ) {
return (
< >
< script
dangerouslySetInnerHTML = { {
__html : `
if (
localStorage.getItem('isDarkMode') === 'true' ||
(localStorage.getItem('isDarkMode') === null &&
window.matchMedia('(prefers-color-scheme: dark)').matches)
) {
document.documentElement.classList.add('dark')
document.documentElement.style.colorScheme = 'dark'
} else {
document.documentElement.classList.remove('dark')
document.documentElement.style.colorScheme = ''
}
` ,
} }
> < / script >
< button className = "p-2" >
< div className = "w-16" > < / div >
< / button >
< div className = "rounded-full border border-gray-300 p-1 dark:border-gray-600" >
< div className = "h-8 w-24" > < / div >
< / div >
< / >
)
} else {
return (
< >
< div className = "flex rounded-full border border-gray-300 p-1 dark:border-gray-600" >
< input
type = "radio"
name = "theme-toggle"
value = "light"
id = "theme-toggle-light"
aria-label = "Switch to light theme"
className = "peer/light appearance-none"
checked = { isDarkMode === 'false' }
onChange = { setDarkMode }
> < / input >
< label
htmlFor = "theme-toggle-light"
className = "gray-100 flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-0 text-gray-500 hover:text-gray-900 peer-checked/light:bg-gray-200 peer-checked/light:text-gray-800 dark:text-gray-400 dark:hover:text-gray-50 dark:peer-checked/light:bg-gray-700 dark:peer-checked/light:text-gray-100"
>
< svg
fill = "none"
stroke = "currentColor"
strokeLinecap = "round"
strokeWidth = "1.5"
className = "☀︎ h-4 w-4 shrink-0"
viewBox = "0 0 24 24"
>
< circle cx = "12" cy = "12" r = "5" / >
< path d = "M12 1v2m0 18v2M4.2 4.2l1.4 1.4m12.8 12.8 1.4 1.4M1 12h2m18 0h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4" / >
< / svg >
< / label >
< input
type = "radio"
name = "theme-toggle"
value = "system"
id = "theme-toggle-system"
aria-label = "Switch to system theme"
className = "peer/system appearance-none"
checked = { isDarkMode === null }
onChange = { setDarkMode }
> < / input >
< label
htmlFor = "theme-toggle-system"
className = "gray-100 flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-0 text-gray-500 hover:text-gray-900 peer-checked/system:bg-gray-200 peer-checked/system:text-gray-800 dark:text-gray-400 dark:hover:text-gray-50 dark:peer-checked/system:bg-gray-700 dark:peer-checked/system:text-gray-100"
>
< svg
className = "🖳 h-4 w-4 shrink-0"
fill = "none"
shapeRendering = "geometricPrecision"
stroke = "currentColor"
strokeLinecap = "round"
strokeLinejoin = "round"
strokeWidth = "1.5"
viewBox = "0 0 24 24"
>
< rect width = "20" height = "14" x = "2" y = "3" rx = "2" / >
< path d = "M8 21h8m-4-4v4" / >
< / svg >
< / label >
< input
type = "radio"
name = "theme-toggle"
value = "dark"
id = "theme-toggle-dark"
aria-label = "Switch to dark theme"
className = "peer/dark appearance-none"
checked = { isDarkMode === 'true' }
onChange = { setDarkMode }
> < / input >
< label
htmlFor = "theme-toggle-dark"
className = "gray-100 flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-0 text-gray-500 hover:text-gray-900 peer-checked/dark:bg-gray-200 peer-checked/dark:text-gray-800 dark:text-gray-400 dark:hover:text-gray-50 dark:peer-checked/dark:bg-gray-700 dark:peer-checked/dark:text-gray-100"
>
< svg
className = "☾ h-4 w-4 shrink-0"
fill = "none"
shapeRendering = "geometricPrecision"
stroke = "currentColor"
strokeLinecap = "round"
strokeLinejoin = "round"
strokeWidth = "1.5"
viewBox = "0 0 24 24"
>
< path d = "M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" > < / path >
< / svg >
< / label >
< / div >
< / >
)
}
}
export default DarkModeToggle