ReactでTheme UI Part1

Theme UIをReactで使うチュートリアル記事です。

この記事では簡易なWebページを作成します。超シンプルですが、実際にページを組むときに直面するあれこれ(グローバルスタイルやフォントなど)はカバーしてあります。

SandBoxに実行可能なDEMOを用意しましたが、解説はローカル開発を想定しています。また、以下は最低限Reactが動作する段階からの解説になります。

依存関係のインストール

まずは最低限必要なパッケージをインストールします。


yarn add theme-ui @emotion/core @mdx-js/react

ちなみにTheme UIは、以下を組み合わせたライブラリです。

Theme Object

まずテーマオブジェクトを作成する必要があります。これはスタイルが定義されているオブジェクトファイルです。大雑把に分別すると、最上位プロパティは以下の3つになります。

  • あらかじめ用意されているもの(spaceやfontsなど)
  • 自分で定義したもの(今回の例だとflexなど)
  • styles以下のオブジェクト(MDXで使用。次回の記事で解説)

以下のようなテーマオブジェクトを作成しました。

src/theme.js

export default {
  space: [0, 4, 8, 16, 32, 64, 128, 256, 512],
  fonts: {
    body:
      '"メイリオ", Meiryo, "MS Pゴシック", "MS PGothic", sans-serif',
    heading: '"Segoe UI", Roboto, sans-sefig',
    monospace: 'Menlo, monospace',
  },
  fontSizes: [12, 14, 16, 20, 24, 32, 48, 64, 96],
  fontWeights: {
    body: 400,
    heading: 700,
    bold: 700,
  },
  lineHeights: {
    body: 1.5,
    heading: 1.125,
  },
  colors: {
    text: '#000',
    background: '#fff',
    primary: '#07c',
    secondary: '#30c',
    muted: '#f6f6f6',
  },
  sizes: {
    container: 768,
  },
  breakpoints: ['40em', '56em', '64em'],
  flex: {
    header: {
      flexDirection: ['column', 'row'],
      textAlign: 'center',
    },
  },
  styles: {
    root: {
      fontFamily: 'body',
      lineHeight: 'body',
      fontWeight: 'body',
    },
    h1: {
      color: 'text',
      fontFamily: 'heading',
      lineHeight: 'heading',
      fontWeight: 'heading',
      fontSize: 5,
    },
    h2: {
      color: 'text',
      fontFamily: 'heading',
      lineHeight: 'heading',
      fontWeight: 'heading',
      fontSize: 4,
    },
    h3: {
      color: 'text',
      fontFamily: 'heading',
      lineHeight: 'heading',
      fontWeight: 'heading',
      fontSize: 3,
    },
    h4: {
      color: 'text',
      fontFamily: 'heading',
      lineHeight: 'heading',
      fontWeight: 'heading',
      fontSize: 2,
    },
    h5: {
      color: 'text',
      fontFamily: 'heading',
      lineHeight: 'heading',
      fontWeight: 'heading',
      fontSize: 1,
    },
    h6: {
      color: 'text',
      fontFamily: 'heading',
      lineHeight: 'heading',
      fontWeight: 'heading',
      fontSize: 0,
    },
    p: {
      color: 'text',
      fontFamily: 'body',
      fontWeight: 'body',
      lineHeight: 'body',
      fontSize: [0, 1, 2],
    },
    a: {
      color: 'primary',
    },
    pre: {
      fontFamily: 'monospace',
      overflowX: 'auto',
      code: {
        color: 'inherit',
      },
    },
    code: {
      fontFamily: "monospace",
      fontSize: "inherit"
    },
    blockquote: {
      mx: 0,
      px: [0, 2, 3],
    },
    table: {
      width: '100%',
      borderCollapse: 'separate',
      borderSpacing: 0,
    },
    th: {
      textAlign: 'left',
      borderBottomStyle: 'solid',
    },
    td: {
      textAlign: 'left',
      borderBottomStyle: 'solid',
    },
    img: {
      maxWidth: '100%',
    },
  },
};

最上位のプロパティ名はscaleTheme KeyVarinat Groupなどと呼ばれています。これらの中で、予め用意されているプロパティ名はCSSプロパティと対応しています。例えばtheme.colorsはCSSのcolor, background-color, border-colorに対応しているといった具合です。

対応するキーは参照時にTheme Keyを指定する必要はありません。

colors: {
	primary: '#333'
}
styles: {
	h1: {
		color: 'primary' // colors.primaryと記述しなくていい
	}
}

ThemeProvider

テーマオブジェクトは、ThemeProvidertheme propに渡すことで配下のコンポーネントで利用できます。このコンポーネントをApp.jsに定義します。

