🦄

UIライブラリを簡単に構築するためのシステム『YUIS』を作成・公開しました

2022/11/04に公開

まず公開しているもののご紹介からです。

リポジトリと、実際にYUISを利用して構成したサンプルページを公開しています。

https://github.com/PK-Yakkun/YUIS

https://yuis.vercel.app/

🗒 この記事について

こんな方に届けば嬉しい

  • フロントのコンポーネント設計・デザイン設計にお悩みの方
  • 再利用性の高い柔軟なUIライブラリを自作したい方
  • UIライブラリを作成するもレスポンシブやCSS周りの再利用性を考慮した設計が原因で投げ出した方(私です)
  • MUIやChakraUIライクなUIライブラリを自作したい方
  • Atomic Designの理解を深めたい方

この記事に書くこと

GithubのREADMEと重複する内容もありますが、こちらでは意図や設計思想的な部分まで、解説メインで書きたいなと思っています。

  • 作成したものと作成意図
  • 導入方法
  • 設計思想の解説
    • サンプルから見るCSS Mixinの管理について
    • 各機能の仕組み解説
    • Atomic Design的思想について
  • 参考記事

非常にボリューミーな内容となっておりますので、本体だけ気になる方はGithubへ飛ぶことを推奨します。

中身が気になる方は読んでいただき、それも必要な箇所を適宜読み飛ばすのが精神的に楽だと思います...

🔨 なにを作ったのか、YUISとはなにか

Next.js(TypeScript)を前提とした際に利用できる、UIコンポーネントライブラリを構築するための基盤を作成しました。

YUIS自体はUIライブラリではなく、あくまでも設計思想とその基盤のようなものです。

そしてこれは完成されたシステムではなく、私なりに構築したUI設計に近いです(つまりオレオレ)。
なので構造や仕組みを流用し、みなさまがご都合の良いようにカスタマイズすることを前提としています。

ちなみに名前は『Yakkun UI System』の略です...
名前があったほうがかっこいいし、テンションが上がります。

💬 なぜ作成したのか

そもそものきっかけは自分で利用するためです。

UIを構築するにあたって銀の弾丸は存在しておらず、規模やスキル、チームメンバーに応じてベストな選択肢は変わってくると思います。

  • Reactに移行するけどCSSに慣れている(JSに不慣れな)デザイナーがいるからCSSの文法がいい
  • エンジニア主体で開発するためCSS in JSを使いたい
  • MUIやChakraUIを採用して効率よく開発したい
  • 勉強のためすべて1から構築したい
    • などなど...

そんな中、せめて個人開発をする上で便利な基盤があればいいなと思ったことがきっかけです。
そしてそれが誰かの役に立ったり、逆にアドバイスをいただいたりしてより良いものになれば嬉しいなと思っています。

意識したこと

  • 依存関係を少なくしたい
  • MUIライクにスタイリングしたい
  • Atomic Designを前提としたい

おもにこの三軸です。

利用するライブラリが増えるとコンパイルの設定を追加したり、複雑さが増します。
まずはそれを可能な限りクリアな状態に保ち、環境に依存されにくいことを目指しました。

次にスタイリング方法です。

MUIは本当に便利で、私もとあるプロジェクトで採用しました。

ただ優秀すぎるだけに機能が多く、個人で開発する分にはなかなか活かしきることができません。

であれば、自分用に作成したミニマムなものがあればいい、そしてそれがMUIライクに構築できるならなおよしと思った次第です。

最後にデザイン設計ですが、やはりReactを利用する上ではAtomic Designが個人的に利にかなっていると思います。

なにより再利用を前提とした思想とのシナジーが高く、設計こそ難しいですが基盤さえあれば、スケールアップの効率はぐんぐん上がっていくのかなと感じています。

💪 YUISでなにができるのか

  • 少ない依存関係でUIコンポーネントライブラリを構築できる
    • 詳細は後述しますがemotionのみ利用しています
  • MUIライクにスタイリングが可能(になる仕組みを利用できる)
    • 例: <Box w="100px" bg="#333" p={4} mb={2}>
  • propsごとにレスポンシブに関するスタイルを付与することが可能(になる仕組みを利用できる)
    • 例: <Box w={{sm: "100px", md: "200px"}}>
  • Atomic Designに則った設計でUIを構築できる
    • ディレクトリ構造とコンポーネントのサンプルを提供しています
  • グローバルに定義された要素が既にある&編集可能
    • color, font, space, breakpoint
    • 複数Themeの切り替えサンプルあり

おもに上記項目が魅力かなと思っています。

例にある<Box>コンポーネントそのものを提供しているわけではなく、あくまでも「そういった機能をもつコンポーネントを作ることができる仕組み」が用意されているということにご注意ください。

(とはいえサンプルとしてすでに<Box>を作成しておりますが...参考になれば幸いです)

📚 導入方法

繰り返しになりますが、YUISはライブラリではないためnpmパッケージ化しているというわけではありません。

下記リポジトリから/srcとその配下をごそっとコピーしてご利用いただければと思います。

https://github.com/PK-Yakkun/YUIS

1-A. Next.jsのプロジェクトが既にある場合

srcディレクトリをコピー

npxコマンドで初期生成されるpages, stylessrc配下にあり、その他ディレクトリもまとめてあるので置換していただければOKです。

1-B. Next.jsのプロジェクトがない場合

環境構築

下記コマンドを実行し、Next.js + TypeScriptの環境を生成します。

{app-name}はお好きなアプリケーション名を入力してください。

npx create-next-app {app-name} --use-npm --ts

2. 必要なパッケージをインストール

下記コマンドを実行し、ふたつのパッケージをインストールします。
個人的にhuskyなども入れていますが、基本はこのふたつがあれば大丈夫です。

npm i @emotion/react @emotion/styled

huskyに関しても記事を書いているので、気になる方はご一読ください。

(いつの間にかビルドエラー現象を回避したい方は導入を推奨します。)

https://zenn.dev/pk_yakkun/articles/053637c8da5011

3. tsconfigでエイリアスの設定

import文で絶対パスを利用するため、tsconfig.jsonに下記を追記します。

tsconfig.json
{
  "compilerOptions": {
    // 省略
    "baseUrl": "./src",
    "paths": {
      "@/*": ["./*"]
    }
  },
   // 省略
}

手順としては以上になります。(かんたん!)

🔎 ソースベースで解説

Boxコンポーネントを見る

まずはすべての基礎となる<Box>から見ていきます。
すでにサンプルとしてGithub上にありますが、もっと簡易的に考えてみます。

今のゴールは「wpropsを持ってwidthを指定できる<div>を作ること」とします。
つまり、下記のように扱えればOKということです。

✅ 横幅が100pxの<div>になる
<Box w="100px">
  ここにコンテンツが入ります。
</Box>

ではこのようなコンポーネントを作成する手順を書いていきます。

ちなみにAtomic Designについてはあとから熱弁いたしますので、ディレクトリ構成についてはいったん「そういうもの」とご認識ください。

