Atomic CSSとTailwindCSSの考え方
概要
まだ批判的な人も多いようですか、昨今間違いなく流行りの兆しを見せているTailwindCSS。
なぜTailwindCSSがここまで使われるようになり始め、その設計思想はどのようなものなのか、簡単に従来のスタイルアプローチと比較しながら調べてみました。
Semantic CSS
Semantic CSS(セマンティックCSS)は、HTMLの要素に意味のある記述的なクラス名を使用してスタイリングする古典的アプローチです。この設計思想は、クラス名がその要素の内容や機能を直接的に反映するように命名されるべきだという考えに基づいています。
例えば次のようのHTMLを考えてみましょう。
<div class="article-preview">
<img class="article-preview__image" src="https://i.vimeocdn.com/video/585037904_1280x720.webp" alt="">
<div class="article-preview__content">
<h2 class="article-preview__title">Stubbing Eloquent Relations for Faster Tests</h2>
<p class="article-preview__body">
In this quick blog post and screencast, I share a trick I use to speed up tests that use Eloquent relationships but don't really depend on database functionality.
</p>
</div>
</div>
出典 https://adamwathan.me/css-utility-classes-and-separation-of-concerns/
このHTMLから読み取れるのは、このHTMLは記事のプレビューを表示を担う箇所であり、中には画像やタイトル、本文などが入っているということです。当然レイアウトは何もないので、これに対してスタイルを当てる必要があります。このHTMLに対してスタイルを当ててみましょう。
.article-preview {
background-color: white;
border: 1px solid hsl(0,0%,85%);
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
overflow: hidden;
}
.article-preview__image {
display: block;
width: 100%;
height: auto;
}
.article-preview__content {
padding: 1rem;
}
.article-preview__title {
font-size: 1.25rem;
color: rgba(0,0,0,0.8);
}
.article-preview__body {
font-size: 1rem;
color: rgba(0,0,0,0.75);
line-height: 1.5;
}
問題点
これでスタイルも当てれたのでめでたしめでたし・・・とはいきたくないところです。
上記のスタイルの作り方にはいくつか問題点があります。
HTMLへの密依存
まず、 article-preview__content
や article-preview__title
といったコンテンツの要素に対して当てるスタイルについて、そもそもの話として article-preview
で指定したスタイルが当たっているということが前提となっており、そしてその前提を保証するのはCSSではなくHTMLであるいう問題があります。万が一HTMLを変更する場合は当然CSSも変更する必要が出てきます。デグレの温床にもなり得るでしょう。
汎用性の低さ
このプレビューと全く同じようなスタイルだが場所によって微妙に異なるものにしたい、といった場合はどうするのかという問題もあります。(全部コピペして微妙に変える、SCSSなどではmixinなどを使って共通化するアプローチが取られているかと思います)
クラス数と記述量の肥大化
上記と関連してですが、共通化できなければ当然似たようなクラスが増えていくことになります。
ページや要素が増えていけばいくほどクラスが量産されていき、保守性と可読性がどんどん下がっていくことになるでしょう。
上記のような問題点がありながらもセマンティックなCSSのアプローチは、やはりHTMLと紐づいておりどこに対してのスタイルなのかわかりやすいという点で検索性が高く、また直感的なスタイルアプローチであるため広く使われてきたように思います。
そのためJSでのWebページの構築が主流となった現在でも、CSS ModulesといったセマンティックなCSSのアプローチとの相性が良い手段が提供されてきました。
CSS-in-JS
現代ではReactやVueといったコンポネント指向のライブラリが覇権を握り、コンポネントを利用したページ・UIの構築といったものが完全に主流となりました。そこで新たに登場してきたスタイルアプローチがCSS-in-JSです。HTMLは基本的にUIをパーツに分割するといったことができず(現在はWeb Componentsなどが登場してきていますが)、JSやCSSはグローバルに宣言する必要がありました。
しかしコンポネント指向型のライブラリの登場によって、レイアウトをパーツごとに分解しそれぞれに対してロジックやスタイルを定義するといった設計が可能となり、CSSの設計法も大きく変わることになりました。
先ほどのHTMLとCSSを少し改変してCSS-in-JSのライブラリであるemotionで再現してみると、以下のような形です。
import React from 'react';
import styled from '@emotion/styled';
import { css, ThemeProvider } from '@emotion/react';
const dynamicStyle = ({
variant,
color,
textAlign,
lineHeight,
marginTop,
marginBottom,
}) => css`
color: ${theme => theme.colors.text[color]};
font-size: ${theme => theme.typography.paragraph[variant].fontSize};
text-align: ${textAlign};
line-height: ${lineHeight};
margin-top: ${theme => theme.spacing[marginTop]};
margin-bottom: ${theme => theme.spacing[marginBottom]};
`;
const Article = styled.div`
background-color: white;
border: 1px solid hsl(0, 0%, 85%);
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
padding: 1rem;
`;
const Title = styled.h2`
${props => dynamicStyle({
variant: 'large',
color: 'primary',
textAlign: 'center',
lineHeight: '1.3',
marginTop: 'medium',
marginBottom: 'small',
})}
`;
const Body = styled.p`
${props => dynamicStyle({
variant: 'medium',
color: 'secondary',
textAlign: 'left',
lineHeight: '1.5',
marginTop: 'small',
marginBottom: 'medium',
})}
`;
const ArticleComponent = ({ title, body, theme }) => {
return (
<ThemeProvider theme={theme}>
<Article>
<Title>{title}</Title>
<Body>{body}</Body>
</Article>
</ThemeProvider>
);
};
JSでスタイルを管理するため関数化して引数に応じてスタイルを変更させるといったことも可能になります。
これによって共通点の多いスタイルは一般化することができ、従来のセマンティックなCSSアプローチで欠点としてあった汎用性については克服されたように思います。
また、グローバルに宣言しなくなったことでスタイルのスコープをコンポネント配下に限定できるようになったり、class名を個別で考える必要がなくなったり(当然コンポネント名は考える必要がありますが、、)と多くのメリットを享受できるようになりました。
問題点
しかし柔軟さと汎用さを確保できるようになった一方、周辺技術の進化や発展に伴いやはりこのアプローチ(というかJSでスタイルを制御すること自体)にも新たに問題が提起されるようになっていきます。
-
スタイルの再計算に対するオーバーヘッド
多くのCSS-in-JSライブラリではJSで管理する状態に応じてブラウザのStyleRuleを更新します。
しかしこの更新が起きるたびに、Render Treeの算出が再実行され再描写等のブラウザの処理が実行されてしまいます。ReactにおけるConcurrent Renderingの登場等によってこのあたりのパフォーマンスについて改めて問題視されるようになりました。
https://dev.to/gopal1996/understanding-reflow-and-repaint-in-the-browser-1jbg
-
スタイルの事前決定&作成が難しい
JSが管理する状態に応じてスタイルを変えるということは、逆を言えば利用するスタイルを事前に決定することができないということを意味します。(スタイルアプローチ自体についての欠点からは若干逸れますが)
ライブラリやフレームワークによってはバンドラーやそのプラグインなどでこの問題を回避しようとしているものもありますが、やはり事前に決定するにはある程度の限界があります。SSRやSSGを利用する環境などでは、この問題はボトルネックになる可能性があります。
スタイル部分をサーバー側でキャッシュしておくといったことも当然できないため、CDNでキャッシュすることができるCSSファイルを利用する場合よりはパフォーマンスで後手に回ります。
Atomic CSS/Functional CSS/Utility First CSS
ここまで紹介してきたアプローチの問題点や周辺技術の進化・流行から最近注目を浴び始めているスタイルアプローチがAtomic CSSです。
Atomic CSS(Functional CSS/Utility First CSS)とは、スタイルシートの設計方法の一つで、多くの小さな単一目的のクラスを作成していくCSSの設計思想です。
各クラスは1つの特定のスタイルプロパティ(例えば、マージン、パディング、フォントサイズ)を設定します。このアプローチの主な利点は、コードの再利用が容易であること、スタイルシートのサイズが最小限に抑えられること、そしてスタイルの整合性が保たれることです。
例えば以下のようなスタイルルールがAtomic CSSに該当します。
.absolute {
display: absolute;
}
.flex {
display: flex;
}
.text-center {
text-align: center;
}
Atomic CSSは、Tailwind CSSやTachyonsのようなフレームワークで広く採用されています。これらのフレームワークは、開発者がクラス名を組み合わせることで、迅速にレイアウトを作成できるように設計されています。
インラインスタイルとの違い
しばしば、Utility Firstなスタイル設計は、インラインスタイルで記載するのと変わらないのではと言われることがあるようですが、二つは全く異なるものといってよいでしょう。
インラインスタイルでは設定できる値に対する制約が一切なく、デザインシステムを保証することが難しいのに加え、直接マークアップすることによるキャッシュの効率が低下する懸念などがあります。
利点
利用するスタイルの事前決定が可能
このアプローチで定義されるスタイルは基本的に全てがUtilityクラスになります。
そのため、CSS側ではすでに利用するスタイルは定義し終えている前提であるため、ビルドの段階で利用するスタイルを全て決定することができます。CSSファイルとして提供することが可能となるため、キャッシュ戦略によってパフォーマンスの改善が図れます。
また、HTML(JS)側でclass名を指定するだけで済むため、SSRやSSGを利用する環境でもスタイルの問題がボトルネックとなりえません。
スタイルルール数の肥大化を避けられる
新たなコンポネントを追加しようとなっても、HTML(JS)側ですでに定義されているスタイルを組み合わせて構築することができるため、セマンティックアプローチのようにコンポネントが増えるごとにクラス数が肥大化するといったことを避けやすいといった利点があります。
https://johnpolacek.medium.com/by-the-numbers-a-year-and-half-with-atomic-css-39d75b1263b4
古典的なセマンティックCSSを利用していた場合からAtomic CSSを利用するようリファクタした場合におけるCSSファイルのサイズ推移を計測された方がいらっしゃいます。
やはりプロジェクトが発展していくごとにスタイル数は肥大がしていく傾向がありますが、Atomic CSSアプローチに切り替えたタイミングで大幅な削減が行えたようです。
出典: By The Numbers: A Year and Half with Atomic CSS
同じようなアプローチを持つライブラリなどは多く存在していたかと思います。(例えばBootstrapなど)
なぜ今この時代にAtomic CSSがまた流行り始めているかというと、やはりコンポーネント指向がメジャーになったことで再利用性を容易に確保できるようになったこと、そしてSSRやSSGといったサーバー側でDOMを取り扱うことが増えクライアント側でスタイルを生成するニーズが落ちてきている、といった事情が大きいかと思います。
TailwindCSS
Atomic CSSを採用している代表的なCSSフレームワークとして、今最も知名度があるであろうものはTailwindCSSでしょう。
Tailwind CSSは、Adam Wathan氏が開発したユーティリティファーストのCSSフレームワークです。頻繁に使用されるスタイル属性に対応する数多くのユーティリティクラスを提供しており、デベロッパーはHTML内に直接クラスを適用することで迅速にスタイリングを行うことができます。
レスポンシブや擬似クラス、 テーマ設定やプラグインシステムなども備えており、モダンなUIデザインを実現するために必要な機能を一通り用意されているフレームワークです。
前述までに出した例をTailwindCSSとHTMLで書き換えたものになります。
<div class="max-w-xl mx-auto overflow-hidden bg-white border rounded-lg border-gray-200 shadow-md">
<img class="w-full" src="https://i.vimeocdn.com/video/585037904_1280x720.webp" alt="">
<div class="p-5">
<h2 class="text-xl font-semibold text-gray-800">Stubbing Eloquent Relations for Faster Tests</h2>
<p class="mt-2 text-base text-gray-600">
In this quick blog post and screencast, I share a trick I use to speed up tests that use Eloquent relationships but don't really depend on database functionality.
</p>
</div>
</div>
HTML側であらかじめ存在するUtilityクラスを利用してスタイルを組み立ています。
この例ではUtilityクラスだけで組み立てられているため、スタイルを別途宣言する手間を省けています。
セレクタ
TailwindCSSは単にclassのプリセットを提供するだけでなく、セレクタを利用したスタイルなども簡単に実装できるようになっています。(この辺がFunctinal CSSの由来かもしれません)
例えば下記のような定義のHTML(JS)が存在した場合、
<div className="mt-6 flex flex-col gap-2 group">hover area</div>
<div className="group-hover:bg-emphasis">this will be black</div>
ビルド後には次のようなCSSが構築されます。
.group:hover .group-hover\:bg-black {
--tw-bg-opacity: 1;
background-color: rgb(0 0 0 / var(--tw-bg-opacity));
}
このようにセレクタを利用したスタイルの構築も抽象化してくれるのがTailwindCSS特徴の一つです。
カスタマイズ性
プロジェクトルートに配置する設定ファイルtailwind.config.js
に追記するだけで、プラグインやカスタムクラス、デザインテーマなどを指定することができます。
const config: Config = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
'./src/features/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
/**
* @see https://v1.tailwindcss.com/docs/border-radius
*/
borderRadius: {
md: '6px',
xl: '12px',
'5xl': '60px',
},
borderWidth: {
'1': '1px',
},
colors: {
emphasis: '#D84190',
error: '#E50000',
black: '#000000',
white: '#FFFFFF',
inherit: 'inherit',
},
~
より踏み込んだ使い方もでき、例えばvariantを追加してある程度のOS差分をCSS吸収する、といったことも可能です。
plugins: [
plugin(({ addBase, addComponents, addUtilities, addVariant, theme }) => {
// モバイル対応
addVariant("mobile", "@media screen and (pointer:coarse)");
addVariant("not-mobile", "@media not screen and (pointer:coarse)");
// safari対応
addVariant(
"safari",
"@media screen and (-webkit-min-device-pixel-ratio:0)"
);
// iOS対応
addVariant(
"ios",
"@media screen and (-webkit-min-device-pixel-ratio:0) and (pointer:coarse)"
);
})
~
<div
className={clsx(
"fixed z-second top-header right-0",
// iOSの場合のみ適用する
"ios:bottom-0 ios:right-0"
)}
>
~
スタイルの再利用
ベースの思想として前述したUtility Firstがあるため、スタイルを共通化したいと言って安易に独自のカスタムクラスを生成するのはアンチパターンとなります。
TailwindCSSでは基本的にはコンポネント指向のフレームワーク(※1)と併用することで、共通のスタイルではなく共通のコンポネントを作ることを推奨しているようです。コンポネントを利用することで、独自のスタイルルールを増殖させることなくプロジェクトのデザインを実装することが可能となります。下記がコンポネントと併用した例となります。
import React from 'react';
// 画像表示用コンポーネント
const ArticleImage = ({ src, alt }) => {
return <img className="w-full" src={src} alt={alt} />;
}
// タイトル表示用コンポーネント
const ArticleTitle = ({ children }) => {
return <h2 className="text-xl font-semibold text-gray-800">{children}</h2>;
}
// 本文表示用コンポーネント
const ArticleBody = ({ children }) => {
return <p className="text-base text-gray-600">{children}</p>;
}
// 記事プレビューコンポーネント
const ArticlePreview = ({ imageUrl, title, body }) => {
return (
<div className="max-w-xl mx-auto overflow-hidden bg-white border rounded-lg border-gray-200 shadow-md">
<ArticleImage src={imageUrl} alt={title} />
<div className="p-5 flex flex-col gap-2">
<ArticleTitle>{title}</ArticleTitle>
<ArticleBody>{body}</ArticleBody>
</div>
</div>
);
}
export default ArticlePreview
今回は結構ドメイン寄りのコンポネントを作成していますが、より汎用的なデザインコンポネントとして切り出したりすることで、独自のクラスを作らずにデザインルールに沿った画面を構築していくことが可能になります。
とは言ってもMVCモデル型のフレームワークやテンプレート型のフレームワークを利用する場合など、再利用可能なUIを作るコストが高い場合が想定されるでしょう。そのためTailwindCSSでは、ベースとなるグローバルのCSSファイルに @apply
ディレクティブを追記することでカスタムクラスを作る選択肢が用意されています。
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.btn-primary {
@apply py-2 px-5 bg-violet-500 text-white font-semibold rounded-full shadow-md hover:bg-violet-700 focus:outline-none focus:ring focus:ring-violet-400 focus:ring-opacity-75;
}
}
上記では btn-primary
クラスが作成され、いずれの箇所でも共通のクラスとして利用が可能となります。(※2)
ただ繰り返しになりますが、 @apply
を利用することはTailwindCSSの利点(Utility Firstであることによって得られるシンプルさや保守性など)を殺しかねないとして全く推奨されていないことは留意する必要がありそうです。
※1 WebComponentは後述する理由でまだ併用が難しそう。
※2 tailwind.config.js
内で設定することも可能
TailwindCSSへの批判
TailwindCSSにはまだまだ根強い批判もあり、いくつかの懸念が指摘されています。
HTMLが汚い
HTMLに直接classを記述いく形であるため、何も考えずそのままclassを当てていくと行数が長くなったり、もはやどこの部分で何をやっているのかわからなくなる懸念は確かにあります。
これもスタイルの再利用で紹介したように、閉じたコンポネントを細かく切っていくことでどのコンポネントが画面上の何を担っているのか、わかりやすい形で設計するなどの対策が取れそうです。
ShadowDOMやWebComponentsを考慮していない
TailwindCSSでは、ビルド後は基本的に一つのCSSだけが作成され、それを全てのページで参照する形になります。そのためスタイルのスコープが断絶するShadowDom内ではTailwindで定義されている全てのスタイルルールが利用できないことになります。
この点についてはコミュニティでもいくつか議論があるようで、対策についていくつか提案がありました。例えば、
- twindのようなTailwind互換のランタイムを導入し、WebComponent内で利用する
- ShadowDOM内でtailwindのCSSをimportする
- ただ、styleを独立させるのがShadowDOMの目的なので、同じものをimportするとShadowDOMを利用する意味がない
今後の発展次第では、また違う解決策が出てくるかもしれません。
参考記事
Discussion