import React from 'react';
import { ThemeProvider, Styled } from 'theme-ui';
import Global from './components/Global';

import theme from './theme';
import Layout from './components/Layout';

const App = () => (
  <ThemeProvider theme={theme}>
    <Styled.root>
      <Global />
      <Layout />
    </Styled.root>
  </ThemeProvider>
);

export default App;

未定義コンポーネントもありますが、以降で触れていきます。また、テーマオブジェクトを渡しただけでは、配下のコンポーネントに即スタイルが適用されるわけではありません。

Global Fonts

Theme UIはグローバルのフォントの設定を行わないため、特に指定がなければ要素単位でフォントの設定が必要になります。これは面倒なので、Styles.rootというコンポーネントでラップしてフォントを設定します。

テーマオブジェクトにstyles.rootという項目があったのを確認してください。これがマッピングされます。

styles: {
    root: {
      fontFamily: 'body',
      lineHeight: 'body',
      fontWeight: 'body',
    },
    ...
}

Global Style

Theme UIではグローバルスコープにCSSを定義しませんが、必要なケースもあります。ThemeProviderより上位…例えばbodyの余白はこの方法でしか設定ができません。

グローバルスタイルはEmotionのGlobalコンポーネントを利用します。

// src/components/Global.js

import React from 'react';
import { Global } from '@emotion/core';

export default props => (
  <Global
    styles={theme => ({
      body: {
        margin: 0,
        padding: 0,
      },
    })}
  />
);

組み込みコンポーネント

@theme-ui/componentsというパッケージをインストールすると、定義済みコンポーネントが使用できます。これらはBoxなど一部コンポーネントを除いて、デフォルトのスタイルがセットされています(正しくは、後述のdefault variantが用意されているため)。

パッケージをインストールしておきます。


yarn add @theme-ui/components

レイアウトコンポーネント

画面に表示されるコンポーネントをラップするレイアウトコンポーネントを作成します。

/src/components/layout.js

/** @jsx jsx */
import { jsx } from 'theme-ui';
import {
  Container,
  Heading,
  Flex,
  NavLink,
  Link,
} from '@theme-ui/components';

const Layout = ({ children }) => (
  <div
    sx={{
      display: 'grid',
      gridTempleteColumns: '1fr',
      gridTemplateRows: 'auto 1fr auto',
      height: '100vh',
    }}
  >
    <header
      sx={{
        px: [0, 3, 4],
        py: [0, 3, 4],
        display: 'flex',
        flexDirection: ['column', 'row'],
        justifyContent: ['flex-start', 'space-between'],
      }}
    >
      <Heading sx={{ fontSize: 2 }} as="h1" p={2}>
        MyPage
      </Heading>
      <Flex as="nav" sx={{ variant: 'flex.header' }}>
        <NavLink href="#" p={2}>
          Blog
        </NavLink>
        <NavLink href="#" p={2}>
          About
        </NavLink>
      </Flex>
    </header>
    <main>
      <Container bg="muted" p={4}>
        Test
      </Container>
    </main>
    <footer
      sx={{
        px: 4,
        py: 4,
        mx: 'auto',
      }}
    >
      Theme UI Sample
      <Link href="https://twitter.com/__maeda">@mda</Link>
    </footer>
  </div>
);

export default Layout;

あえて統一感のない色々な方法でスタイルを定義しています。順番に見ていきましょう。

Custom Pragma

馴染みのない人は、1行目が不思議に思えるでしょう。

/** @jsx */
import { jsx } from 'theme-ui';

これは、JSXのコンパイル時にReact.createElementの代わりにEmotionのjsx関数を使用することをBabelに伝達する記述です。このあと解説するsx propを使用する場合は、これを1行目に必ず入れる必要があります。

sx prop

<div
  sx={{
    display: 'grid',
    gridTempleteColumns: '1fr',
    gridTemplateRows: 'auto 1fr auto',
    height: '100vh',
  }}
>

全体をラップするdivはグリッドレイアウトでページを組んでいます。このとき利用するのが、sx propです。sxはinline styleで定義するのとほぼ同じです。しかし、ハードコーティングするだけではメリットを感じられないはずです。

多少恩恵を感じられるのは以下でしょうか。

<Heading sx={{ fontSize: 2 }} as="h1" p={2}>
	MyPage
</Heading>

fontSize: 2font-size: 2pxではありません。テーマオブジェクトのfontSizeが対応しています。

fontSizes: [12, 14, 16, 20, 24, 32, 48, 64, 96]

配列fontSizes[2]16を指すため、fontSize: 16pxということです。

