🐃

Next.jsとEmotion & Tailwind (Twin.macro)で環境構築して保守性の高い快適なマークアップ体験を手に入れる。

2021/03/25に公開

なにを目指しているのか

優れたスタイリング体験

CSS-IN-JS ライブラリのEmotionと CSS のショートハンドライブラリのTailwind CSSを組み合わせて、SCSS の便利なやネストで記述できる従来の優れた CSS 記述の自由を残したまま、スコープが限定された保守性の高く Tailwind CSS の持つスピードでスタイリングできる現代的なマークアップ開発体験を目指します。

つまり、CSS-IN-JS と Tailwind CSS のいいとこ取りをします。見た方が早いのでコチラをご覧下さい。

Emotion で CSS コンポーネントを作成しつつ、CSS コンポーネントの中でtwキーワードを使って Tailwind CSS のショートハンドが使えます。

また、css={}でスタイルを当てることによって、グローバル汚染を防ぎつつ、HTML タグにスタイリングされているスタイルを一発(Ctl + クリック)で参照することができます。

twin.macro

Tailwind CSS のサポートライブラリとして twin.macro があります。CSS-IN-JS と Tailwind CSS を組み合わせる場合には、このライブラリを使うことになりますが、日本語,英語どちらも情報が少ないため今回、記事にしました。

この記事は twin.macro(Nextjs + Emotion)を TypeScript で環境構築した手順になっておりますので、twin.macroの情報が少なく困っている方も参考にしてください。
https://github.com/ben-rogerson/twin.examples/tree/master/next-emotion

テンプレート

Github に完成品をあげてあります。Emotion と Tailwind CSS のコンビに感動したらよろしくお願いします。

https://github.com/junseinagao/with-typescript-eslint-jest-emotion-tailwind-twin

環境構築手順

yarn create next-appで初期プロジェクトを立ち上げる。

Nextjs 公式の example からTypeScript、Eslint、Jest を導入した exampleを使ってyarn create next-appします。
https://github.com/vercel/next.js/tree/master/examples/with-typescript-eslint-jest

なぜ、この Boilerplate を使っているか

TypeScript と Eslint を導入するのがおっくうだからです。この 2 つを導入しないプロジェクトは存在しないと思うので手っ取り早くこれを使います。また、lint-staged と husky によるフックスクリプトもセットアップしてくれてます。(Commit 時に自動で整形をかけてくれる。)Jest が余分だと思ったら後で取り除きましょう。

yarn create next-app --example with-typescript-eslint-jest 自分のプロジェクト名

pagesフォルダをsrcフォルダ下で管理する。

管理しやすくするためにpagesフォルダをsrcフォルダ直下に移動する。また、型定義ファイルを管理するためにtypesフォルダをsrcフォルダ直下に作成する。

VSCode 上で作成するか、以下のコマンドを使用する。

mkdir src src/types
mv ./pages ./src/pages

③Direct Path(絶対パス)を有効にする。

component をimportする際に、相対パスで指定していたのでは保守性が薄まるので、絶対パスを使える様にする。

yarn でbabel-plugin-module-resolverを追加する。

yarn add -D babel-plugin-module-resolver

.babelrc.babelrc.jsonに変えて、以下の様に記述する。

.babelrc.json
{
  "presets": ["next/babel"],
  "plugins": [
    "@emotion/babel-plugin",
    "babel-plugin-macros",
    [
      "module-resolver",
      {
        "root": ["."],
        "alias": {
          "~": "./src"
        }
      }
    ]
  ]
}

tsconfig.jsonに以下の記述を追加する。

tsconfig.json
  "compilerOptions": {
    ...
+   "baseUrl": ".",
+   "paths": {
+     "~/*": ["src/*"]
    }
  },

この結果、import { ... } from '~/'と記述するとsrc/を参照してくれるようになる。