/components/atoms/Box/Box.tsx
import styled from "@emotion/styled";
import { LayoutProps, layoutMixin } from "@/styles/mixins/layout";

export type BoxStyleProps = Partial<LayoutProps>

export const BoxStyled = styled.div<BoxStyleProps>(
  layoutMixin
);

export type BoxProps = BoxStyleProps & {
  children?: React.ReactNode;
};
export const Box = (props: BoxProps) => {
  return <BoxStyled {...props} />;
};

importしているものは下記の3つです。

  • emotion/styled
    • styled-componentを作成するために使う
  • layoutMixin
    • CSSを生成する関数
  • LayoutProps
    • layoutMixinに使用する型

次に型定義ですが、ここでは下記のようにしてBoxStyleProps型を定義しました。

型定義のところ抜粋
export type BoxStyleProps = Partial<LayoutProps>

Partial<T>の形を取ることで型Tの項目をすべてオプショナルとして継承することができます。

LayoutPropsの中身は下記です。

styles/mixins/layout.tsから抜粋
export type Size = string | number | BreakPointProps;

export type LayoutProps = {
  w: Size;
  h: Size;
  maxW: Size;
  maxH: Size;
  display: CSS.Property.Display | BreakPointProps;
  overflow: CSS.Property.Overflow | BreakPointProps;
  overflowX: CSS.Property.OverflowX | BreakPointProps;
  overflowY: CSS.Property.OverflowY | BreakPointProps;
};

型の中身はさておき、とにかくw, hなどレイアウトに関するCSSを生成するための値がまとまっています。

メインとなるlayoutMixin関数は上記のLayoutProps型の引数を取り、whなどをもとにwidth,heightをCSSとして生成します。

それをBoxStyledに渡しています。

抜粋
export const BoxStyled = styled.div<BoxStyleProps>(
  layoutMixin
);

もともとstyled-componentsのご利用経験がある方は見慣れているかと思います。

styled.div()の括弧にはCSSを渡すことができ、そのCSSを持った<div>が生成されます。

styled.p()であれば、渡されたCSSを持った<p>が生成される...といった感じです。

()にはリテラルでCSSを書くこともできますが、オブジェクトCSSや関数を渡すこともできます。

今回の場合、layoutMixin関数を渡しています。

ではlayoutMixin関数の中身を見てみます。

/styles/mixins/layout.ts
import * as CSS from "csstype";
import { css } from "@emotion/react";
import { BreakPointProps } from "@/types/responsive";
import { createResponsiveStyle } from "@/lib/responsive";

export type Size = string | number | BreakPointProps;

export type LayoutProps = {
  w: Size;
  h: Size;
  maxW: Size;
  maxH: Size;
  display: CSS.Property.Display | BreakPointProps;
  overflow: CSS.Property.Overflow | BreakPointProps;
  overflowX: CSS.Property.OverflowX | BreakPointProps;
  overflowY: CSS.Property.OverflowY | BreakPointProps;
};

export const layoutMixin = ({
  w,
  h,
  maxW,
  maxH,
  display,
  overflow,
  overflowX,
  overflowY,
}: Partial<LayoutProps>) => {
  return css(
    w != null && typeof w === "string"
      ? `width: ${w};`
      : typeof w === "object" && createResponsiveStyle("width", w.sm, w.md),
    h != null && typeof h === "string"
      ? `height: ${h};`
      : typeof h === "object" && createResponsiveStyle("height", h.sm, h.md),
    maxW != null && typeof maxW === "string"
      ? `max-width: ${maxW};`
      : typeof maxW === "object" &&
          createResponsiveStyle("maxWidth", maxW.sm, maxW.md),
    maxH != null && typeof maxH === "string"
      ? `maxHeight: ${maxH};`
      : typeof maxH === "object" &&
          createResponsiveStyle("maxHeight", maxH.sm, maxH.md),
    display != null && typeof display === "string"
      ? `display: ${display};`
      : typeof display === "object" &&
          createResponsiveStyle("display", display.sm, display.md),
    overflow != null && typeof overflow === "string"
      ? `overflow: ${overflow}`
      : typeof overflow === "object" &&
          createResponsiveStyle("overflow", overflow.sm, overflow.md),
    overflowX != null && typeof overflowX === "string"
      ? `overflowX: ${overflowX}`
      : typeof overflowX === "object" &&
          createResponsiveStyle("overflowX", overflowX.sm, overflowX.md),
    overflowY != null && typeof overflowY === "string"
      ? `overflowY: ${overflowY}`
      : typeof overflowY === "object" &&
          createResponsiveStyle("overflowY", overflowY.sm, overflowY.md)
  );
};
🏃ガイド
Box.tsx → layout.ts(イマココ)

レスポンシブに関しては後述しますので、いったんメインの処理部分だけ見てみます。

return内を超抜粋
w != null && typeof w === "string"
      ? `width: ${w};`

wがnullでない、かつstring型であればwidthの値にwとして受け取った数値をそのまま代入します。

もう一度、今回のゴールを見てみます。

<Box w="100px">
  ここにコンテンツが入ります。
</Box>

このように、wに対してstring型で100pxと指定しているため、最終的に横幅が100pxの<div>が生成されます。

意外とシンプルな仕組みだと思います。

レスポンシブにも対応したいのさ

はい、横幅が100pxとなりましたがPCでは400pxにしたいかもしれません。

次に、下記のようにスタイルを与えることを目標とします。

<Box w={{ sm:"100px", md: "400px" }}>

値をオブジェクトで渡し、スマホ用とPC用で使用するスタイルを一度に指定します。

それを可能にしているのも各Mixinなので、先ほどのlayout.tsをもう一度見てみます。

/styles/mixins/layout.ts
import * as CSS from "csstype";
import { css } from "@emotion/react";
import { BreakPointProps } from "@/types/responsive";
import { createResponsiveStyle } from "@/lib/responsive";

export type Size = string | number | BreakPointProps;

export type LayoutProps = {
  w: Size;
  h: Size;
  maxW: Size;
  maxH: Size;
  display: CSS.Property.Display | BreakPointProps;
  overflow: CSS.Property.Overflow | BreakPointProps;
  overflowX: CSS.Property.OverflowX | BreakPointProps;
  overflowY: CSS.Property.OverflowY | BreakPointProps;
};

