10

next/font

Uses Next.js with TypeScript and Tailwind CSS

This is my first time using the new next/font package. I followed Next.js' tutorial, and it was easy to set up. I'm using both Inter and a custom local typeface called App Takeoff. To actually use both of these typefaces, I'm using Tailwind CSS, where Inter is connected to font-sans and App Takeoff is connected to font-display.

Everything works except in one spot

I have done plenty of testing between files, and for some reason both typefaces work everywhere except my Modal component. (See Helpful Update at the bottom for why it doesn't work in the Modal component.)

Example

index.tsx

Inter and App Takeoff typefaces working correctly

modal.tsx via index.tsx

Inter and App Takeoff typefaces not working

As you can see, the typefaces work just fine when they aren't inside the modal, but as soon as they're in the modal they don't work.

Here's some relevant code:

// app.tsx

import '@/styles/globals.css'
import type { AppProps } from 'next/app'

import { Inter } from 'next/font/google'
const inter = Inter({
  subsets: ['latin'],
  variable: '--font-inter'
})

import localFont from 'next/font/local'
const appTakeoff = localFont({
  src: [
    {
      path: '../fonts/app-takeoff/regular.otf',
      weight: '400',
      style: 'normal'
    },
    {
      path: '../fonts/app-takeoff/regular.eot',
      weight: '400',
      style: 'normal'
    },
    {
      path: '../fonts/app-takeoff/regular.woff2',
      weight: '400',
      style: 'normal'
    },
    {
      path: '../fonts/app-takeoff/regular.woff',
      weight: '400',
      style: 'normal'
    },
    {
      path: '../fonts/app-takeoff/regular.ttf',
      weight: '400',
      style: 'normal'
    }
  ],
  variable: '--font-app-takeoff'
})

const App = ({ Component, pageProps }: AppProps) => {
  return (
    <div className={`${inter.variable} font-sans ${appTakeoff.variable}`}>
      <Component {...pageProps} />
    </div>
  )
}

export default App
// modal.tsx

import type { FunctionComponent } from 'react'
import type { Modal as ModalProps } from '@/typings/components'
import React, { useState } from 'react'
import { Fragment } from 'react'
import { Transition, Dialog } from '@headlessui/react'

const Modal: FunctionComponent<ModalProps> = ({ trigger, place = 'bottom', className, addClass, children }) => {

  const [isOpen, setIsOpen] = useState(false),
        openModal = () => setIsOpen(true),
        closeModal = () => setIsOpen(false)

  const Trigger = () => React.cloneElement(trigger, { onClick: openModal })

  const enterFrom = place === 'center'
    ? '-translate-y-[calc(50%-12rem)]'
    : 'translate-y-full sm:-translate-y-[calc(50%-12rem)]'

  const mainPosition = place === 'center'
    ? '-translate-y-1/2'
    : 'translate-y-0 sm:-translate-y-1/2'

  const leaveTo = place === 'center'
    ? '-translate-y-[calc(50%+8rem)]'
    : 'translate-y-full sm:-translate-y-[calc(50%+8rem)]'

  return (
    <>
    
      <Trigger />

      <Dialog open={isOpen} onClose={closeModal} className='z-50'>

        {/* Backdrop */}
        <div className='fixed inset-0 bg-zinc-200/50 dark:bg-zinc-900/50 backdrop-blur-sm cursor-pointer' aria-hidden='true' />

        <Dialog.Panel
          className={`
            ${className || `
              fixed left-1/2
              ${
                place === 'center'
                ? 'top-1/2 rounded-2xl'
                : 'bottom-0 sm:bottom-auto sm:top-1/2 rounded-t-2xl xs:rounded-b-2xl'
              }
              bg-zinc-50 dark:bg-zinc-900
              w-min
              -translate-x-1/2
              overflow-hidden
              px-2 xs:px-6
              shadow-3xl shadow-primary-400/10
            `}
            ${addClass || ''}
          `}
        >
          {children}
              
        </Dialog.Panel>

        <button
          onClick={closeModal}
          className='
            fixed top-4 right-4
            bg-primary-600 hover:bg-primary-400
            rounded-full
            h-7 w-7 desktop:hover:w-20
            overflow-x-hidden
            transition-[background-color_width] duration-300 ease-in-out
            group/button
          '
          aria-role='button'
        >
          Close
        </button>

      </Dialog>

    </>
  )
}

export default Modal

I hope this information helps. Let me know if there's anything else that would be helpful to know.

Helpful Update

Thank you Jonathan Wieben for explanation of why this isn't working (See Explanation). The issue simply has to do with the scope of the applied styles, and Headless UI's usage of the React Portal component. If anyone has some ideas of how I can either change where the Portal is rendered or change the scope of the styles, that would be super helpful. Jonathan Wieben pointed out a way to do this, however—from my testing—it doesn't work with Tailwind CSS.

2

5 Answers 5

11
+25

The Dialog component you are using renders in a portal (see here).

you typically want to render them as a sibling to the root-most node of your React application. That way you can rely on natural DOM ordering to ensure that their content is rendered on top of your existing application UI.

You can confirm this by inspecting your modal DOM element in your browser and seeing if it is indeed placed outside the div wrapper from your App component (I suspect it is).

If so, this is the explanation for why the modal content does not render with the expected font: It is rendered outside the component that defines the font.

To get around this, you could define your font on a higher level, e.g. in your head like described here: Next docs.

1
  • 3
    This makes sense, but this doesn't seem to work with TailwindCSS, unless I'm just doing it wrong. Do you know of a way to apply this on the TailwindCSS level?
    – andrilla
    Feb 20, 2023 at 16:51
5

I had the exact same problem with headlessui, tailwind and nextjs. I found the solution that was marked correctly way too complicated for something as simple as modal. What worked for me is to insert the same font into the Modal component:

//Modal.tsx
import { Dialog, Transition } from '@headlessui/react';
import { Rubik } from '@next/font/google';

const rubik = Rubik({
  subsets: ['latin'],
  variable: '--font-rubik',
});

type Props = {
  children: React.ReactNode;
  isOpen: boolean;
  closeModal: any;
};

const Modal = ({ children, isOpen, closeModal }: Props) => {
  return (
  <>
  <Transition ...>
    <Dialog ...>
    ...
        <Dialog.Panel
              className={`${rubik.variable} font-sans ...`}>
              ...
        </Dialog.Panel>
    </Dialog>
  </Transition>
  </>
    );
};
export default Modal;

Worked like a charm.

1
  • This is much better, and so simple. Thank you!
    – andrilla
    Mar 31, 2023 at 15:41
0

Finally a Solution

...though not perfect...

This solution works, but it doesn't allow us to take full advantage of the loading next/font. Thankfully, it is an easy solution for now.

Since the issue comes from @headlessui/react rendering the Modal component as a child of the <body> element, we need to apply the next/font-generated CSS variables on the <body> element, rather than the <div> element in the App component as shown in the next/font documentation.

Unfortunately, there's no way to add them in the same way you would with the <div> element. What we need to do, is use a more vanilla JavaScript approach, and apply the classes after the page has loaded using document.querySelector('body') and className.add().

Add Class function (optional)

For my solution, I'm using a custom function called addClass. This may not be necessary, but when I first tried body.classList.add(typefaceClasses), it said the string had incorrect characters.

If you want to use the addClass function, here it is:

/**
 * ### Add Class
 * - Adds the specified classes to the specified elements
 * @param {Element|HTMLElement|HTMLElement[]|NodeList|string|undefined} elements An HTML Element, an array of HTML Elements, a Node List, a string (as a selector for a querySelector)
 * @param {string|string[]} classes A string or an array of classes to add to each element
 */
export const addClass = (elements: Element | HTMLElement | HTMLElement[] | NodeList | string, classes: string | string[]) => {

  const elementsType = elements.constructor.name,
        classesType = classes.constructor.name

  let elementList: HTMLElement[] | undefined,
      classesList: string[] | undefined

  // * Convert elements to array
  // @ts-ignore elementsType verifies type
  if (elementsType === 'String') elementList = Array.from(document.querySelectorAll(elements)) // Selector
  // @ts-ignore elementsType varfies type
  if (elementsType.startsWith('HTML')) elementList = [elements] // One HTML Element
  // @ts-ignore elementsType verifies type
  if (elementsType === 'NodeList') elementList = Array.from(elements) // Multiple HTML Elements
  // @ts-ignore elementsType verifies type
  if (elementsType === 'Array') elementList = elements // Array of Elements

  // * Convert classes to array
  // @ts-ignore classesType verifies type
  if (classesType === 'String' && classes.split(' ')) classesList = classes.split(' ')
  // @ts-ignore classesType verifies type
  if (classesType === 'Array') classesList = classes

  if (elementList && classesList) elementList.forEach((element: HTMLElement) =>
    classesList!.forEach((classItem: string) => {
      if (hasClass(element, classItem)) return
      element.classList.add(classItem)
    })
  )
}

Adding Classes to the Body

As you can see in the following example, we use useEffect

// app.tsx

import '@/styles/globals.css'
import type { AppProps } from 'next/app'

import { Inter } from 'next/font/google'
const inter = Inter({
  subsets: ['latin'],
  variable: '--font-inter'
})

import localFont from 'next/font/local'
const appTakeoff = localFont({
  src: [
    {
      path: '../fonts/app-takeoff/regular.otf',
      weight: '400',
      style: 'normal'
    },
    {
      path: '../fonts/app-takeoff/regular.eot',
      weight: '400',
      style: 'normal'
    },
    {
      path: '../fonts/app-takeoff/regular.woff2',
      weight: '400',
      style: 'normal'
    },
    {
      path: '../fonts/app-takeoff/regular.woff',
      weight: '400',
      style: 'normal'
    },
    {
      path: '../fonts/app-takeoff/regular.ttf',
      weight: '400',
      style: 'normal'
    }
  ],
  variable: '--font-app-takeoff'
})

import { useEffect, useMemo } from 'react'

import { addClass } from '@/utils/class-management'

const App = ({ Component, pageProps }: AppProps) => {

  // Set an array of the classes (use string with classList.add())
  const typefaceClasses = useMemo(() => [
    inter.variable,
    appTakeoff.variable,
    'font-sans'
  ], [])

  useEffect(() => {
    // First we make sure the window is defined
    if (typeof window) {
      // Get the body element
      const body = document.querySelector('body')
      // If the body element is truthy, we add all of the classes to it
      // Otherwise null
      body ? addClass(body, typefaceClasses) : null
    }
  }, [typefaceClasses])

  return (
    <Component {...pageProps} />
  )
}

export default App

What Next?

Hopefully someone will come along one day and give a better solution, or perhaps the Next.js team will add support for the <body> element by default.

0

This is not an issue when using the App Directory. Now that it's out of beta, I highly recommend using it.

0
/********* external libraries ****************/
/********* external libraries ****************/

/********* internal libraries ****************/
import { Noto_Sans_TC } from '@next/font/google';
import CustomFont from '@next/font/local';
import type { NextPage } from 'next';
import type { AppProps } from 'next/app';
import Head from 'next/head';
import type { ReactElement, ReactNode } from 'react';
import './styles.css';

/********* internal libraries ****************/

export type NextPageWithLayout<P = unknown, IP = P> = NextPage<P, IP> & {
  getLayout?: (page: ReactElement) => ReactNode;
};

type AppPropsWithLayout = AppProps & {
  Component: NextPageWithLayout;
};

const notoSansTC = Noto_Sans_TC({
  weight: ['300', '400', '700', '900'],
  subsets: ['chinese-traditional'],
  display: 'swap',
});

const chappaFont = CustomFont({
  src: '../public/fonts/chappa-Black.ttf',
  variable: '--font-chappa',
});
const cubic11 = CustomFont({
  src: '../public/fonts/Cubic_11_1.013_R.ttf',
  variable: '--font-cubic11',
});

export default function CustomApp({
  Component,
  pageProps: { session, ...pageProps },
}: AppPropsWithLayout) {
  const getLayout = Component.getLayout ?? ((page) => page);

  return (
    <>
      <style jsx global>{`
        .body {
          font-family: ${notoSansTC.style.fontFamily};
        }

        .font-cubic11 {
          font-family: ${cubic11.style.fontFamily};
        }

        .font-chappa {
          font-family: ${chappaFont.style.fontFamily};
        }
      `}</style>
      <Head>
        <title>Welcome</title>
      </Head>
      <div
        className={`${chappaFont.variable} ${cubic11.variable} ${notoSansTC.className}`}
      >
        {getLayout(<Component {...pageProps} />)}
      </div>
    </>
  );
}

I use Next docs and Next docs

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.