このHeadingは前述した組み込みコンポーネントです。pという独自のpropsがあり、これはpaddingを意味します(例えばdivpは機能しません)。このときの数値はテーマオブジェクトのspaceが該当します。

space: [0, 4, 8, 16, 32, 64, 128, 256, 512],

つまり上記ではpadding: 8pxというわけです。asstyles.h1で描画するという意味です。

他にもNavLinkLinkなどの独自コンポーネントを利用しています。各々の仕様は、ドキュメントに記載されています。

Breakpoints

Flexも独自コンポーネントです。これはvariantという機能で、テーマオブジェクトのflex.headerを参照してスタイルを定義しています。このオブジェクト自体のネーミングは開発者が自由に定義できます。

<Flex as="nav" sx={{ variant: 'flex.header' }}>
    <NavLink href="#" p={2}>
      Blog
     </NavLink>
    <NavLink href="#" p={2}>
      About
    </NavLink>
</Flex>      

ここではFlex Layoutでアイテムの主軸方向を設定しています。breakpointsは、レスポンシブデザインのブレークポイントを意味します。特定のプロパティ値に配列を使うと、ブレークポイントが参照されます。

// theme object
breakpoints: ['40em', '56em', '64em'],
...
flex: {
  header: {
    flexDirection: ['column', 'row'],
    textAlign: 'center',
  },
},

主軸方向を40emまでは行、56emからは列方向にスイッチさせています。ブレークポイントは3つありますが、今回は3つ目を定義していません。この場合は直前のスタイルが維持されます。つまり横幅が64emになっても主軸は列です。

尚、1-2つ目のブレークポイントは同じで3つ目のみ変更したいときは、2つ目を空にします。

flex: {
  header: {
    flexDirection: ['column', ,'row'],
  },
},

ブレークポイントはテーマオブジェクト内だけではなくsx prop内でも利用できます。

<header
  sx={{
    px: [0, 3, 4],
    py: [0, 3, 4],
    display: 'flex',
    flexDirection: ['column', 'row'],
    justifyContent: ['flex-start', 'space-between'],
  }}
>
	...
</header>

pxpadding-leftpadding-rightの短縮表記です。こういったショートハンドがいくつか用意されています。

ちなみにsxの中でvariantを指定する方法は、レイアウトでよく使用されるパターンです。Gatsby公式テーマにあるスタイルのShadowingは、この機能を利用しています。

Container

Containerも独自コンポーネントで、これはコンテナをラップするのに適しています。テーマオブジェクトのsizes.contaeinerに定義した値は、Containerの最大横幅に設定されます。

sizes: {
  container: 768,
},

尚、Container自体のスタイルはtheme.layout.containerで定義することもできます。デフォルトで定義されているContainerはtheme.layout.containerを参照するので、ここにスタイルを定義してすればすべてのContainerはそれを参照します。

variant

上記然り、組み込みコンポーネントはTheme Keyに対応するvariant groupが用意されているものがあります。例えば、Buttonコンポーネントはtheme.buttonsというVariant Groupに対応しています。尚、Buttonがデフォルトで使用するスタイルはbuttons.primaryです。

variantは言ってしまえば、スタイルのグループ化です。あらかじめ用意されているFontsなどは汎用的なVariant Groupとも言えるかもしれません。

組み込みコンポーネントはすべてvariantというpropを持っているため、theme.buttons.secondaryを定義しておけば、以下のようにsxを使用しなくてもスタイルを指定できます。

<Button variant="secondary">
	Button
</Button>

variant="buttons.secondary"と指定しなくていい点に注目してください。例えばFlexも組み込みコンポーネントですが、Varinat Groupを持っていないため、先のレイアウトコンポーネントで<Flex varinat="header">は機能しません。

実行

この時点で実行すると、超シンプルなWebページが確認できるはずです。

実行結果

Conclusion

時間をかけて記事を書いたのですが、Theme UIの良さは伝わらなかったでしょう(笑)。あれやこれやとスタイル定義方法に尺を取ったため、そこまで手が回りませんでした。

Theme UIのテーマオブジェクトはSystem UIという仕様に基づいており、できるだけ他のライブラリと併用できるように設計されています。これは少ない労力で一貫性のあるスタイルを維持するのに役立ちます。今回だと、例えばフォントやスペース、ブレークポイントなどでその一面が垣間見えたかもしれません。

制約ベースのデザイン、もしくはスタイルガイド駆動型開発とも言うようですね。CSS-in-JSの中でも面白いアプローチに思えます。

次回は今回の完成品にMDXを埋め込み、スタイリングまで行います。