export const layoutMixin = ({
  w,
  h,
  maxW,
  maxH,
  display,
  overflow,
  overflowX,
  overflowY,
}: Partial<LayoutProps>) => {
  return css(
    w != null && typeof w === "string"
      ? `width: ${w};`
      : typeof w === "object" && createResponsiveStyle("width", w.sm, w.md),
    h != null && typeof h === "string"
      ? `height: ${h};`
      : typeof h === "object" && createResponsiveStyle("height", h.sm, h.md),
    maxW != null && typeof maxW === "string"
      ? `max-width: ${maxW};`
      : typeof maxW === "object" &&
          createResponsiveStyle("maxWidth", maxW.sm, maxW.md),
    maxH != null && typeof maxH === "string"
      ? `maxHeight: ${maxH};`
      : typeof maxH === "object" &&
          createResponsiveStyle("maxHeight", maxH.sm, maxH.md),
    display != null && typeof display === "string"
      ? `display: ${display};`
      : typeof display === "object" &&
          createResponsiveStyle("display", display.sm, display.md),
    overflow != null && typeof overflow === "string"
      ? `overflow: ${overflow}`
      : typeof overflow === "object" &&
          createResponsiveStyle("overflow", overflow.sm, overflow.md),
    overflowX != null && typeof overflowX === "string"
      ? `overflowX: ${overflowX}`
      : typeof overflowX === "object" &&
          createResponsiveStyle("overflowX", overflowX.sm, overflowX.md),
    overflowY != null && typeof overflowY === "string"
      ? `overflowY: ${overflowY}`
      : typeof overflowY === "object" &&
          createResponsiveStyle("overflowY", overflowY.sm, overflowY.md)
  );
};
🏃ガイド
Box.tsx → layout.ts(イマココ)

importしているBreakPointPropsは型です。

createResponsiveStyleはレスポンシブ用のCSSを生成する関数です。

先ほどのwの処理部分の続きを見てみます。

return内を超抜粋
w != null && typeof w === "string"
      ? `width: ${w};`
      : typeof w === "object" && createResponsiveStyle("width", w.sm, w.md),

wがnullではないかつstring型か?という分岐の後ろに、wがオブジェクト型であれば、という分岐があります。

今回のゴールを振り返ります。

<Box w={{ sm:"100px", md: "400px" }}>

つまり、string型であればそのままCSSを生成。
オブジェクトであればレスポンシブ用の記述と判断し、createResponsiveStyle関数の実行に入ります。

それではさらに深掘りし、BreakPointProps型とcreateResponsiveStyle関数を見ていきます。

BreakPointProps型

/types/responsive.d.ts
// w, h, などのCSS用propsが受け付けるレスポンシブ用のオブジェクトの型
export type BreakPointProps = {
  sm?: CSS.Properties;
  md?: CSS.Properties;
};
🏃ガイド
Box.tsx → layout.ts → responsive.d.ts(イマココ)

型定義ファイルなので、拡張子は.d.tsとしています。

過去の自分がコメントを残していましたが、内容は書いてある通りです。

ちなみに私は「ブレイクポイントふたつでいいんじゃね」派閥でして、0~899px, 900px~だけにしています。

iPhoneやAndroidなど、さまざまなデバイスの横幅をブレイクポイントとするのは本末転倒で、スマホ対応はいかなるスマホで見ても「よしなにスタイルが調整される」状態にコーディングするのがベスト、つまりCSSは可能な限りフレキシブルであるべきだと考えているためです。

タブレットとして普及率の高いiPadは縦持ちで768pxですが、それ以上である900px、
またPCモニターで見るときにディスプレイよりは小さいであろう900pxをブレイクポイントとすることで、それぞれよしなにしようとする気持ちが湧いてきます。ここはパッションの問題です。

ありあまるパッションにより、もっと大きいディスプレイに対応したいときなどは、こういったブレイクポイント周りの定義をカスタマイズして値を増やすことになります。

createResponsiveStyle関数

次にレスポンシブ用のCSSを作成するための関数です。

/lib/responsive.ts
import * as CSS from "csstype";
import { theme } from "@/theme/theme";
import { css } from "@emotion/react";

/**
 * レスポンシブ用スタイルを生成する関数
 * Mixin内でのみ使用する
 * @param propaty CSSプロパティ名(キャメルケースで渡す)
 * @param sm breakpoint.sm用のスタイル
 * @param md breakpoint.md用のスタイル
 * @returns ブレイクポイントごとのオブジェクトCSS
 */
export const createResponsiveStyle = (
  propaty?: string | string[],
  sm?: CSS.Properties | string | number,
  md?: CSS.Properties | string | number
) => {
  if (typeof propaty === "string") {
    // propatyがstringの場合
    return css(
      sm != null && theme.breakpoint.sm({ [propaty]: sm }),
      md != null && theme.breakpoint.md({ [propaty]: md })
    );
  } else if (Array.isArray(propaty)) {
    // propatyが配列の場合
    return css(
      sm != null && theme.breakpoint.sm({ [propaty![0]]: sm }),
      theme.breakpoint.sm({ [propaty![1]]: sm }),
      md != null && theme.breakpoint.md({ [propaty![0]]: md }),
      theme.breakpoint.md({ [propaty![1]]: md })
    );
  } else {
    // あり得ない条件、処理の終了
    return;
  }
};
🏃ガイド
Box.tsx → layout.ts → responsive.ts(イマココ、さっきとは別ファイル)

先ほどと拡張子が違い、別ファイルであることにご注意ください。

この関数はCSSプロパティ名とスマホ用、PC用のスタイルを引数として受け取り、CSSにして返却します。

処理部分を抜粋
if (typeof propaty === "string") {
    // propatyがstringの場合
    return css(
      sm != null && theme.breakpoint.sm({ [propaty]: sm }),
      md != null && theme.breakpoint.md({ [propaty]: md })
    );
  } else if (Array.isArray(propaty)) {
    // propatyが配列の場合
    return css(
      sm != null && theme.breakpoint.sm({ [propaty![0]]: sm }),
      theme.breakpoint.sm({ [propaty![1]]: sm }),
      md != null && theme.breakpoint.md({ [propaty![0]]: md }),
      theme.breakpoint.md({ [propaty![1]]: md })
    );
  } else {
    // あり得ない条件、処理の終了
    return;
  }

if文でpropatyとして受け取る値がstring型か配列かで分岐しています。

基本的にはstring型で受け取り、1props=1CSSプロパティである想定です。

つまり例にあったw={{ sm:"100px", md:"400px"}}であれば、100pxと400pxを文字列で受け取り、widthに対してスマホ用とPC用のCSSを生成します。

これがレスポンシブ用の記述とそれを実現する仕組みです。

文字列ではなく配列はなんのためにあるのかと言いますと、次のSpacingのためです。

Spacingについて

paddingmarginもショートハンド的に利用したいところです。
これらは/styles/space.tsで管理しています。

せっかくなので、先ほどの<Box>に機能を追加していきます。

/components/atoms/Box/Box.tsx
import styled from "@emotion/styled";
import { LayoutProps, layoutMixin } from "@/styles/mixins/layout";
import { SpaceProps, spaceMixin } from "@/styles/mixins/space"; // これを追加

export type BoxStyleProps = Partial<LayoutProps> & Partial<SpaceProps>

export const BoxStyled = styled.div<BoxStyleProps>(
  layoutMixin,
  spaceMixin, // これを追加
);

export type BoxProps = BoxStyleProps & {
  children?: React.ReactNode;
};
export const Box = (props: BoxProps) => {
  return <BoxStyled {...props} />;
};

考え方はlayoutと同じで、CSSを生成する関数とその引数に使う型を呼び出してそれぞれ追加していくだけです。

