Next.jsとEmotion & Tailwind (Twin.macro)で環境構築して保守性の高い快適なマークアップ体験を手に入れる。
なにを目指しているのか
優れたスタイリング体験
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
の情報が少なく困っている方も参考にしてください。
テンプレート
Github に完成品をあげてあります。Emotion と Tailwind CSS のコンビに感動したら★よろしくお願いします。
環境構築手順
yarn create next-app
で初期プロジェクトを立ち上げる。
①Nextjs 公式の example からTypeScript、Eslint、Jest を導入した exampleを使ってyarn create next-app
します。
なぜ、この 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
に変えて、以下の様に記述する。
{
"presets": ["next/babel"],
"plugins": [
"@emotion/babel-plugin",
"babel-plugin-macros",
[
"module-resolver",
{
"root": ["."],
"alias": {
"~": "./src"
}
}
]
]
}
tsconfig.json
に以下の記述を追加する。
"compilerOptions": {
...
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["src/*"]
}
},
この結果、import { ... } from '~/'
と記述するとsrc/
を参照してくれるようになる。
サンプルとして、`test/pages/index.test.tsx`の import 文の記述を絶対パスに変更してみる。
- 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.macro
のGlobalStyles
を読み込む
Twin がTailwind CSS 専用のリセット CSSに加えて、不具合を解消したリセット CSS を用意してくれているので、これを読み込む。
読み込むためにカスタムApp
を作成する。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 のルールを変更する。
"rules": {
- "react/prop-types": 0,
+ "@typescript-eslint/explicit-module-boundary-types": "off",
}
Flicker 現象の対策をする。
初期 render 時にページがちらつく場合があるそうなので、重要なスタイルを最初に読み込むためにカスタムDocument
を作成する。
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>
)
}
}
package.json
に記述する。
Twin の設定をpackage.json
に以下の記述を追加する。
"lint-staged": {
...
},
+ "babelMacros": {
+ "twin": {
+ "preset": "emotion"
+ }
+ },
"dependencies": {
...
},
...
.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/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
の最終的な形になる。
{
"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 Twin IntelliSense
を VSCode にインストールする。
⑤Tailwind CSS の候補を表示するためにTwin を使う場合は、この拡張機能なしではやってられない。逆に入れた場合は最高の体験を獲得できます。
index.tsx を Emotion と Tailwind を使って書き直す。
例として boilerplate のindex.tsx
を Twin(Tailwind with Emotion)を使った記述に書き換えてみます。
_app.tsx
に記述する。
グローバル CSS をindex.tsx
で<style global jsx>
を使って書かれているグローバル CSS を_app.tsx
に移す。ついでに不要な<Head>
を取り除く。
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 →</h3>
<p>Find in-depth information about Next.js features and API.</p>
</a>
<a href="https://nextjs.org/learn" className="card">
<h3>Learn →</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 →</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 →</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
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
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 →</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 →</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 →</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 →</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
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 →</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 →</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 →</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 →</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;
`;
参考文献
Discussion