サンプルとして、`test/pages/index.test.tsx`の import 文の記述を絶対パスに変更してみる。
test/pages/index.test.tsx
- import { Home } from '../../src/pages/index'
+ import { Home } from '~/pages/index'

無事に絶対パスを解決して、Ctl + clcik等でも参照できるのが確認できる。

④Twin.macro を使って Tailwind CSS と Emotion を上手く導入する。

ここからが本番

Tailwind CSS を Emotion の CSS オブジェクトの中で使いたいので、Twin.macro という Tailwind CSS と他の CSS-IN-JS ライブラリの仲を保ってくれるライブラリを使用して、Tailwind CSS と Emotion を導入します。

Install the dependencies

必要なモジュールを yarn で追加する。

yarn add @emotion/react @emotion/styled @emotion/css @emotion/server
yarn add -D twin.macro tailwindcss postcss@latest autoprefixer@latest @emotion/babel-plugin babel-plugin-macros

Tailwind CSS の config を npx で作成する。(後でも構わない。)

npx tailwindcss-cli@latest init -p

twin.macroGlobalStylesを読み込む

Twin がTailwind CSS 専用のリセット CSSに加えて、不具合を解消したリセット CSS を用意してくれているので、これを読み込む。

読み込むためにカスタムAppを作成する。src/pages_app.tsxというファイルを作り以下の様に記述する。

src/pages/_app.tsx
import Head from 'next/head'
import { GlobalStyles } from 'twin.macro'
import { AppProps } from 'next/app'

const App = ({ Component, pageProps }: AppProps) => (
  <>
    <Head>
      <title>
        Nextjs App with TypeScript, ESlint, Jest, Emotion, Tailwind and Twin
      </title>
      <link rel="icon" href="/favicon.ico" />
    </Head>
    <GlobalStyles />
    <Component {...pageProps} />
  </>
)

また、TypeScript を生かすために、JSX.Elementの Props の型が明示されていないとエラーを出すように、ESlint のルールを変更する。

.esllintrc.json
  "rules": {
-   "react/prop-types": 0,
+   "@typescript-eslint/explicit-module-boundary-types": "off",
  }

Flicker 現象の対策をする。

初期 render 時にページがちらつく場合があるそうなので、重要なスタイルを最初に読み込むためにカスタムDocumentを作成する。

src/pages_document.tsxというファイルを作り以下の様に記述する。

src/pages/_document.tsx
import Document, {
  Html,
  Head,
  Main,
  NextScript,
  DocumentContext,
} from 'next/document'
import { extractCritical } from '@emotion/server'

export default class MyDocument extends Document {
  static async getInitialProps(ctx: DocumentContext) {
    const initialProps = await Document.getInitialProps(ctx)
    const page = await ctx.renderPage()
    const styles = extractCritical(page.html)
    return {
      ...initialProps,
      ...page,
      styles: (
        <>
          {initialProps.styles}
          <style
            data-emotion-css={styles.ids.join(' ')}
            dangerouslySetInnerHTML={{ __html: styles.css }}
          />
        </>
      ),
    }
  }