これだけで下記のような記述が可能になります。

<Box w="100px" p="12px" mb={ 4 }>

p="12px"ではpaddingに対し12pxを指定しています。

mb={ 4 }ではmargin-bottomに24pxを指定しています。

なぜ24pxなのかというと、4を6倍した数値が24であるためです。

なぜ6倍しているかというと、number型だった場合は6倍してpxに換算するという処理がspace.tsにあるためです。

/styles/mixins/space.ts
import { createResponsiveStyle } from "@/lib/responsive";
import { BreakPointProps } from "@/types/responsive";
import { SizeType } from "@/types/size";
import { css } from "@emotion/react";

type Spacings = {
  [key in SizeType]: string;
};

export const spacings: Spacings = {
  "xx-small": "2px",
  "x-small": "6px",
  small: "12px",
  medium: "18px",
  large: "24px",
  "x-large": "30px",
  "xx-large": "36px",
  "xxx-large": "42px",
};

export type Space = SizeType | number | string;

export type SpaceProps = {
  m: Space | BreakPointProps;
  mx: Space | BreakPointProps;
  my: Space | BreakPointProps;
  mt: Space | BreakPointProps;
  mr: Space | BreakPointProps;
  mb: Space | BreakPointProps;
  ml: Space | BreakPointProps;
  p: Space | BreakPointProps;
  px: Space | BreakPointProps;
  py: Space | BreakPointProps;
  pt: Space | BreakPointProps;
  pr: Space | BreakPointProps;
  pb: Space | BreakPointProps;
  pl: Space | BreakPointProps;
};

export const sizeValueToPixel = (size: number) => size * 6;

export const sp = (spacing?: Space) =>
  typeof spacing === "string"
    ? spacings[spacing as SizeType] ?? spacing
    : typeof spacing === "number"
    ? `${sizeValueToPixel(spacing as number)}px`
    : 0;

export const spaceMixin = ({
  m,
  mx,
  my,
  mt,
  mr,
  mb,
  ml,
  p,
  px,
  py,
  pt,
  pr,
  pb,
  pl,
}: Partial<SpaceProps>) => {
  return css(
    (m != null && typeof m === "string") || typeof m === "number"
      ? { margin: `${sp(m)}` }
      : typeof m === "object" &&
          createResponsiveStyle("margin", sp(m.sm), sp(m.md)),
    (mx != null && typeof mx === "string") || typeof mx === "number"
      ? { marginRight: `${sp(mx)}`, marginLeft: `${sp(mx)}` }
      : typeof mx === "object" &&
          createResponsiveStyle(
            ["marginRight", "marginLeft"],
            sp(mx.sm),
            sp(mx.md)
          ),
    (my != null && typeof my === "string") || typeof my === "number"
      ? { marginTop: `${sp(my)}`, marginBottom: `${sp(my)}` }
      : typeof my === "object" &&
          createResponsiveStyle(
            ["marginTop, marginBottom"],
            sp(my.sm),
            sp(my.md)
          ),
    (mt != null && typeof mt === "string") || typeof mt === "number"
      ? { marginTop: `${sp(mt)}` }
      : typeof mt === "object" &&
          createResponsiveStyle("marginTop", sp(mt.sm), sp(mt.md)),
    (mr != null && typeof mr === "string") || typeof mr === "number"
      ? { marginRight: `${sp(mr)}` }
      : typeof mr === "object" &&
          createResponsiveStyle("marginRight", sp(mr.sm), sp(mr.md)),
    (mb != null && typeof mb === "string") || typeof mb === "number"
      ? { marginBottom: `${sp(mb)}` }
      : typeof mb === "object" &&
          createResponsiveStyle("marginBottom", sp(mb.sm), sp(mb.md)),
    (ml != null && typeof ml === "string") || typeof ml === "number"
      ? { marginLeft: `${sp(ml)}` }
      : typeof ml === "object" &&
          createResponsiveStyle("marginLeft", sp(ml.sm), sp(ml.md)),
    (p != null && typeof p === "string") || typeof p === "number"
      ? { padding: `${sp(p)}` }
      : typeof p === "object" &&
          createResponsiveStyle("padding", sp(p.sm), sp(p.md)),
    (px != null && typeof px === "string") || typeof px === "number"
      ? { paddingRight: `${sp(px)}`, paddingLeft: `${sp(px)}` }
      : typeof px === "object" &&
          createResponsiveStyle(
            ["paddingRight", "paddingLeft"],
            sp(px.sm),
            sp(px.md)
          ),
    (py != null && typeof py === "string") || typeof py === "number"
      ? { paddingTop: `${sp(py)}`, paddingBottom: `${sp(py)}` }
      : typeof py === "object" &&
          createResponsiveStyle(
            ["paddingTop", "paddingBottom"],
            sp(py.sm),
            sp(py.md)
          ),
    (pt != null && typeof pt === "string") || typeof pt === "number"
      ? { paddingTop: `${sp(pt)}` }
      : typeof pt === "object" &&
          createResponsiveStyle("paddingTop", sp(pt.sm), sp(pt.md)),
    (pr != null && typeof pr === "string") || typeof pr === "number"
      ? { paddingRight: `${sp(pr)}` }
      : typeof pr === "object" &&
          createResponsiveStyle("paddingRight", sp(pr.sm), sp(pr.md)),
    (pb != null && typeof pb === "string") || typeof pb === "number"
      ? { paddingBottom: `${sp(pb)}` }
      : typeof pb === "object" &&
          createResponsiveStyle("paddingBottom", sp(pb.sm), sp(pb.md)),
    (pl != null && typeof pl === "string") || typeof pl === "number"
      ? { paddingLeft: `${sp(pl)}` }
      : typeof pl === "object" &&
          createResponsiveStyle("paddingLeft", sp(pl.sm), sp(pl.md))
  );
};

ずいぶんややこしそうになりましたが、まずはいつも通り型定義です。

spaceに関しては"small" = {hoge}pxのようにkeyと数値をマッピングしています。

型定義のところ抜粋
export const spacings: Spacings = {
  "xx-small": "2px",
  "x-small": "6px",
  small: "12px",
  medium: "18px",
  large: "24px",
  "x-large": "30px",
  "xx-large": "36px",
  "xxx-large": "42px",
};

つまり、p="small" m="large"などといった指定も可能にしているということです。

最小の2pxを除き6の倍数で定義しており、だいたいどれを使ってもサイト上の余白がバランス良くなるように設計しています。

ここの設定はお好みになりますが、偶数の倍数で定義するのがよいのかなと思います。

そして先ほどのmb={ 4 }のようにnumber型で指定した際の処理ですが、下記になります。

関数抜粋
export const sizeValueToPixel = (size: number) => size * 6;

export const sp = (spacing?: Space) =>
  typeof spacing === "string"
    ? spacings[spacing as SizeType] ?? spacing
    : typeof spacing === "number"
    ? `${sizeValueToPixel(spacing as number)}px`
    : 0;

