Some things I learnt upgrading to NextJS 13 - pages to app what can possibly go wrong?
Upgrading to NextJS 13
I have a side project closed source in early stage development, it has been running nicely on NextJS 12 but as things go, there is a new shiny version so naturally I wanted to use that!
The following are some of my learning's migrating from old to new with NextJS framework, also included, is a collection of resources used a long the way.
I am generally not a great technical writer, I always end up with the TLDR; version, so I apologize for that now. Writing is something I am actively trying to improving on as a software engineer, hence this is why I am starting a blog, thanks hashnode!
This is a hindsight reflection on how things went down, so please ask any questions as I have surely left things out on my path to upgrade.
Jargon translation - words, acronyms
- TLDR = too long don't read
- Programming language = They are a kind of computer language
- Library = A collection of pre-written code
- Framework = abstraction which can provide generic functionality
- Javascript = ¿programming language?
- React = Javascript Library
- NextJS = react framework
- Closed Source = not available for public use
- Client-side = refers to operations that are performed by the client(essentially the web browser)
- Server-side = refers to operations that are performed by the server, (a different computer to the one you are using the app on)
- UI = user interface (the stuff you look at, aka the most important part of the app)
- Web app = an application that runs inside your web browser aka: app
- Software engineer = Someone who writes gibberish across a computer screen all day, and then analyses why what has been written is not yet as good as it could be
- Stack trace = A list of things the application was doing when an Exception(error) happened
- Component = An independent and reusable bit of code
- Use client = An instruction for when building the app that explains that this component should be used Client side
- Leaf = is a reference to the location of a leaf on a tree, usually being the most outer part of the tree, in reference to this app the trunk of the tree is everything inside the app folder.
Resources
Project
How it started | How its going |
---|---|
Nothing outrageous there, no refactoring... I know you all want to see what is in the app folder, so here it is.
The app folder
Learning's so far, potential points of interest
- I only have one layout.tsx (server-side), this is because I have a PageTemplate component that handles some general styling throughout my app. This could change as I get more familiar with layout.tsx components. check out the code section below to see what *PageTemplate looks like.
// single flex col with marginX
return (
<PageTemplate>
<Title headerType="h1Title" title="Add a your Details" />
<AddDetailsForm />
</PageTemplate>
)
// tripple flex col
return (
<PageTemplate
fixedLeftContent={
[<div className="text-black">left content</div>]
}
fixedRightContent={
[<div className="text-black">right content</div>]
}
>
<Title
headerType="h1Title"
title="Update a your Profile"
/>
<UpdateProfileForm />
</PageTemplate>
)
- I only use one error.tsx(client-side), this is because previously I used a pretty standard errorBoundary component, it handles all major UI failures, this code now lives inside error.tsx. As I get more comfortable with error.tsx and needing better stack trace I am sure this will change.
- All Pages except the list/page.tsx are client-side, I will need to move the
'use client'
to the leaf components as I continue work on this app, this will help lift the performance of the site, and better inform the developer where client-side interactive components live. - For now I have offloaded payment responsibility to stripe for payment through url, the
pages/api
, handles that redirect - Next auth is handled through pages/api also, essentially following the above resource How to Build a Fullstack App with Next.js, Prisma, and PostgreSQL with some tweaking here and there.
- Prisma client and usage, essentially followed the above recourse How to Build a Fullstack App with Next.js, Prisma, and PostgreSQL with some tweaking here and there
- tailwindcss I didn't notice any changes for adding tailwindcss to the project.
Pros & Cons of upgrade
Pros
- Having a constant reminder to write code that is composition over abstraction, the reminder is the new folder/file convention.
- Requesting data where it is needed in a simple way and now only one way.
- Flexibility to implement different patterns for folder/file management outside of app folder per team or application requirements.
- New and easier way to add fonts to the project
- Remove
<head>
node noise from page, it now lives inlayout.tsx
- Better developer experience
- Reduced code abstraction
- Reduced need for context providers
- Reduced duplication of tailwindcss utility styling throughout app
- Better Image component implementation
- Overall better developer experience
- Single focus when working on UI, API, App composition, related to developer experience
- No more custom server stuff, this was always possible, but it just feel more permanent now, your head space is either with in
app/components
orapi/prisma
Cons
pages/api
I was very ready to get rid of pages folder, I never really liked it. I think api folder can be a first class citizen just like app.- Learning curve of server side and client side working in tandem, not specific to NextJS, just my limited knowledge so far.
- Have to update all
Link
components to no longer contain<a>
elements, or use a legacy version ofLink
.
Code snippets
I have added some code snippets to reference, hopefully adding some context to all the words above.
rootLayout
import React, { ReactNode } from 'react'
import { Iceland } from '@next/font/google'
import CustomContextMenu from '../components/contextMenu'
import BootstrapContext from '../context/bootstrapContext'
import Footer from '../components/common/footer'
import '../styles/globals.css'
const iceland = Iceland({
weight: '400',
style: 'normal',
subsets: ['latin'],
})
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" dir="ltr" className={iceland.className}>
<head>
<title>The Shop</title>
<meta name="description" content="buy and sell merchandise online" />
<link rel="icon" href="/favicon.ico" />
<link rel="manifest" href="/site.webmanifest" />
<link
rel="sitemap"
type="application/xml"
title="Sitemap"
href="/sitemap.xml"
/>
<link rel="home" href="/" />
<link rel="canonical" href="https://www..." />
<link
rel="apple-touch-icon"
sizes="180x180"
href="/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/favicon-16x16.png"
/>
<link rel="manifest" href="/site.webmanifest" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
<link rel="icon" href="/favicon.ico" />
</head>
<body className="font-theme">
<BootstrapContext>{children}</BootstrapContext>
<Footer />
<CustomContextMenu />
</body>
</html>
)
}
PageTemplate
import React, {
FC,
Children,
cloneElement,
ReactNode,
ReactElement,
} from 'react'
// NOTE:: PageContent just provides "padding: 1rem"
import PageContent from '../common/pageContent'
type PageTemplateProps = {
children: ReactNode | ReactNode[]
fixedLeftContent?: ReactNode | ReactNode[]
fixedRightContent?: ReactNode | ReactNode[]
}
// TODO:: maybe update hiding showing side columns based on count remove css.
const PageTemplate: FC<PageTemplateProps> = (props: PageTemplateProps) => {
const { children, fixedLeftContent, fixedRightContent } = props
const fixedSideContentWidth =
fixedRightContent && fixedLeftContent
? 'w-fixed lg:w-1/4 md:w-1/4 w-full flex-shrink flex-grow-0'
: 'w-fixed lg:w-1/2 md:w-1/2 w-full flex-shrink flex-grow-0'
return (
<>
<div className="w-full flex flex-col sm:flex-row flex-wrap sm:flex-nowrap pb-4 flex-grow">
<div
className={`${
!fixedLeftContent || Children.count(fixedLeftContent) === 0
? 'hidden'
: fixedSideContentWidth
}`}
>
<div className="sticky w-full h-full justify-center text-center items-center">
<PageContent>
{Children.count(fixedLeftContent) > 0
? Children.map(fixedLeftContent, (child: ReactElement) =>
cloneElement(child),
)
: null}
</PageContent>
</div>
</div>
<main role="main" className="w-auto flex-grow">
<PageContent>
{Children.count(children) === 1
? children
: Children.map(children, (child: ReactElement) =>
cloneElement(child),
)}
</PageContent>
</main>
<div
className={`${
!fixedRightContent || Children.count(fixedRightContent) === 0
? 'hidden'
: fixedSideContentWidth
}`}
>
<div className="flex sm:flex-col justify-center text-center items-center">
<PageContent>
{Children.count(fixedRightContent) > 0
? Children.map(fixedRightContent, (child: ReactElement) =>
cloneElement(child),
)
: null}
</PageContent>
</div>
</div>
</div>
</>
)
}
PageTemplate.defaultProps = {
fixedLeftContent: undefined,
fixedRightContent: undefined,
}
export default PageTemplate
error.tsx
'use client'
import React, { useState, useEffect } from 'react'
import Link from 'next/link'
interface ErrorBoundaryProps {
error: Error
reset: () => void
}
interface Error {
message: string
}
const __DEV__: boolean =
!process.env.NODE_ENV || process.env.NODE_ENV === 'development'
function AppErrorBoundary({ error, reset }: ErrorBoundaryProps) {
const [hasError, setHasError] = useState<boolean>(false)
useEffect(() => {
if (error) {
setHasError(true)
// Log the error to an error reporting service
console.error(error)
}
}, [error])
return (
<div className="flex items-center justify-center w-screen h-screen bg-gradient-to-r from-indigo-600 to-blue-400">
<div className="px-40 py-20 bg-white rounded-md shadow-xl">
<div className="flex flex-col items-center">
<h1 className="font-bold text-blue-600 text-9xl capitalize">Error</h1>
<h6 className="mb-2 text-2xl font-bold text-center text-gray-800 md:text-3xl">
<span className="text-red-500">Oops! </span>
This wasn't meant to happen! 😫😢
</h6>
<p className="mb-8 text-center text-gray-500 md:text-lg">
Sorry for any inconvenience. If you're seeing this often,
please
<Link href="/">contact us</Link>
</p>
<Link
href="/"
className="px-6 py-2 text-sm font-semibold text-blue-800 bg-blue-100"
>
Take me home
</Link>
{__DEV__ && (
<button onClick={() => reset()}>Reset error boundary</button>
)}
</div>
</div>
</div>
)
}
export default AppErrorBoundary
list/page.tsx
import prisma from '../../../../lib/prisma'
import { MerchandiseApi } from '../../../../pages/api/merchandiseApi'
import { formatDate } from '../../../../utils/helpers/commonFunctions'
import CardGrid from '../../../../components/cardGrid'
import Title from '../../../../components/common/title'
import SearchBanner from '../../../../components/searchBanner'
import PageTemplate from '../../../../components/pageTemplate'
async function getData() {
const data = await prisma.merchandise.findMany({
include: {
seller: { include: { user: true } },
shop: true,
item: true,
itemImages: { include: { image: true } },
},
})
const merchandise: MerchandiseApi = {
merchandise: data.map(item => {
return {
...item,
creationDate: formatDate(item.creationDate, 'readableShortDate'),
}
}),
}
return merchandise
}
export default async function PropertiesPage() {
const data = await getData()
return (
<>
<SearchBanner
bannerImage=""
bannerHasSearch
showPropertyTypeFilters
width="w-full mx-auto transition-width transition-slowest ease"
/>
<PageTemplate
fixedLeftContent={[<div className="text-black">left content</div>]}
fixedRightContent={[<div className="text-black">right content</div>]}
>
<Title
headerType="h1Title"
containerClassName="text-center"
title="Merchandise result page"
/>
<CardGrid data={data.merchandise} />
</PageTemplate>
</>
)
}
stripe
import { Stripe, loadStripe } from '@stripe/stripe-js'
let stripePromise: Promise<Stripe | null>
const getStripe = () => {
if (!stripePromise) {
stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!)
}
return stripePromise
}
export default getStripe
'use client'
import { useEffect } from 'react'
import { loadStripe } from '@stripe/stripe-js'
import { usePathname } from 'next/navigation'
import { PrimaryButton } from '../common/button'
// Make sure to call `loadStripe` outside of a component’s render to avoid
// recreating the `Stripe` object on every render.
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY)
const StripPaymentButton = () => {
useEffect(() => {
// Check to see if this is a redirect back from Checkout
const query = new URLSearchParams(window.location.search)
if (query.get('success')) {
console.log('Order placed! You will receive an email confirmation.')
}
if (query.get('canceled')) {
console.log(
'Order canceled -- continue to shop around and checkout when you’re ready.',
)
}
}, [])
const pathname = usePathname()
console.log(
'🚀 ~ file: index.tsx ~ line 21 ~ StripPaymentButton ~ pathname',
pathname,
)
return (
<form
action="/api/checkout_sessions"
method="POST"
className="relative w-full"
>
<PrimaryButton
width="lg:w-max md:w-max w-full"
height="h-full"
buttonPadding="p-1"
type="submit"
role="link"
textColour="text-gray-700"
borderRadius="rounded-sm"
backgroundColor="bg-green-300"
containerClassName="absolute lg:right-2 right-0 lg:-top-64 md:-top-56 -top-24 lg:w-auto md:w-auto w-full"
>
Purchase
</PrimaryButton>
</form>
)
}
export default StripPaymentButton
pages/api/checkout_sessions
import { NextApiRequest, NextApiResponse } from 'next'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
// https://github.com/stripe/stripe-node#configuration
apiVersion: '2022-08-01',
})
const stripeHandler = async (
req: NextApiRequest,
res: NextApiResponse,
): Promise<void> => {
if (req.method === 'POST') {
try {
// Create Checkout Sessions from body params.
const session = await stripe.checkout.sessions.create({
submit_type: 'pay',
payment_method_types: ['card'],
line_items: [
{
// Provide the exact Price ID (for example, pr_1234) of the product you want to sell
price: 'YOUR_PRICE_CODE_FOR_THIS_ITEM,
quantity: 1,
},
],
mode: 'payment',
success_url: `${req.headers.origin}/?success=true`,
cancel_url: `${req.headers.origin}/?canceled=true`,
automatic_tax: { enabled: true },
})
res.redirect(303, session.url)
} catch (err) {
res.status(err.statusCode || 500).json(err.message)
}
} else {
// TODO:: do i need 'OPTIONS' here also?
res.setHeader('Allow', 'POST')
res.status(405).end('Method Not Allowed')
}
}
export default stripeHandler
pages/api/auth/[...nextauth].ts
const handleNavToListMyProperty = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
router.push(!session ? '/api/auth/signin' : '/create-user')
},
[session],
)
import { NextApiHandler } from 'next'
import NextAuth from 'next-auth'
import { PrismaAdapter } from '@next-auth/prisma-adapter'
import GitHubProvider from 'next-auth/providers/github'
import prisma from '../../../lib/prisma'
const authHandler: NextApiHandler = (req, res) => NextAuth(req, res, options)
export default authHandler
const options = {
providers: [
GitHubProvider({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
}),
],
adapter: PrismaAdapter(prisma),
secret: process.env.SECRET,
}
Prisma
import { PrismaClient } from '@prisma/client'
let prisma: PrismaClient
if (process.env.NODE_ENV === 'production') {
prisma = new PrismaClient()
} else {
if (!global.prisma) {
global.prisma = new PrismaClient()
}
prisma = global.prisma
}
export default prisma
client-side api POST example: pages/api/add-new-item
const newItem = await fetcher(
`${endPoint}/add-new-item`,
payload,
'POST',
)
export const fetcher = async <T>(
endpointUrl: string,
payload?: T | Record<string, string> | string,
method = 'GET',
): Promise<T> => {
const response = await fetch(endpointUrl, {
method,
headers: {
'content-type': 'application/json;charset=UTF-8',
},
body: JSON.stringify(payload),
})
const responseData = await response.json()
if (response.ok) {
if (responseData) {
return responseData
} else {
return Promise.reject(new Error())
}
} else {
const error = new Error(
responseData.errors?.map((e: Error) => e.message).join('\n') ?? 'unknown',
)
return Promise.reject(error)
}
}
import { NextApiRequest, NextApiResponse } from 'next'
import prisma from '../../../lib/prisma'
import { ItemDetails } from '../itemDetails'
const itemDetailsHandler = async (
req: NextApiRequest,
res: NextApiResponse,
): Promise<void> => {
const {
body,
// query,
method,
} = req
switch (method) {
case 'POST':
const newData: ItemDetails = {
...body,
}
try {
const data = await prisma.item.create({
data: {
sometablecolumn: newData.value
...
},
})
const newItem: ItemDetails = data
return res.status(200).json(newItem)
// return res.send(payload);
} catch (error: any) {
// TODO:: dispatch error to redux here maybe?
// throw error for errorWrapper to handle?
// send to some error logging service?
throw error
}
default:
res.setHeader('Allow', ['OPTIONS'])
res.status(405).end(`Method ${method} Not Allowed`)
}
}
export default itemDetailsHandler
tailwindcss
/* TODO:: import any custom fonts here */
/* import/add tailwind styles to project */
@import "tailwindcss/base";
@import "./custom-base.css";
@import "tailwindcss/components";
@import "./custom-components.css";
/* import any npm dependency styles here under "tailwindcss/components" to make sure they are added as "custom" styling */
@import "tailwindcss/utilities";
/* @import "custom-utilities.css"; */
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./app/**/*.{js,ts,jsx,tsx}',
'./pages/**/*.{js,ts,jsx,tsx}',
'./components/**/*.{js,ts,jsx,tsx}',
],
presets: [],
darkMode: 'media', // or 'class'
variants: {
extend: {
opacity: ['disabled'],
},
},
theme: {
extend: {
transitionProperty: {
width: 'width',
},
zIndex: {
'-1': '-1',
},
transformOrigin: {
0: '0%',
},
animation: {
loader: 'loader 0.6s infinite alternate',
},
keyframes: {
loader: {
from: {
opacity: 1,
transform: 'translate3d(0, -1rem, 0)',
},
to: {
opacity: 0.1,
},
},
},
fontFamily: {
script: ["'Nanum Brush Script', cursive"],
theme: ["'Chillax-Regular', sans"],
},
},
screens: {
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
'2xl': '1536px',
},
colors: ({ colors }) => ({
inherit: colors.inherit,
current: colors.current,
transparent: colors.transparent,
black: colors.black,
white: colors.white,
slate: colors.slate,
gray: colors.gray,
zinc: colors.zinc,
neutral: colors.neutral,
stone: colors.stone,
red: colors.red,
orange: colors.orange,
amber: colors.amber,
yellow: colors.yellow,
lime: colors.lime,
green: colors.green,
emerald: colors.emerald,
teal: colors.teal,
cyan: colors.cyan,
sky: colors.sky,
blue: colors.blue,
indigo: colors.indigo,
violet: colors.violet,
purple: colors.purple,
fuchsia: colors.fuchsia,
pink: colors.pink,
rose: colors.rose,
'primary-colour': '#fb5607',
'secondary-colour': '#ff006e',
'tertiary-colour': '#3a86ff',
'secondary-contrast-colour': '#ffbe0b',
'contrast-colour': '#8338ec',
'theme-black': '#121212',
'theme-grey': '#535353',
}),
columns: {
auto: 'auto',
1: '1',
2: '2',
3: '3',
4: '4',
5: '5',
6: '6',
7: '7',
8: '8',
9: '9',
10: '10',
11: '11',
12: '12',
'3xs': '16rem',
'2xs': '18rem',
xs: '20rem',
sm: '24rem',
md: '28rem',
lg: '32rem',
xl: '36rem',
'2xl': '42rem',
'3xl': '48rem',
'4xl': '56rem',
'5xl': '64rem',
'6xl': '72rem',
'7xl': '80rem',
},
spacing: {
px: '1px',
0: '0px',
0.5: '0.125rem',
1: '0.25rem',
1.5: '0.375rem',
2: '0.5rem',
2.5: '0.625rem',
3: '0.75rem',
3.5: '0.875rem',
4: '1rem',
5: '1.25rem',
6: '1.5rem',
7: '1.75rem',
8: '2rem',
9: '2.25rem',
10: '2.5rem',
11: '2.75rem',
12: '3rem',
14: '3.5rem',
16: '4rem',
20: '5rem',
24: '6rem',
28: '7rem',
32: '8rem',
36: '9rem',
40: '10rem',
44: '11rem',
48: '12rem',
52: '13rem',
56: '14rem',
60: '15rem',
64: '16rem',
72: '18rem',
80: '20rem',
96: '24rem',
},
animation: {
none: 'none',
spin: 'spin 1s linear infinite',
ping: 'ping 1s cubic-bezier(0, 0, 0.2, 1) infinite',
pulse: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
bounce: 'bounce 1s infinite',
fade: 'fadeIn 2s ease',
},
aspectRatio: {
auto: 'auto',
square: '1 / 1',
video: '16 / 9',
},
backdropBlur: ({ theme }) => theme('blur'),
backdropBrightness: ({ theme }) => theme('brightness'),
backdropContrast: ({ theme }) => theme('contrast'),
backdropGrayscale: ({ theme }) => theme('grayscale'),
backdropHueRotate: ({ theme }) => theme('hueRotate'),
backdropInvert: ({ theme }) => theme('invert'),
backdropOpacity: ({ theme }) => theme('opacity'),
backdropSaturate: ({ theme }) => theme('saturate'),
backdropSepia: ({ theme }) => theme('sepia'),
backgroundColor: ({ theme }) => ({
...theme('colors'),
'primary-colour': '#fb5607',
'secondary-colour': '#ff006e',
'contrast-colour': '#8338ec',
}),
backgroundImage: {
none: 'none',
'gradient-to-t': 'linear-gradient(to top, var(--tw-gradient-stops))',
'gradient-to-tr':
'linear-gradient(to top right, var(--tw-gradient-stops))',
'gradient-to-r': 'linear-gradient(to right, var(--tw-gradient-stops))',
'gradient-to-br':
'linear-gradient(to bottom right, var(--tw-gradient-stops))',
'gradient-to-b': 'linear-gradient(to bottom, var(--tw-gradient-stops))',
'gradient-to-bl':
'linear-gradient(to bottom left, var(--tw-gradient-stops))',
'gradient-to-l': 'linear-gradient(to left, var(--tw-gradient-stops))',
'gradient-to-tl':
'linear-gradient(to top left, var(--tw-gradient-stops))',
},
backgroundOpacity: ({ theme }) => theme('opacity'),
backgroundPosition: {
bottom: 'bottom',
center: 'center',
left: 'left',
'left-bottom': 'left bottom',
'left-top': 'left top',
right: 'right',
'right-bottom': 'right bottom',
'right-top': 'right top',
top: 'top',
},
backgroundSize: {
auto: 'auto',
cover: 'cover',
contain: 'contain',
},
blur: {
0: '0',
none: '0',
sm: '4px',
DEFAULT: '8px',
md: '12px',
lg: '16px',
xl: '24px',
'2xl': '40px',
'3xl': '64px',
},
brightness: {
0: '0',
50: '.5',
75: '.75',
90: '.9',
95: '.95',
100: '1',
105: '1.05',
110: '1.1',
125: '1.25',
150: '1.5',
200: '2',
},
borderColor: ({ theme }) => ({
...theme('colors'),
DEFAULT: theme('colors.gray.200', 'currentColor'),
}),
borderOpacity: ({ theme }) => theme('opacity'),
borderRadius: {
none: '0px',
sm: '0.125rem',
DEFAULT: '0.25rem',
md: '0.375rem',
lg: '0.5rem',
xl: '0.75rem',
'2xl': '1rem',
'3xl': '1.5rem',
full: '9999px',
},
borderWidth: {
DEFAULT: '1px',
0: '0px',
2: '2px',
4: '4px',
8: '8px',
},
boxShadow: {
sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
DEFAULT: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
md: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
lg: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
xl: '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
'2xl': '0 25px 50px -12px rgb(0 0 0 / 0.25)',
inner: 'inset 0 2px 4px 0 rgb(0 0 0 / 0.05)',
none: 'none',
},
boxShadowColor: ({ theme }) => theme('colors'),
caretColor: ({ theme }) => theme('colors'),
accentColor: ({ theme }) => ({
...theme('colors'),
auto: 'auto',
}),
contrast: {
0: '0',
50: '.5',
75: '.75',
100: '1',
125: '1.25',
150: '1.5',
200: '2',
},
container: {},
content: {
none: 'none',
},
cursor: {
auto: 'auto',
default: 'default',
pointer: 'pointer',
wait: 'wait',
text: 'text',
move: 'move',
help: 'help',
'not-allowed': 'not-allowed',
none: 'none',
'context-menu': 'context-menu',
progress: 'progress',
cell: 'cell',
crosshair: 'crosshair',
'vertical-text': 'vertical-text',
alias: 'alias',
copy: 'copy',
'no-drop': 'no-drop',
grab: 'grab',
grabbing: 'grabbing',
'all-scroll': 'all-scroll',
'col-resize': 'col-resize',
'row-resize': 'row-resize',
'n-resize': 'n-resize',
'e-resize': 'e-resize',
's-resize': 's-resize',
'w-resize': 'w-resize',
'ne-resize': 'ne-resize',
'nw-resize': 'nw-resize',
'se-resize': 'se-resize',
'sw-resize': 'sw-resize',
'ew-resize': 'ew-resize',
'ns-resize': 'ns-resize',
'nesw-resize': 'nesw-resize',
'nwse-resize': 'nwse-resize',
'zoom-in': 'zoom-in',
'zoom-out': 'zoom-out',
},
divideColor: ({ theme }) => theme('borderColor'),
divideOpacity: ({ theme }) => theme('borderOpacity'),
divideWidth: ({ theme }) => theme('borderWidth'),
dropShadow: {
sm: '0 1px 1px rgb(0 0 0 / 0.05)',
DEFAULT: ['0 1px 2px rgb(0 0 0 / 0.1)', '0 1px 1px rgb(0 0 0 / 0.06)'],
md: ['0 4px 3px rgb(0 0 0 / 0.07)', '0 2px 2px rgb(0 0 0 / 0.06)'],
lg: ['0 10px 8px rgb(0 0 0 / 0.04)', '0 4px 3px rgb(0 0 0 / 0.1)'],
xl: ['0 20px 13px rgb(0 0 0 / 0.03)', '0 8px 5px rgb(0 0 0 / 0.08)'],
'2xl': '0 25px 25px rgb(0 0 0 / 0.15)',
none: '0 0 #0000',
},
fill: ({ theme }) => theme('colors'),
grayscale: {
0: '0',
DEFAULT: '100%',
},
hueRotate: {
0: '0deg',
15: '15deg',
30: '30deg',
60: '60deg',
90: '90deg',
180: '180deg',
},
invert: {
0: '0',
DEFAULT: '100%',
},
flex: {
1: '1 1 0%',
auto: '1 1 auto',
initial: '0 1 auto',
none: 'none',
},
flexBasis: ({ theme }) => ({
auto: 'auto',
...theme('spacing'),
'1/2': '50%',
'1/3': '33.333333%',
'2/3': '66.666667%',
'1/4': '25%',
'2/4': '50%',
'3/4': '75%',
'1/5': '20%',
'2/5': '40%',
'3/5': '60%',
'4/5': '80%',
'1/6': '16.666667%',
'2/6': '33.333333%',
'3/6': '50%',
'4/6': '66.666667%',
'5/6': '83.333333%',
'1/12': '8.333333%',
'2/12': '16.666667%',
'3/12': '25%',
'4/12': '33.333333%',
'5/12': '41.666667%',
'6/12': '50%',
'7/12': '58.333333%',
'8/12': '66.666667%',
'9/12': '75%',
'10/12': '83.333333%',
'11/12': '91.666667%',
full: '100%',
}),
flexGrow: {
0: '0',
DEFAULT: '1',
},
flexShrink: {
0: '0',
DEFAULT: '1',
},
fontFamily: {
sans: [
'ui-sans-serif',
'system-ui',
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"Helvetica Neue"',
'Arial',
'"Noto Sans"',
'sans-serif',
'"Apple Color Emoji"',
'"Segoe UI Emoji"',
'"Segoe UI Symbol"',
'"Noto Color Emoji"',
],
serif: [
'ui-serif',
'Georgia',
'Cambria',
'"Times New Roman"',
'Times',
'serif',
],
mono: [
'ui-monospace',
'SFMono-Regular',
'Menlo',
'Monaco',
'Consolas',
'"Liberation Mono"',
'"Courier New"',
'monospace',
],
},
fontSize: {
xs: ['0.75rem', { lineHeight: '1rem' }],
sm: ['0.875rem', { lineHeight: '1.25rem' }],
base: ['1rem', { lineHeight: '1.5rem' }],
lg: ['1.125rem', { lineHeight: '1.75rem' }],
xl: ['1.25rem', { lineHeight: '1.75rem' }],
'2xl': ['1.5rem', { lineHeight: '2rem' }],
'3xl': ['1.875rem', { lineHeight: '2.25rem' }],
'4xl': ['2.25rem', { lineHeight: '2.5rem' }],
'5xl': ['3rem', { lineHeight: '1' }],
'6xl': ['3.75rem', { lineHeight: '1' }],
'7xl': ['4.5rem', { lineHeight: '1' }],
'8xl': ['6rem', { lineHeight: '1' }],
'9xl': ['8rem', { lineHeight: '1' }],
},
fontWeight: {
thin: '100',
extralight: '200',
light: '300',
normal: '400',
medium: '500',
semibold: '600',
bold: '700',
extrabold: '800',
black: '900',
},
gap: ({ theme }) => theme('spacing'),
gradientColorStops: ({ theme }) => theme('colors'),
gridAutoColumns: {
auto: 'auto',
min: 'min-content',
max: 'max-content',
fr: 'minmax(0, 1fr)',
},
gridAutoRows: {
auto: 'auto',
min: 'min-content',
max: 'max-content',
fr: 'minmax(0, 1fr)',
},
gridColumn: {
auto: 'auto',
'span-1': 'span 1 / span 1',
'span-2': 'span 2 / span 2',
'span-3': 'span 3 / span 3',
'span-4': 'span 4 / span 4',
'span-5': 'span 5 / span 5',
'span-6': 'span 6 / span 6',
'span-7': 'span 7 / span 7',
'span-8': 'span 8 / span 8',
'span-9': 'span 9 / span 9',
'span-10': 'span 10 / span 10',
'span-11': 'span 11 / span 11',
'span-12': 'span 12 / span 12',
'span-full': '1 / -1',
},
gridColumnEnd: {
auto: 'auto',
1: '1',
2: '2',
3: '3',
4: '4',
5: '5',
6: '6',
7: '7',
8: '8',
9: '9',
10: '10',
11: '11',
12: '12',
13: '13',
},
gridColumnStart: {
auto: 'auto',
1: '1',
2: '2',
3: '3',
4: '4',
5: '5',
6: '6',
7: '7',
8: '8',
9: '9',
10: '10',
11: '11',
12: '12',
13: '13',
},
gridRow: {
auto: 'auto',
'span-1': 'span 1 / span 1',
'span-2': 'span 2 / span 2',
'span-3': 'span 3 / span 3',
'span-4': 'span 4 / span 4',
'span-5': 'span 5 / span 5',
'span-6': 'span 6 / span 6',
'span-full': '1 / -1',
},
gridRowStart: {
auto: 'auto',
1: '1',
2: '2',
3: '3',
4: '4',
5: '5',
6: '6',
7: '7',
},
gridRowEnd: {
auto: 'auto',
1: '1',
2: '2',
3: '3',
4: '4',
5: '5',
6: '6',
7: '7',
},
gridTemplateColumns: {
none: 'none',
1: 'repeat(1, minmax(0, 1fr))',
2: 'repeat(2, minmax(0, 1fr))',
3: 'repeat(3, minmax(0, 1fr))',
4: 'repeat(4, minmax(0, 1fr))',
5: 'repeat(5, minmax(0, 1fr))',
6: 'repeat(6, minmax(0, 1fr))',
7: 'repeat(7, minmax(0, 1fr))',
8: 'repeat(8, minmax(0, 1fr))',
9: 'repeat(9, minmax(0, 1fr))',
10: 'repeat(10, minmax(0, 1fr))',
11: 'repeat(11, minmax(0, 1fr))',
12: 'repeat(12, minmax(0, 1fr))',
},
gridTemplateRows: {
none: 'none',
1: 'repeat(1, minmax(0, 1fr))',
2: 'repeat(2, minmax(0, 1fr))',
3: 'repeat(3, minmax(0, 1fr))',
4: 'repeat(4, minmax(0, 1fr))',
5: 'repeat(5, minmax(0, 1fr))',
6: 'repeat(6, minmax(0, 1fr))',
},
height: ({ theme }) => ({
auto: 'auto',
...theme('spacing'),
'1/2': '50%',
'1/3': '33.333333%',
'2/3': '66.666667%',
'1/4': '25%',
'2/4': '50%',
'3/4': '75%',
'1/5': '20%',
'2/5': '40%',
'3/5': '60%',
'4/5': '80%',
'1/6': '16.666667%',
'2/6': '33.333333%',
'3/6': '50%',
'4/6': '66.666667%',
'5/6': '83.333333%',
full: '100%',
screen: '100vh',
min: 'min-content',
max: 'max-content',
fit: 'fit-content',
}),
inset: ({ theme }) => ({
auto: 'auto',
...theme('spacing'),
'1/2': '50%',
'1/3': '33.333333%',
'2/3': '66.666667%',
'1/4': '25%',
'2/4': '50%',
'3/4': '75%',
full: '100%',
}),
keyframes: {
spin: {
to: {
transform: 'rotate(360deg)',
},
},
ping: {
'75%, 100%': {
transform: 'scale(2)',
opacity: '0',
},
},
pulse: {
'50%': {
opacity: '.5',
},
},
bounce: {
'0%, 100%': {
transform: 'translateY(-25%)',
animationTimingFunction: 'cubic-bezier(0.8,0,1,1)',
},
'50%': {
transform: 'none',
animationTimingFunction: 'cubic-bezier(0,0,0.2,1)',
},
},
fadeIn: {
from: {
opacity: 0,
},
to: {
opacity: 1,
},
},
},
letterSpacing: {
tighter: '-0.05em',
tight: '-0.025em',
normal: '0em',
wide: '0.025em',
wider: '0.05em',
widest: '0.1em',
},
lineHeight: {
none: '1',
tight: '1.25',
snug: '1.375',
normal: '1.5',
relaxed: '1.625',
loose: '2',
3: '.75rem',
4: '1rem',
5: '1.25rem',
6: '1.5rem',
7: '1.75rem',
8: '2rem',
9: '2.25rem',
10: '2.5rem',
},
listStyleType: {
none: 'none',
disc: 'disc',
decimal: 'decimal',
},
margin: ({ theme }) => ({
auto: 'auto',
...theme('spacing'),
}),
maxHeight: ({ theme }) => ({
...theme('spacing'),
full: '100%',
screen: '100vh',
min: 'min-content',
max: 'max-content',
fit: 'fit-content',
}),
maxWidth: ({ theme, breakpoints }) => ({
none: 'none',
0: '0rem',
xs: '20rem',
sm: '24rem',
md: '28rem',
lg: '32rem',
xl: '36rem',
'2xl': '42rem',
'3xl': '48rem',
'4xl': '56rem',
'5xl': '64rem',
'6xl': '72rem',
'7xl': '80rem',
full: '100%',
min: 'min-content',
max: 'max-content',
fit: 'fit-content',
prose: '65ch',
...breakpoints(theme('screens')),
}),
minHeight: {
0: '0px',
full: '100%',
screen: '100vh',
min: 'min-content',
max: 'max-content',
fit: 'fit-content',
},
minWidth: {
0: '0px',
full: '100%',
min: 'min-content',
max: 'max-content',
fit: 'fit-content',
},
objectPosition: {
bottom: 'bottom',
center: 'center',
left: 'left',
'left-bottom': 'left bottom',
'left-top': 'left top',
right: 'right',
'right-bottom': 'right bottom',
'right-top': 'right top',
top: 'top',
},
opacity: {
0: '0',
5: '0.05',
10: '0.1',
20: '0.2',
25: '0.25',
30: '0.3',
40: '0.4',
50: '0.5',
60: '0.6',
70: '0.7',
75: '0.75',
80: '0.8',
90: '0.9',
95: '0.95',
100: '1',
},
order: {
first: '-9999',
last: '9999',
none: '0',
1: '1',
2: '2',
3: '3',
4: '4',
5: '5',
6: '6',
7: '7',
8: '8',
9: '9',
10: '10',
11: '11',
12: '12',
},
padding: ({ theme }) => theme('spacing'),
placeholderColor: ({ theme }) => theme('colors'),
placeholderOpacity: ({ theme }) => theme('opacity'),
outlineColor: ({ theme }) => theme('colors'),
outlineOffset: {
0: '0px',
1: '1px',
2: '2px',
4: '4px',
8: '8px',
},
outlineWidth: {
0: '0px',
1: '1px',
2: '2px',
4: '4px',
8: '8px',
},
ringColor: ({ theme }) => ({
DEFAULT: theme('colors.blue.500', '#3b82f6'),
...theme('colors'),
}),
ringOffsetColor: ({ theme }) => theme('colors'),
ringOffsetWidth: {
0: '0px',
1: '1px',
2: '2px',
4: '4px',
8: '8px',
},
ringOpacity: ({ theme }) => ({
DEFAULT: '0.5',
...theme('opacity'),
}),
ringWidth: {
DEFAULT: '3px',
0: '0px',
1: '1px',
2: '2px',
4: '4px',
8: '8px',
},
rotate: {
0: '0deg',
1: '1deg',
2: '2deg',
3: '3deg',
6: '6deg',
12: '12deg',
45: '45deg',
90: '90deg',
180: '180deg',
},
saturate: {
0: '0',
50: '.5',
100: '1',
150: '1.5',
200: '2',
},
scale: {
0: '0',
50: '.5',
75: '.75',
90: '.9',
95: '.95',
100: '1',
105: '1.05',
110: '1.1',
125: '1.25',
150: '1.5',
},
scrollMargin: ({ theme }) => ({
...theme('spacing'),
}),
scrollPadding: ({ theme }) => theme('spacing'),
sepia: {
0: '0',
DEFAULT: '100%',
},
skew: {
0: '0deg',
1: '1deg',
2: '2deg',
3: '3deg',
6: '6deg',
12: '12deg',
},
space: ({ theme }) => ({
...theme('spacing'),
}),
stroke: ({ theme }) => theme('colors'),
strokeWidth: {
0: '0',
1: '1',
2: '2',
},
textColor: ({ theme }) => theme('colors'),
textDecorationColor: ({ theme }) => theme('colors'),
textDecorationThickness: {
auto: 'auto',
'from-font': 'from-font',
0: '0px',
1: '1px',
2: '2px',
4: '4px',
8: '8px',
},
textUnderlineOffset: {
auto: 'auto',
0: '0px',
1: '1px',
2: '2px',
4: '4px',
8: '8px',
},
textIndent: ({ theme }) => ({
...theme('spacing'),
}),
textOpacity: ({ theme }) => theme('opacity'),
transformOrigin: {
center: 'center',
top: 'top',
'top-right': 'top right',
right: 'right',
'bottom-right': 'bottom right',
bottom: 'bottom',
'bottom-left': 'bottom left',
left: 'left',
'top-left': 'top left',
},
transitionDelay: {
75: '75ms',
100: '100ms',
150: '150ms',
200: '200ms',
300: '300ms',
500: '500ms',
700: '700ms',
1000: '1000ms',
},
transitionDuration: {
DEFAULT: '150ms',
75: '75ms',
100: '100ms',
150: '150ms',
200: '200ms',
300: '300ms',
500: '500ms',
700: '700ms',
1000: '1000ms',
},
transitionProperty: {
none: 'none',
all: 'all',
DEFAULT:
'background-color, border-color, color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter',
colors: 'background-color, border-color, color, fill, stroke',
opacity: 'opacity',
shadow: 'box-shadow',
transform: 'transform',
},
transitionTimingFunction: {
DEFAULT: 'cubic-bezier(0.4, 0, 0.2, 1)',
linear: 'linear',
in: 'cubic-bezier(0.4, 0, 1, 1)',
out: 'cubic-bezier(0, 0, 0.2, 1)',
'in-out': 'cubic-bezier(0.4, 0, 0.2, 1)',
},
translate: ({ theme }) => ({
...theme('spacing'),
'1/2': '50%',
'1/3': '33.333333%',
'2/3': '66.666667%',
'1/4': '25%',
'2/4': '50%',
'3/4': '75%',
full: '100%',
}),
width: ({ theme }) => ({
auto: 'auto',
...theme('spacing'),
'1/2': '50%',
'1/3': '33.333333%',
'2/3': '66.666667%',
'1/4': '25%',
'2/4': '50%',
'3/4': '75%',
'1/5': '20%',
'2/5': '40%',
'3/5': '60%',
'4/5': '80%',
'1/6': '16.666667%',
'2/6': '33.333333%',
'3/6': '50%',
'4/6': '66.666667%',
'5/6': '83.333333%',
'1/12': '8.333333%',
'2/12': '16.666667%',
'3/12': '25%',
'4/12': '33.333333%',
'5/12': '41.666667%',
'6/12': '50%',
'7/12': '58.333333%',
'8/12': '66.666667%',
'9/12': '75%',
'10/12': '83.333333%',
'11/12': '91.666667%',
full: '100%',
screen: '100vw',
min: 'min-content',
max: 'max-content',
fit: 'fit-content',
}),
willChange: {
auto: 'auto',
scroll: 'scroll-position',
contents: 'contents',
transform: 'transform',
},
zIndex: {
auto: 'auto',
0: '0',
10: '10',
20: '20',
30: '30',
40: '40',
50: '50',
},
},
variantOrder: [
'first',
'last',
'odd',
'even',
'visited',
'checked',
'empty',
'read-only',
'group-hover',
'group-focus',
'focus-within',
'hover',
'focus',
'focus-visible',
'active',
'disabled',
],
}