  render() {
    return (
      <Html lang="ja">
        <Head />
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}

Twin の設定をpackage.jsonに記述する。

package.jsonに以下の記述を追加する。

package.json
  "lint-staged": {
    ...
  },
+ "babelMacros": {
+   "twin": {
+     "preset": "emotion"
+   }
+ },
  "dependencies": {
    ...
  },
  ...

.babelrc.jsonを記述する。

.babelrc.jsonに以下の記述を追加する。これが.babelrc.jsonの最終的な形になる。

.babelrc.json
{
  "presets": [
+   [
-     "next/babel"
+     "next/babel",
+     {
+       "preset-react": {
+         "runtime": "automatic",
+         "importSource": "@emotion/react"
+       }
+     }
+   ]
  ],
  "plugins": [
    "@emotion/babel-plugin",
    "babel-plugin-macros",
    [
      "module-resolver",
      {
        "root": ["."],
        "alias": {
          "~": "./src"
        }
      }
    ]
  ]
}

Twin の型定義ファイルを記述する。

Twin の型定義ファイルを記述するために、src/typestwin.d.tsを作成し、以下の様に記述する。

src/types/twin.d.ts
import 'twin.macro'
import styledImport from '@emotion/styled'
import { css as cssImport } from '@emotion/react'

declare module 'twin.macro' {
  const styled: typeof styledImport
  const css: typeof cssImport
}

tsconfig.jsonを編集してtwin.d.tsを読み込む。以下の様に記述を追加する。これが.babelrc.jsonの最終的な形になる。

tsconfig.json
{
  "compilerOptions": {
    ...
  },
+ "files": ["src/types/twin.d.ts"],
  "exclude": ["node_modules", ".next", "out"],
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js"]
}

以上でいったん、Twin を使った Tailwind と Emotion の導入は完了。

tsxコンポーネントで以下の様に記述してマークアップしていく

import tw, { css } from "twin.macro";
<div className={sample}>
const sample = css`
  ${tw`bg-red-500`}
`;

⑤Tailwind CSS の候補を表示するためにTailwind Twin IntelliSenseを VSCode にインストールする。

Twin を使う場合は、この拡張機能なしではやってられない。逆に入れた場合は最高の体験を獲得できます。

https://marketplace.visualstudio.com/items?itemName=lightyen.tailwindcss-intellisense-twin

index.tsx を Emotion と Tailwind を使って書き直す。

例として boilerplate のindex.tsxを Twin(Tailwind with Emotion)を使った記述に書き換えてみます。

グローバル CSS を_app.tsxに記述する。

index.tsx<style global jsx>を使って書かれているグローバル CSS を_app.tsxに移す。ついでに不要な<Head>を取り除く。

src/pages/index.tsx
src/pages/index.tsx
- import Head from 'next/head'
import Image from 'next/image'

export const Home = (): JSX.Element => (
  <div className="container">
-   <Head>
-     <title>Create Next App</title>
-     <link rel="icon" href="/favicon.ico" />
-   </Head>

    <main>
      <h1 className="title">
        Welcome to <a href="https://nextjs.org">Next.js!</a>
      </h1>

      <p className="description">
        Get started by editing <code>pages/index.tsx</code>
      </p>

      <button
        onClick={() => {
          window.alert('With typescript and Jest')
        }}
      >
        Test Button
      </button>

      <div className="grid">
        <a href="https://nextjs.org/docs" className="card">
          <h3>Documentation &rarr;</h3>
          <p>Find in-depth information about Next.js features and API.</p>
        </a>

        <a href="https://nextjs.org/learn" className="card">
          <h3>Learn &rarr;</h3>
          <p>Learn about Next.js in an interactive course with quizzes!</p>
        </a>

        <a
          href="https://github.com/vercel/next.js/tree/master/examples"
          className="card"
        >
          <h3>Examples &rarr;</h3>
          <p>Discover and deploy boilerplate example Next.js projects.</p>
        </a>

        <a
          href="https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
          className="card"
        >
          <h3>Deploy &rarr;</h3>
          <p>Instantly deploy your Next.js site to a public URL with Vercel.</p>
        </a>
      </div>
    </main>

    <footer>
      <a
        href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
        target="_blank"
        rel="noopener noreferrer"
      >
        Powered by{' '}
        <Image src="/vercel.svg" alt="Vercel Logo" height={'32'} width={'64'} />
      </a>
    </footer>

    <style jsx>{`
      .container {
        min-height: 100vh;
        padding: 0 0.5rem;
        display: flex;
        flex-direction: column;
        justify-content: center;
        align-items: center;
      }

      main {
        padding: 5rem 0;
        flex: 1;
        display: flex;
        flex-direction: column;
        justify-content: center;
        align-items: center;
      }

      footer {
        width: 100%;
        height: 100px;
        border-top: 1px solid #eaeaea;
        display: flex;
        justify-content: center;
        align-items: center;
      }

      footer img {
        margin-left: 0.5rem;
      }

      footer a {
        display: flex;
        justify-content: center;
        align-items: center;
      }

      a {
        color: inherit;
        text-decoration: none;
      }

      .title a {
        color: #0070f3;
        text-decoration: none;
      }

      .title a:hover,
      .title a:focus,
      .title a:active {
        text-decoration: underline;
      }

      .title {
        margin: 0;
        line-height: 1.15;
        font-size: 4rem;
      }

      .title,
      .description {
        text-align: center;
      }

      .description {
        line-height: 1.5;
        font-size: 1.5rem;
      }

      code {
        background: #fafafa;
        border-radius: 5px;
        padding: 0.75rem;
        font-size: 1.1rem;
        font-family: Menlo, Monaco, Lucida Console, Liberation Mono,
          DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace;
      }

      .grid {
        display: flex;
        align-items: center;
        justify-content: center;
        flex-wrap: wrap;

        max-width: 800px;
        margin-top: 3rem;
      }

      .card {
        margin: 1rem;
        flex-basis: 45%;
        padding: 1.5rem;
        text-align: left;
        color: inherit;
        text-decoration: none;
        border: 1px solid #eaeaea;
        border-radius: 10px;
        transition: color 0.15s ease, border-color 0.15s ease;
      }

      .card:hover,
      .card:focus,
      .card:active {
        color: #0070f3;
        border-color: #0070f3;
      }

      .card h3 {
        margin: 0 0 1rem 0;
        font-size: 1.5rem;
      }

      .card p {
        margin: 0;
        font-size: 1.25rem;
        line-height: 1.5;
      }

      @media (max-width: 600px) {
        .grid {
          width: 100%;
          flex-direction: column;
        }
      }
    `}</style>

-   <style jsx global>{`
-     html,
-     body {
-       padding: 0;
-       margin: 0;
-       font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
-         Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
-     }
-
-     * {
-       box-sizing: border-box;
-     }
-   `}</style>
  </div>
)

export default Home

Emotion の<Global>を用いてグローバル CSS を適用する。

src/pages/\_app.tsx
src/pages/_app.tsx
import Head from 'next/head'
import { GlobalStyles, css } from 'twin.macro'
+ import { Global } from '@emotion/react'
import { AppProps } from 'next/app'

const App = ({ Component, pageProps }: AppProps) => (
  <>
    <Head>
      <title>
        Nextjs App with TypeScript, ESlint, Jest, Emotion, Tailwind and Twin
      </title>
      <link rel="icon" href="/favicon.ico" />
    </Head>
    <GlobalStyles />
+   <Global styles={globalStyles} />
    <Component {...pageProps} />
  </>
)

+const globalStyles = css`
+  html,
+  body {
+    padding: 0;
+    margin: 0;
+    font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
+      Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
+  }
+
+  * {
+    box-sizing: border-box;
+  }
+`

export default App

<style jsx>で記述されている css を Emotion の CSS コンポーネントに置き換える。

ザッと css で置き換えてみました。(かなり適当に)

src/pages/index.tsx
src/pages/index.tsx
import Image from 'next/image'
import { css } from 'twin.macro'

export const Index = () => (
  <div css={container}>
    <section css={main}>
      <h1 css={[title, title_and_description]}>
        Welcome to <a href="https://nextjs.org">Next.js!</a>
      </h1>

      <p css={[description, title_and_description]}>
        Get started by editing <code>pages/index.tsx</code>
      </p>

      <button
        onClick={() => {
          window.alert(
            'With TypeScript, ESlint, Jest, Emotion, Tailwind and Twin'
          )
        }}
      >
        Test Button
      </button>

      <div css={grid}>
        <a href="https://nextjs.org/docs" css={[a, card]}>
          <h3>Documentation &rarr;</h3>
          <p>Find in-depth information about Next.js features and API.</p>
        </a>

        <a href="https://nextjs.org/learn" css={[a, card]}>
          <h3>Learn &rarr;</h3>
          <p>Learn about Next.js in an interactive course with quizzes!</p>
        </a>

        <a
          href="https://github.com/vercel/next.js/tree/master/examples"
          css={[a, card]}
        >
          <h3>Examples &rarr;</h3>
          <p>Discover and deploy boilerplate example Next.js projects.</p>
        </a>

        <a
          href="https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
          css={[a, card]}
        >
          <h3>Deploy &rarr;</h3>
          <p>Instantly deploy your Next.js site to a public URL with Vercel.</p>
        </a>
      </div>
    </section>

    <footer css={footer}>
      <a
        href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
        target="_blank"
        rel="noopener noreferrer"
        css={a}
      >
        Powered by{' '}
        <Image src="/vercel.svg" alt="Vercel Logo" height={'32'} width={'64'} />
      </a>
    </footer>
  </div>
)

const container = css`
  min-height: 100vh;
  padding: 0 0.5rem;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
`

const main = css`
  padding: 5rem 0;
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
`

const footer = css`
  width: 100%;
  height: 100px;
  border-top: 1px solid #eaeaea;
  display: flex;
  justify-content: center;
  align-items: center;

  & img {
    margin-left: 0.5rem;
  }

  & a {
    display: flex;
    justify-content: center;
    align-items: center;
  }
`

const a = css`
  color: inherit;
  text-decoration: none;
`

const title = css`
  margin: 0;
  line-height: 1.15;
  font-size: 4rem;

  & a {
    color: #0070f3;
    text-decoration: none;
  }

  & a:hover,
  & a:focus,
  & a:active {
    text-decoration: underline;
  }
`

const title_and_description = css`
  text-align: center;
`

const description = css`
  line-height: 1.5;
  font-size: 1.5rem;
`

const grid = css`
  display: flex;
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;

  max-width: 800px;
  margin-top: 3rem;

  @media (max-width: 600px) {
    width: 100%;
    flex-direction: column;
  }
`

const card = css`
  margin: 1rem;
  flex-basis: 45%;
  padding: 1.5rem;
  text-align: left;
  color: inherit;
  text-decoration: none;
  border: 1px solid #eaeaea;
  border-radius: 10px;
  transition: color 0.15s ease, border-color 0.15s ease;

  &:hover,
  &:focus,
  &:active {
    color: #0070f3;
    border-color: #0070f3;
  }

  & h3 {
    margin: 0 0 1rem 0;
    font-size: 1.5rem;
  }

  & p {
    margin: 0;
    font-size: 1.25rem;
    line-height: 1.5;
  }
`

export default Index

この様に Emotion のcssコンポーネントを使って、生 CSS や SCSS と近い書き心地で CSS を記述することができるのが、この構成の強みです。

スタイルの変わっている部分を Tailwind を使って書き換える。

Prowered by ▲Vercel<img>に当ててるスタイルが聞いていないので、書き換えてみます。新しく書き換えるので、Tailwind を使っていきましょう。また、Test Buttonに新しくスタイルを当ててみます。

src/pages/index.tsx
src/pages/index.tsx
import Image from 'next/image'
- import { css } from 'twin.macro'
+ import tw, { css } from 'twin.macro'

export const Index = () => (
  <div css={container}>
    <section css={main}>
      <h1 css={[title, title_and_description]}>
        Welcome to <a href="https://nextjs.org">Next.js!</a>
      </h1>
      <p css={[description, title_and_description]}>
        Get started by editing <code>pages/index.tsx</code>
      </p>

      <button
+       css={testButton}
        onClick={() => {
          window.alert(
            'With TypeScript, ESlint, Jest, Emotion, Tailwind and Twin'
          )
        }}
      >
        Test Button
      </button>
      <div css={grid}>
        <a href="https://nextjs.org/docs" css={[a, card]}>
          <h3>Documentation &rarr;</h3>
          <p>Find in-depth information about Next.js features and API.</p>
        </a>
        <a href="https://nextjs.org/learn" css={[a, card]}>
          <h3>Learn &rarr;</h3>
          <p>Learn about Next.js in an interactive course with quizzes!</p>
        </a>
        <a
          href="https://github.com/vercel/next.js/tree/master/examples"
          css={[a, card]}
        >
          <h3>Examples &rarr;</h3>
          <p>Discover and deploy boilerplate example Next.js projects.</p>
        </a>
        <a
          href="https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
          css={[a, card]}
        >
          <h3>Deploy &rarr;</h3>
          <p>Instantly deploy your Next.js site to a public URL with Vercel.</p>
        </a>
      </div>
    </section>
    <footer css={footer}>
      <a
        href="https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app"
        target="_blank"
        rel="noopener noreferrer"
        css={a}
      >
-       <span>Powered by</span>
+       <span tw="mr-2">Powered by</span>
        <Image src="/vercel.svg" alt="Vercel Logo" height={'32'} width={'64'} />
      </a>
    </footer>
  </div>
)
const container = css`
  min-height: 100vh;
  padding: 0 0.5rem;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
`
const main = css`
  padding: 5rem 0;
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
`
const footer = css`
  width: 100%;
  height: 100px;
  border-top: 1px solid #eaeaea;
  display: flex;
  justify-content: center;
  align-items: center;
  & img {
    margin-left: 0.5rem;
  }
  & a {
    display: flex;
    justify-content: center;
    align-items: center;
  }
`

+ const testButton = css`
+ ${tw`mt-8 bg-blue-500 text-white rounded px-2 py-1`}
+ font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace;
+ `

const a = css`
  color: inherit;
  text-decoration: none;
`
const title = css`
  margin: 0;
  line-height: 1.15;
  font-size: 4rem;
  & a {
    color: #0070f3;
    text-decoration: none;
  }
  & a:hover,
  & a:focus,
  & a:active {
    text-decoration: underline;
  }
`
const title_and_description = css`
  text-align: center;
`
const description = css`
  line-height: 1.5;
  font-size: 1.5rem;
`
const grid = css`
  display: flex;
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;
  max-width: 800px;
  margin-top: 3rem;
  @media (max-width: 600px) {
    width: 100%;
    flex-direction: column;
  }
`
const card = css`
  margin: 1rem;
  flex-basis: 45%;
  padding: 1.5rem;
  text-align: left;
  color: inherit;
  text-decoration: none;
  border: 1px solid #eaeaea;
  border-radius: 10px;
  transition: color 0.15s ease, border-color 0.15s ease;
  &:hover,
  &:focus,
  &:active {
    color: #0070f3;
    border-color: #0070f3;
  }
  & h3 {
    margin: 0 0 1rem 0;
    font-size: 1.5rem;
  }
  & p {
    margin: 0;
    font-size: 1.25rem;
    line-height: 1.5;
  }
`
export default Index

この様に簡単な変更は、直接 inline でtw属性を使って記述したり

<span tw="mr-2">Powered by</span>

この様に Tailwind だけでは表現しきれない複雑なスタイリングは Emotion の css コンポーネントを使って記述することができます。

<button
  css={testButton}
  onClick={() => {
    window.alert("With TypeScript, ESlint, Jest, Emotion, Tailwind and Twin");
  }}>
  Test Button
</button>
const testButton = css`
  ${tw`mt-8 bg-blue-500 text-white rounded px-2 py-1`}
  font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace;
`;

参考文献

https://qiita.com/oedkty/items/b911b7f3949cd1562fb7
https://github.com/vercel/next.js/tree/canary/examples/with-typescript-eslint-jest
https://qiita.com/mk668a/items/e98c8cff3b6cdbd7d6a3
https://github.com/ben-rogerson/twin.examples/tree/master/next-emotion
https://github.com/ben-rogerson/twin.macro/discussions/227

GitHubで編集を提案

Discussion