number型だったら6倍する処理を通してからpxにする、というだけの処理です。

mixinの部分はlayoutと同じで、nullでなければCSSを生成します。

spaceのレスポンシブについて

さきほどcreateResponsiveStyle関数で受け取る値がstring型の場合と配列の場合があると書きました。

spaceはlayoutと異なり、py={ 2 }とすると1propsでpadding-top,padding-bottom`のふたつのCSSが必要です。

そういった場合に複数CSSを生成できるように配列で渡せるようにしてみました。

createResponsiveStyleのreturn部分を抜粋
// propatyが配列の場合
return css(
      sm != null && theme.breakpoint.sm({ [propaty![0]]: sm }),
      theme.breakpoint.sm({ [propaty![1]]: sm }),
      md != null && theme.breakpoint.md({ [propaty![0]]: md }),
      theme.breakpoint.md({ [propaty![1]]: md })
    );

この処理により、配列の0番目と1番目でそれぞれレスポンシブ用のCSSが生成されます。

引数としてのsmはCSSの値であり、theme.breakpoint.sm()とは別物であることにご注意ください。

それでは続いてtheme.breakpoint.sm()について見ていきます。

@mediaでブレイクポイントを指定

グローバルに参照する値をThemeとして定義しています。

フォントやカラー、ブレイクポイントごとにファイルを分割しており、それらをまとめるファイルが下記です。

/theme/theme.ts
import { palette, nightPalette } from "./settings/palettes";
import { fonts } from "./settings/fonts";
import { breakpoints } from "./settings/breakpoints";
import { spacings } from "./settings/spacings";

export const theme = {
  color: palette,
  font: fonts,
  breakpoint: breakpoints,
  spacing: spacings,
};

export const nightTheme = {
  color: nightPalette,
  font: fonts,
  breakpoint: breakpoints,
  spacing: spacings,
};

今回はひとまずbreakpointを見るため、さらにファイルを遡ります。

/theme/settings/breakpoints.ts
import { css } from "@emotion/react";

export const breakpoints = {
  sm: (sm: any) =>
    sm != null && css({ "@media (max-width: 899px)": sm }),
  md: (md: any) => md != null && css({ "@media (min-width: 899px)": md }),
};

書いてて気づいたんですがanyを使っていますね...また改修しておきます...

とにかくここで受け取った値を@mediaの中に入れてCSSを生成しています。

ブレイクポイントを変更したい場合はこの部分の数値を変更する必要があります。

項目自体を増やしたい場合は型定義している部分にも改修が必要であることに注意してください。

Themeについて

先ほどさらっと触れたThemeについて解説していきます。

YUISではふたつのテーマを切り替える機能のサンプルも作成しているので、ぜひ試してみてください。

https://yuis.vercel.app/

定義部分

Boxのところでも出てきましたが、theme.tsにまとめてあります。

/theme/theme.ts
import { palette, nightPalette } from "./settings/palettes";
import { fonts } from "./settings/fonts";
import { breakpoints } from "./settings/breakpoints";
import { spacings } from "./settings/spacings";

export const theme = {
  color: palette,
  font: fonts,
  breakpoint: breakpoints,
  spacing: spacings,
};

export const nightTheme = {
  color: nightPalette,
  font: fonts,
  breakpoint: breakpoints,
  spacing: spacings,
};

あらゆるスタイルはここの値を参照しており、グローバルに影響しているthemenightThemeを切り替えることで全スタイルを出し分けることができます。

読み込み部分

Next.jsの仕組みとしてすべてのページファイルは_app.tsxを介しています。

その_app.tsxでラップしておくことで全ページに影響を与えることができます。

/pages/_app.tsx
import "@/styles/globals.css";
import type { AppProps } from "next/app";
import { ThemeProvider } from "@emotion/react";
import { ThemeContext } from "@/lib/store/theme";
import { theme, nightTheme } from "@/theme/theme";
import { useState } from "react";
import Head from "next/head";

function MyApp({ Component, pageProps }: AppProps) {
  // NightModeに切り替えるステート
  const [isNightMode, setIsNightMode] = useState<boolean>(false);

  return (
    <ThemeProvider theme={isNightMode ? nightTheme : theme}>
      <ThemeContext.Provider value={{ isNightMode, setIsNightMode }}>
        <Head>
          <title>YUIS</title>
          <meta charSet="utf-8" />
          <meta
            name="viewport"
            content="initial-scale=1.0, width=device-width"
          />
          <meta
            property="description"
            content="YUISは、UIコンポーネントライブラリを作成するための基盤です。あなただけのUIライブラリを育成して、世界中のライバルとバトルしよう!!"
          />
          <meta property="og:title" content="YUIS -Yakkun UI System-" />
          <meta
            property="og:description"
            content="あなただけのUIライブラリを育成して、世界中のライバルとバトルしよう!!"
          />
          <meta
            property="og:image"
            content={`https://yuis.vercel.app/images/card_large_yuis.png`}
          />
          <meta name="twitter:card" content="summary_large_image" />
        </Head>
        <Component {...pageProps} />
      </ThemeContext.Provider>
    </ThemeProvider>
  );
}

export default MyApp;

YUISのサンプルは1ページしかないため、meta情報などもここに書きました。

themeに関する部分だけを抜粋すると下記のようになります。

/pages/_app.tsx
import "@/styles/globals.css";
import type { AppProps } from "next/app";
import { ThemeProvider } from "@emotion/react";
import { ThemeContext } from "@/lib/store/theme";
import { theme, nightTheme } from "@/theme/theme";
import { useState } from "react";

function MyApp({ Component, pageProps }: AppProps) {
  // NightModeに切り替えるステート
  const [isNightMode, setIsNightMode] = useState<boolean>(false);

  return (
    <ThemeProvider theme={isNightMode ? nightTheme : theme}>
      <ThemeContext.Provider value={{ isNightMode, setIsNightMode }}>
        <Component {...pageProps} />
      </ThemeContext.Provider>
    </ThemeProvider>
  );
}

export default MyApp;

useStateではどちらのthemeを使うかのフラグを管理しています。

<ThemeProvider>に渡すことで反映されますが、これをステートのtrue/falseによって切り替えます。

<ThemeContext>はグローバルに参照するための要素です。
具体的にはthemeを切り替える(=フラグを切り替える)部分で参照するために設けています。

ここを少し深ぼるため、import元の@/lib/store/themeを見てみます。

/lib/store/theme.ts
import { createContext } from "react";

export const ThemeContext = createContext(
  {} as {
    isNightMode: Object;
    setIsNightMode: React.Dispatch<React.SetStateAction<boolean>>;
  }
);

Reactが提供しているcreateContextを利用しています。

本来であればバケツリレーとよく言われるように、Reactでは情報を親から子へ渡していきます。

しかし、useContextというhooksを利用することで、そのバケツリレーをすっ飛ばしてグローバルに参照することが可能になります。

一見便利に見えますがグローバルに参照できる値というのは得手して管理が難しく、運用する上で「どういったものをグローバルに扱うか」というルールを設けておかないとカオス化しやすいです。

上記のステートとその更新関数を実際に扱っている部分は下記です。

/components/organisms/ThemeToggle/Themetoggle.tsx
import { Box } from "@/components/atoms/Box/Box";
import { Toggle } from "@/components/molecules/Toggle/Toggle";
import { ThemeContext } from "@/lib/store/theme";
import { useContext } from "react";

export const ThemeToggle = () => {
  const { isNightMode, setIsNightMode } = useContext(ThemeContext);
  return (
    <Box onClick={() => setIsNightMode(!isNightMode)}>
      <Toggle />
    </Box>
  );
};

トグルスイッチで切り替えられるようにしており、useContextで先ほどのステートとその更新関数を参照しています。

<Box onClick={() => setIsNightMode(!isNightMode)}>

ここでクリックするとステートの値を反転させる処理となり、つまりトグルスイッチをクリックするごとにテーマが切り替わることとなります。

このあたりのコンポーネント設計はのちのAtomic Designについて語る部分で解説します。

Typographyについて

文字をコンポーネント化するか否か論争(そんなものがあるのかわかりませんが)について、私はコンポーネント化する派です。

理由は文字であれグローバルに統一された基本スタイルが与えられ、統一された拡張性を持つべきだと考えているからです。

今回、下記のように実装してみました。

/components/atoms/Typography/Typography.tsx
import * as CSS from "csstype";
import { BackgroundProps, backgroundMixin } from "@/styles/mixins/background";
import { BorderProps, borderMixin } from "@/styles/mixins/border";
import { LayoutProps, layoutMixin } from "@/styles/mixins/layout";
import { OpacityProps, opacityMixin } from "@/styles/mixins/opacity";
import { PositionProps, positionMixin } from "@/styles/mixins/position";
import { SpaceProps, spaceMixin } from "@/styles/mixins/space";
import { VariantType } from "@/types/typography";
import styled from "@emotion/styled";
import { useState, useEffect, ElementType } from "react";
import { BreakPointProps } from "@/types/responsive";
import { createResponsiveStyle } from "@/lib/responsive";
import { ColorType } from "@/types/color";

type VariantMapping = { [key in VariantType]: string };

export const variantMapings: VariantMapping = {
  body: "p",
  button: "p",
  caption: "span",
  title: "h1",
  h1: "h1",
  h2: "h2",
  h3: "h3",
  h4: "h4",
  h5: "h5",
  h6: "h6",
};

export type StyleTypographyProps = Partial<LayoutProps> &
  Partial<SpaceProps> &
  Partial<BackgroundProps> &
  Partial<BorderProps> &
  Partial<PositionProps> &
  Partial<OpacityProps> & {
    textAlign?: CSS.Property.TextAlign | BreakPointProps;
    fontSize?: CSS.Property.FontSize | BreakPointProps;
    lineHeight?: CSS.Property.LineHeight | BreakPointProps;
  };

export const TypographyStyled = styled.span<TypographyProps>(
  { fontFamily: `"M PLUS 1p", sans-serif` },
  ({ theme }) => `color: ${theme.color.typography};`,
  ({ theme, color }) => {
    switch (color) {
      case "body":
        return { color: theme.color.body };
      case "primary":
        return { color: theme.color.primary };
      case "secondary":
        return { color: theme.color.secondary };
      case "typography":
        return { color: theme.color.typography };
      default:
        return null;
    }
  },
  ({ variant, theme }) => {
    switch (variant) {
      case "title":
        return [
          theme.breakpoint.sm({
            fontSize: theme.font.title.sm.size,
            fontWeight: theme.font.title.sm.weight,
            lineHeight: theme.font.title.sm.lineH,
          }),
          theme.breakpoint.md({
            fontSize: theme.font.title.md.size,
            fontWeight: theme.font.title.md.weight,
            lineHeight: theme.font.title.sm.lineH,
          }),
        ];
      case "h1":
        return [
          theme.breakpoint.sm({
            fontSize: theme.font.h1.sm.size,
            fontWeight: theme.font.h1.sm.weight,
            lineHeight: theme.font.h1.sm.lineH,
          }),
          theme.breakpoint.md({
            fontSize: theme.font.h1.md.size,
            fontWeight: theme.font.h1.md.weight,
            lineHeight: theme.font.h1.sm.lineH,
          }),
        ];
      case "h2":
        return [
          theme.breakpoint.sm({
            fontSize: theme.font.h2.sm.size,
            fontWeight: theme.font.h2.sm.weight,
            lineHeight: theme.font.h2.sm.lineH,
          }),
          theme.breakpoint.md({
            fontSize: theme.font.h2.md.size,
            fontWeight: theme.font.h2.md.weight,
            lineHeight: theme.font.h2.sm.lineH,
          }),
        ];
      case "h3":
        return [
          theme.breakpoint.sm({
            fontSize: theme.font.h3.sm.size,
            fontWeight: theme.font.h3.sm.weight,
            lineHeight: theme.font.h3.sm.lineH,
          }),
          theme.breakpoint.md({
            fontSize: theme.font.h3.md.size,
            fontWeight: theme.font.h3.md.weight,
            lineHeight: theme.font.h3.sm.lineH,
          }),
        ];
      case "body":
        return [
          theme.breakpoint.sm({
            fontSize: theme.font.body.sm.size,
            fontWeight: theme.font.body.sm.weight,
            lineHeight: theme.font.body.sm.lineH,
          }),
          theme.breakpoint.md({
            fontSize: theme.font.body.md.size,
            fontWeight: theme.font.body.md.weight,
            lineHeight: theme.font.body.sm.lineH,
          }),
        ];
      case "caption":
        return [
          theme.breakpoint.sm({
            fontSize: theme.font.caption.sm.size,
            fontWeight: theme.font.caption.sm.weight,
            lineHeight: theme.font.caption.sm.lineH,
          }),
          theme.breakpoint.md({
            fontSize: theme.font.caption.md.size,
            fontWeight: theme.font.caption.md.weight,
            lineHeight: theme.font.caption.sm.lineH,
          }),
        ];
      default:
        return [
          theme.breakpoint.sm({
            fontSize: theme.font.body.sm.size,
            fontWeight: theme.font.body.sm.weight,
            lineHeight: theme.font.body.sm.lineH,
          }),
          theme.breakpoint.md({
            fontSize: theme.font.body.md.size,
            fontWeight: theme.font.body.md.weight,
            lineHeight: theme.font.body.sm.lineH,
          }),
        ];
    }
  },
  ({ textAlign }) =>
    textAlign != null && typeof textAlign === "string"
      ? `text-align: ${textAlign};`
      : typeof textAlign === "object" &&
        createResponsiveStyle("textAlign", textAlign.sm, textAlign.md),
  ({ fontSize }) =>
    fontSize != null && typeof fontSize === "string"
      ? `font-size: ${fontSize};`
      : typeof fontSize === "object" &&
        createResponsiveStyle("fontSize", fontSize.sm, fontSize.md),
  ({ lineHeight }) =>
    lineHeight != null && typeof lineHeight === "string"
      ? `&& {line-height: ${lineHeight}};`
      : typeof lineHeight === "object" &&
        createResponsiveStyle("lineHeight", lineHeight.sm, lineHeight.md),
  layoutMixin,
  spaceMixin,
  backgroundMixin,
  borderMixin,
  positionMixin,
  opacityMixin
);

export type TypographyProps = StyleTypographyProps & {
  variant?: VariantType;
  color?: ColorType;
  children?: string | React.ReactNode;
  as?: ElementType<any> | undefined;
};

export const Typography = (props: TypographyProps) => {
  const [htmlTag, setHtmlTag] = useState<ElementType<any> | undefined>();
  useEffect(() => {
    // Change HTML tags with mapped values based on variant
    const as = variantMapings[props.variant!] as ElementType<any> | undefined;
    setHtmlTag(as);
  }, [props.variant]);

  return <TypographyStyled as={htmlTag} {...props} />;
};

基本的には<Box>でやっているように、さまざまなMixinを呼び出しています。

特徴的なのは、variantというpropsによってスタイルだけでなくHTML要素が変動する点です。

抜粋
type VariantMapping = { [key in VariantType]: string };

export const variantMapings: VariantMapping = {
  body: "p",
  button: "p",
  caption: "span",
  title: "h1",
  h1: "h1",
  h2: "h2",
  h3: "h3",
  h4: "h4",
  h5: "h5",
  h6: "h6",
};

どの値がどのHTML要素になるかのマッピングを定義しました。

[key in VariantType]: stringとすることで型からkeyを抽出することができます。

variantMapingsではWebにおいて使いそうな値を、それぞれ変換してほしいHTML要素名に紐付けました。

variant="title"であれば<h1>として描画され、variant="caption"であれば<span>として描画されます。

実際に出し分けている部分は下記のようになっています。

抜粋
export type TypographyProps = StyleTypographyProps & {
  variant?: VariantType;
  color?: ColorType;
  children?: string | React.ReactNode;
  as?: ElementType<any> | undefined;
};

export const Typography = (props: TypographyProps) => {
  const [htmlTag, setHtmlTag] = useState<ElementType<any> | undefined>();
  useEffect(() => {
    // Change HTML tags with mapped values based on variant
    const as = variantMapings[props.variant!] as ElementType<any> | undefined;
    setHtmlTag(as);
  }, [props.variant]);

  return <TypographyStyled as={htmlTag} {...props} />;
};

asというpropsを受け付けるようにし、useEffectの中でマッピングしたものを元にHTML要素名を代入しました。

それをステートにセットし、as={htmlTag}で渡します。

asはもともと表示させるHTML要素を操作する機能をもっており、そこに動的に値を渡しているといった仕組みです。

正直、このあたりの理解が浅いのでこれがベストプラクティスなのか怪しいところです。

今のところ不具合は起きていませんが、SSRなどとの兼ね合いで具合が悪くなる可能性もなくはないなと思っています。

だいたいそんな感じです

はい、だいたいそんな感じでMixinとstyledを使って汎用性の高いコンポーネントを作成しています。

元となるコンポーネントでthemeからCSSを参照しておけば、自動的に見た目が統一されていきます。

あとはこれらを組み合わせて機能をもったコンポーネントたちを量産していきます。

そしてその過程で大事なのがデザイン設計であり、Atomic Designというわけです。

すみません、もう少し語らせてください。

🎨 Atomic Designについて

ご存知の方も多いかと思いますが、Atomic Designとは要素を詳細度レベルで5つに分類し、詳細度の低いものから順に組み合わせてより明確な機能をもったUIを構築していくためのデザイン思想です。

概要については下記の記事が非常にわかりやすいかなと思います。

https://bradfrost.com/blog/post/atomic-web-design/

https://design.dena.com/design/atomic-design-を分かったつもりになる

リンク先でも解説されていますが、

atom < molecule < organism < templete < page

この単位でコンポーネントを扱います。
左から右に行くにつれ詳細度が高くなり、粒度の大きいコンポーネントとなります。

atomにあたるものは例にあったような<Box><Typography>であり、彼らは自分自身がどこでどのように利用されるかという知識を持ちません。

ゆえに詳細度が低いと言える要素であり、同時に汎用性が最も高い要素であるということを示します。

そんなatomに該当する要素で構成される要素がmoleculeであり、この階層では単一の機能を持つことができます。

ここで初めて「XXをするためのコンポーネント」というように機能・意味を持ち、明確な用途が発生します。

この流れで、molecule相当のコンポーネントを使いより明確な用途を持ったコンポーネントを作成したとき、それはorganismに分類されます。

目安ですがorganism相当のコンポーネントはWebページを構成する上で意味のある名称をもち、かつ複合的な機能を持つものが多いです。

具体的にはヘッダーであったり、検索ボックスなどがorganismに入ることが多いかなと思います。

そしてorganism相当の大きいコンポーネントやatomなどの細かい要素が集まり構成されるのがページの雛形であるtempleteになります。

templeteは捉え方が難しいですが、ページを構成する上での骨組みだと思っていただければよいかと思います。

あくまでも骨組みであるため、templeteはドメインに紐付いた知識はもちません。

たとえば~~.com/movie,~~.com/weatherというページがあり、映画情報とお天気情報が見れるとします(どんなサイトやねんって感じですが)。

このとき、APIを使って映画情報とお天気情報を取得するべき場所はpageコンポーネントです。

なぜならpage配下のファイルはドメインに関する知識を持つことができる階層であり、

pages/movie.tsxは映画情報を、pages/weather.tsxはお天気情報を扱える要素であるためです。

反対にtempleteはドメインに関する知識を持たない階層であるため、どちらのページからでも共通的に呼び出すことができます。

図にすると下のような感じです。



以上が公式などを踏まえたうえでの私なりのAtomic Designの理解です。

なるほどたしかに便利そうだ!と思っても、実際にこの型にはめて運用するのは非常に難しいです。

提供されているものはあくまでも「概念」でしかないため、いざ設計すると難しいポイントがいくつも出てきます。

引き続き私なりにはなりますが、「こうしてみてはどうか」をサンプルをもって語らせてください。

ThemeToggleコンポーネントの場合

わかりやすく機能を持ったコンポーネントから分解して考えていきます。

さきほども出てきた、テーマを切り替えるためのトグルスイッチを実装する過程を言語化してみます。

  1. 「themeを切り替える」という明確な機能をもつコンポーネントである
  2. 「トグルスイッチ」そのものはthemeを切り替えるためだけのものではない
  3. ゆえに「トグルスイッチ」の見た目だけを担うコンポーネントに「themeを切り替える」機能を持たせたコンポーネントを作成することになる
  4. 「トグルスイッチ」は本質的には「ボタン」であるため、クリック可能であるという責務のみをもつ「ボタン」を利用して作成することになる

といったような考えをもとにUIを作成します。

ちょっと思考の流れとコンポーネントの粒度が一直線ではないので分かりづらいかもしれませんが、図に整理するとこのようになります。

見づらい場合は拡大をお願いします...

いかがでしょうか、なんとなく各階層が持つべき責務の幅がご理解いただけるかなと思います。

この場合でいうとmoleculesで初めて見た目ができた「トグルスイッチ」自体は見た目以上の機能をもたず、ただ切り替わるだけのスイッチです。

これを利用し今回はテーマを切り替えるトグルスイッチを作成しましたが、場合によっては同じ見た目で通知のON/OFFを切り替えるために使ったり、検索のフィルターに使ったりすることがあると思います。

これをまた図にするとこんな感じです。

Atomic Designの魅力が徐々に伝わっていれば幸いです。

このように階層ごとに明確に責務を分割することで再利用性を担保しつつ開発していくことができます。

もっというと、もちろんatomsのButtonはトグルスイッチ以外のクリッカブルな要素になれます。

あえて図にするとこのようになります。

このように、抽象度の低い要素がどんどん繰り返し利用され、より明確な機能をもったコンポーネントに組み込まれていきます。

これがAtomic Designの真髄です。

それを踏まえて

上記を踏まえてソースを見ていきます。

/components/atoms/Button/Button.tsx
import styled from "@emotion/styled";
import { BorderProps, borderMixin } from "@/styles/mixins/border";
import { LayoutProps, layoutMixin } from "@/styles/mixins/layout";
import { OpacityProps, opacityMixin } from "@/styles/mixins/opacity";
import { PositionProps, positionMixin } from "@/styles/mixins/position";
import { SpaceProps, spaceMixin } from "@/styles/mixins/space";
import { AllEventType } from "@/types/events";
import { ColorType } from "@/types/color";

export type ButtonStyleProps = Partial<LayoutProps> &
  Partial<SpaceProps> &
  Partial<BorderProps> &
  Partial<PositionProps> &
  Partial<OpacityProps> &
  Partial<AllEventType> & {
    bgColor?: ColorType;
    isDisable?: boolean;
  };

export const ButtonStyled = styled.button<ButtonStyleProps>(
  // bgColorに渡された値によってbackgroundを出しわける
  ({ bgColor, theme }) => {
    switch (bgColor) {
      case "body":
        return { background: theme.color.body };
      case "primary":
        return { background: theme.color.primary };
      case "secondary":
        return { background: theme.color.secondary };
      case "typography":
        return { background: theme.color.typography };
      default:
        return { background: bgColor };
    }
  },
  // isDisable = trueのとき、クリック無効と不透明度を下げるスタイルを付与
  ({ isDisable }) => isDisable && { pointerEvents: "none", opacity: ".6" },
  { "&:hover": { cursor: "pointer" } },
  layoutMixin,
  spaceMixin,
  borderMixin,
  positionMixin,
  opacityMixin
);

export type ButtonProps = ButtonStyleProps & {
  children?: React.ReactNode;
};

export const Button = (props: ButtonProps) => {
  return <ButtonStyled {...props} />;
};

最も抽象度の高いatomsにあたるButtonです。

汎用性を持たせるため、さまざまなMixinを与えています。

最終的にreturnしている部分をご覧いただければわかるように、Button自身は特定の見た目をもちません。
ただクリックできる<button>要素であることと、最低限のスタイル(ホバー時などの見た目)を担います。

続いて、1階層大きくなりmoleculesにあたるToggle.tsxを見てみます。

/components/molecules/Toggle/Toggle.tsx
import { Button } from "@/components/atoms/Button/Button";
import { Box } from "@/components/atoms/Box/Box";
import { Stack } from "@/components/atoms/Stack/Stack";
import { useState } from "react";

export const Toggle = () => {
  const [isActive, setIsActive] = useState<boolean>(true);

  return (
    <Button
      w="56px"
      h="30px"
      p={1}
      bgColor={isActive ? "primary" : "typography"}
      borderRadius="50px"
      onClick={() => setIsActive(!isActive)}
    >
      <Stack justifyContent={isActive ? "flex-start" : "flex-end"}>
        <Box w="18px" h="18px" bg="#fff" borderRadius="50px" />
      </Stack>
    </Button>
  );
};

ここではトグルスイッチとしての見た目だけを担います。
isActiveというステートを持っており、クリックすることでtrue/falseが切り替わります。

<Stack>は初登場ですが、CSSでいうとflexをもつコンポーネントです。
つまり、要素を横並びにしたりする役割があります。

<Stack justifyContent={isActive ? "flex-start" : "flex-end"}>

ここはjustify-contentの値がtrueかfalseかで動的に切り替わります。

つまり、クリックする → ステートのtrue/falseが切り替わる → 白丸の左右の位置が切り替わる

ということがここでは起きます。

それ以上の機能は持たず、ただトグルスイッチの見た目が切り替わるだけです。

それではテーマを切り替えるコンポーネントを見てみます。

/components/organisms/ThemeToggle/ThemeToggle.tsx
import { Box } from "@/components/atoms/Box/Box";
import { Toggle } from "@/components/molecules/Toggle/Toggle";
import { ThemeContext } from "@/lib/store/theme";
import { useContext } from "react";

export const ThemeToggle = () => {
  const { isNightMode, setIsNightMode } = useContext(ThemeContext);
  return (
    <Box onClick={() => setIsNightMode(!isNightMode)}>
      <Toggle />
    </Box>
  );
};

ボタンであることと、トグルスイッチの見た目は他コンポーネントが担っているため、ソースもシンプルです。

ここではグローバルなテーマをuseContextで参照しており、クリックすることでステートを更新する機能を持ちます。

外側の要素を<Button>ではなく<Box>にしているのは、最終的に描画されるときに<button>の中に<button>を含めることができないためです。

<div>であれば問題はないためこのようにしていますが、外側が<button>であるほうが自然なのでここはもう少し考慮の余地があるかもしれません。

いかがでしょうか

図で理解した上でソースを見てみると、理にかなった構造になっていると感じませんか。

複雑さは変われど基本的にこの思想のもとUIを構築していくことになります。

最後に、サンプルとして作成している「カウンター」を作成するとしたら...

みなさまならどういう構造に分割するのか考えてみていただきたいです。

📚 参考記事

たいへん参考にさせていただきました。ありがとうございました。

https://emotion.sh/docs/introduction

https://suzukalight.com/blog/posts/2021-04-07-styled-components-mixin

🦄 おわりに

非常に長くなってしまいましたが、以上が作成したYUISとその仕組み、そしてAtomic Designに対する私なりの愛でした。

私の考えた構造が決して「正しい」ということではなく、実現方法ももっと改善の余地があるかと思います。

それでも、ほんの少しでもUI設計に悩める方のヒントになればと思い作成いたしました。

みなさまだけのオリジナルUIライブラリを育成し、世界中のライバルと対戦していただければ幸いです。

Discussion