少し前から Tailwind CSS が話題になっているようです。
しかし数年前にTailwind CSSの記事を読んださいに、MUI(以前はMaterial UIと呼ばれていました)で見栄えの良いボタンは<Button variant="contained" color="primary">ボタン</Button>
と書けば済むのに、
Tailwind CSSでは<button type="button" class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 mr-2 mb-2">ボタン</button>
などと長々と書かなければいけないを知り。これはUIデザインやCSSが得意なWebデザイナー向けのフレームワークなんだな、自分にはMUI/Bootstrapでじゅうぶん😊と思いました。
しかし、今回改めて学んでみると思っていた以上に使いやすい事がわかりました。
Tailwind CSSは使われているのか?
Tailwind CSSが、現在どれくらい使われているのか(どれくらいダウンロードされているのか)をnpm trendsで調べてみました。
下の画像のように赤線の Tailwind CSS は2年前には僅かにしか使われていませんでしたが、最近は Bootstrap に迫っています。
ちなみにMaterial UIはバージョンアップでパッケージ名が@material-uiから@muiに変わったので青線とオレンジ線を加算したグラフを想像して見てください。
Webデザインの出来ないプログラマーにとってのCSS
私は、長年Webアプリ(サービス)を開発しています。HTML/CSSは読み書き出来ますが、Webデザインは全くダメです。Webデザイナーがいないチームで開発する場合は苦労します。
・ Webテンプレート
まだBootstrapのようなフレームワークはない時代、有料のWebテンプレートを使って旧EY-Officeホームページを作りました。またアプリの管理画面等はフリーのWebテンプレートを使っていました。
昔有名だったWebテンプレート会社テンプレートモンスターを検索すると、2020年8月に終了していました。😅
・ Bootstrap
2011年にTwitter社のWebデザイナーがBootstrap(当時はTwitter Bootstrapと呼ばれいたと思います)を公開してくれました。これで長年の悩みが解決しました!
Ruby on RailsとBootstrapを組み合わせると、見栄えの良いアプリ(サービス)がホイホイと作れるようになりました。見かけが良いと、同じ機能アプリでも素晴らしいアプリに見えて満足感に浸れました。😁
・ MUI
Reactでフロントエンドを作るようになると、Bootstrapは使いすぎて飽きたので MUI を使いだしました、Reactコンポーネントで提供されているので楽に使えました。
MUIの困ったところ
ただし、MUI(Bootstrap)にも欠点はありました。
1. 独自属性を覚えなくてはいけない
MUIはReactコンポーネントとして提供されていてカスタマイズ出来るように独自の属性をサポートしています。たとえば<Button variant="contained" color="primary">
のvariant=
はボタンの形状を指定するものですがcolor=
に比べると判りにくい名前ですよね。
2. 細かな指定にはCSSが必要
MUI / Bootstrapはコンポーネントを並べると適度な配置の画面が出来ますが、ここは少しスペースを開けたいなどの指定は独自スタイル指定(MUI V4のmakeStyles
など)やstyle=
属性などを使う必要があります。または細かいCSS指定をするときはstyled-componentsなどをインストールし使っていました。
3. 非互換のバージョンアップ
MUI V4からV5へのバージョンアップでは、パッケージ名が変わったり、makeStyles
が非推奨になったりと非互換のアップデートが行われました。このためバージョンアップ工数が大きくなりました。
Tailwind CSSの導入障壁は思ったより低かった
・ Tailwind CSS UI components
最初に書いたようにTailwind CSSを使って見栄えの良いUIコンポーネントを作るにはデザイン能力やCSSの深い知識が必要になります。しかし Tailwind CSSのComponentsページには有償(1度だけ$299払えばOK)のコンポーネントがあります
また、25 Places Where You Can Get Free Tailwind CSS Components のような無償UIコンポーネントをリスティングしている記事がたくさん見つかります。
・ Bootstrap Utility Classesの便利さ
実はある仕事でReact Bootstrapを使っているのですが、BootstrapにはUtilities > SizingやUtilities > Spacingのような便利なユーティリティー・クラスが定義されています。
例えば、<Button className="w-25 mt-3">ボタン</Button>
は
<Button style={ {width: "25%", marginTop: "1rem"} }>ボタン</Button>
と同等になります。
このようなユーティリティー・クラスは色やフォント、枠など色々と用意されているので、BootstrapのUIコンポーネントのちょっとしたカスタマイズは簡単に行えます。このユーティリティー・クラス名はCSSのオリジナルの名前から作られているので直ぐに想像できるようになります。
実は、ユーティリティー・クラスというのはTailwind CSSの基本コンセプトのユーティリティー・ファーストと同等です。Tailwind CSSはクラス名に多数の単語が並びますが、各単語はCSSがわかっている人には直ぐに想像できるような名称です。
この考え方は、MUI V5やChakra UI にも取り入れられています。
MUIで作ったアプリをTailwind CSSで作ってみた
このブログに何度か出てきているジャンケンReactアプリ・MUI版に近いUIのアプリを、Tailwind CSSで作ってみました。
UIコンポーネントは、Flowbiteの無償UIコンポーネントを使ってみました。ただし今回は最初なので、完全なレスポンシブ・デザインは目指していません、またダークモードにも対応していません。
汎用的なReactコンポーネントとしては、
- Header: ヘッダー表示
- Paper: 四角いエリア
- Table: ヘッダーあり、または無しのテーブル表示
- Button: 見栄えの良いボタン
- Tab: タブで「対戦結果」と「対戦成績」を切り替えて表示できます
感想・まとめ
今回はTailwind CSSやFlowbiteを見ながら(学びながら)2日くらいで作りました。現在ではだいぶTailwind CSSが読めるようになってきまました。
- ButtonはFlowbiteを参考に普通のボタンに陰を追加しました
- グー・チョキ・パーのボタンを並べるのは苦労しましたがFlexレイアウトを使う事で結果としてはシンプルに配置できました
- ジャンケンアプリ内(例: ScoreListItem)にもTailwind CSSが登場しますが、シンプルで(Tailwind CSSに慣れれば)読みやすいです
- 今回つくったTableコンポーネントは汎用性が低いですが、アプリで必要な機能だけの実装なので、シンプルなコンポーネントが作れました
- Tailwind CSSのUIコンポーネント、たとえばFlowbiteのTabsのサンプルコードにはJSが無いので動作しません。しかしReactの中で使うには全く問題ありません、逆に妙なJSが入っていないのは良い事かもしれません
結論としては、Tailwind CSSはなかなか良いものだと感じました。後は使いやすいUIコンポーネントが登場(もうあるのでしょうか?)すれば最強になるかもしれませんね。
サンプルコード
画面
Tailwind CSS
- App.tsx
import React, { useMemo, useState } from "react";
import Jyanken, { Statuses, Score, Te, Judgment } from "./Jyanken";
const JudgmentColor = ["text-[#000000]", "text-[#2979ff]", "text-[#ff1744]"];
const App: React.FC = () => {
const [scores, setScores] = useState<Score[]>([]);
const [status, setStatus] = useState<Statuses>({ draw: 0, win: 0, lose: 0 });
const [tabIndex, setTabIndex] = useState(0);
const jyanken = useMemo(() => new Jyanken(), []);
const tabChange = (ix: number) => {
setTabIndex(ix);
getResult();
};
const getResult = () => {
setScores(jyanken.getScores());
setStatus(jyanken.getStatuses());
};
const pon = (te: number) => {
jyanken.pon(te);
getResult();
};
return (
<div className="ml-8">
<Header>じゃんけん ポン!</Header>
<Paper className="w-3/5">
<JyankenBox actionPon={(te) => pon(te)} />
<Tabs titles={["対戦結果", "対戦成績"]} tabIndex={tabIndex} setTabIndex={tabChange} />
{tabIndex === 0 ? <ScoreList scores={scores} /> : null}
{tabIndex === 1 ? <StatusBox status={status} /> : null}
</Paper>
</div>
);
};
type JyankenBoxProps = {
actionPon: (te: number) => void;
};
const JyankenBox: React.FC<JyankenBoxProps> = ({ actionPon }) => {
return (
<div className="w-[230px] mx-auto flex">
<Button onClick={() => actionPon(Te.Guu)}>グー</Button>
<Button className="mx-5" onClick={() => actionPon(Te.Choki)}>
チョキ
</Button>
<Button onClick={() => actionPon(Te.Paa)}>パー</Button>
</div>
);
};
type ScoreListProps = {
scores: Score[];
};
const ScoreList: React.FC<ScoreListProps> = ({ scores }) => {
return (
<Table
header={["時間", "人間", "コンピュータ", "結果"]}
body={scores.map((score, ix) => (
<ScoreListItem key={ix} score={score} />
))}
/>
);
};
type ScoreListItemProps = {
score: Score;
};
const ScoreListItem: React.FC<ScoreListItemProps> = ({ score }) => {
const teString = ["グー", "チョキ", "パー"];
const judgmentString = ["引き分け", "勝ち", "負け"];
const dateHHMMSS = (d: Date) => d.toTimeString().substring(0, 8);
const tdClass = `px-6 py-4 ${JudgmentColor[score.judgment]}`;
return (
<tr className="bg-white border-b">
<td className={tdClass}>{dateHHMMSS(score.created_at)}</td>
<td className={tdClass}>{teString[score.human]}</td>
<td className={tdClass}>{teString[score.computer]}</td>
<td className={tdClass}>{judgmentString[score.judgment]}</td>
</tr>
);
};
type StatusBoxProps = {
status: Statuses;
};
const StatusBox: React.FC<StatusBoxProps> = ({ status }) => {
const statusRow = (title: string, judge: Judgment, count: number) => (
<tr key={title} className="bg-white border-b">
<th className="pl-16 py-4">{title}</th>
<td className={`text-right pr-16 py-4 ${JudgmentColor[judge]}`}>{count}</td>
</tr>
);
return (
<Table
body={[
statusRow("勝ち", Judgment.Win, status.win),
statusRow("負け", Judgment.Lose, status.lose),
statusRow("引き分け", Judgment.Draw, status.draw)
]}
/>
);
};
// -------------------------------------------------------------------------
type HeaderProps = {
children: React.ReactNode;
};
const Header: React.FC<HeaderProps> = ({ children }) => {
return <h1 className="my-4 text-3xl font-bold">{children}</h1>;
};
type PaperProps = {
children: React.ReactNode;
className?: string;
};
const Paper: React.FC<PaperProps> = ({ children, className }) => {
return <div className={`p-6 bg-white ${className}`}>{children}</div>;
};
type TableProps = {
header?: string[];
body: React.ReactElement<any, any>[];
};
const Table: React.FC<TableProps> = ({ header, body }) => {
return (
<table className="w-full text-sm text-left text-gray-500">
{header && (
<thead className="bg-slate-50 border-r border-l border-b">
<tr>
{header.map((title, ix) => (
<th key={ix} scope="col" className="px-6 py-3">
{title}
</th>
))}
</tr>
</thead>
)}
<tbody className="bg-white border-b border-r border-l">{body}</tbody>
</table>
);
};
type ButtonProps = {
children: React.ReactNode;
onClick: () => void;
className?: string;
};
const Button: React.FC<ButtonProps> = ({ children, onClick, className }) => {
const buttonClass =
"text-white text-center text-sm rounded w-16 px-2 py-2 bg-blue-600 hover:bg-blue-700 shadow shadow-gray-800/50";
return (
<button type="button" onClick={onClick} className={`${buttonClass} ${className}`}>
{children}
</button>
);
};
type TabsProps = {
titles: string[];
tabIndex: number;
setTabIndex: (index: number) => void;
};
const Tabs: React.FC<TabsProps> = ({ titles, tabIndex, setTabIndex }) => {
const titleClass =
"w-full inline-block text-white bg-cyan-500 p-4 border-b-2 hover:text-white";
const TitleSelectedClass = " border-blue-600 active";
return (
<ul className="flex text-sm font-medium text-center border-b border-gray-200 mt-8">
{titles.map((title, ix) => {
return (
<li key={ix} className="w-full">
<a
onClick={(_) => setTabIndex(ix)}
className={titleClass + (ix === tabIndex ? TitleSelectedClass : "")}
>
{title}
</a>
</li>
);
})}
</ul>
);
};
export default App;
- index.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
- index.css
@tailwind base;
@tailwind components;
@tailwind utilities;
MUI
- App.tsx
import React, { useState, useMemo } from "react";
import Button from "@mui/material/Button";
import Paper from "@mui/material/Paper";
import Tabs from "@mui/material/Tabs";
import Tab from "@mui/material/Tab";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import Jyanken, { Statuses, Score, Te, Judgment } from "./Jyanken";
const App: React.FC = () => {
const [scores, setScores] = useState<Score[]>([]);
const [status, setStatus] = useState<Statuses>({ draw: 0, win: 0, lose: 0 });
const [tabIndex, setTabIndex] = useState(0);
const jyanken = useMemo(() => new Jyanken(), []);
const tabChange = (ix: number) => {
setTabIndex(ix);
getResult();
};
const getResult = () => {
setScores(jyanken.getScores());
setStatus(jyanken.getStatuses());
};
const pon = (te: number) => {
jyanken.pon(te);
getResult();
};
const tabStyle = { width: 200, height: 50, color: "#fff", backgroundColor: "#01bcd4" };
return (
<div style={{ marginLeft: 30 }}>
<Header>じゃんけん ポン!</Header>
<JyankenBox actionPon={(te) => pon(te)} />
<Paper style={{ width: 400 }}>
<Tabs value={tabIndex} onChange={(_, ix) => tabChange(ix)}>
<Tab label="対戦結果" value={0} style={tabStyle} />
<Tab label="対戦成績" value={1} style={tabStyle} />
</Tabs>
{tabIndex === 0 ? <ScoreList scores={scores} /> : null}
{tabIndex === 1 ? <StatusBox status={status} /> : null}
</Paper>
</div>
);
};
type HeaderProps = {
children: React.ReactNode;
};
const Header: React.FC<HeaderProps> = ({ children }) => <h1>{children}</h1>;
const judgmentStyle = (judgment: Judgment) => ({
paddingRight: 16,
color: ["#000", "#2979FF", "#FF1744"][judgment]
});
type StatusBoxProps = {
status: Statuses;
};
const StatusBox: React.FC<StatusBoxProps> = (props) => (
<Table>
<TableBody>
<TableRow>
<TableCell variant="head">勝ち</TableCell>
<TableCell style={judgmentStyle(Judgment.Win)}>{props.status.win}</TableCell>
</TableRow>
<TableRow>
<TableCell variant="head">負け</TableCell>
<TableCell style={judgmentStyle(Judgment.Lose)}>{props.status.lose}</TableCell>
</TableRow>
<TableRow>
<TableCell variant="head">引き分け</TableCell>
<TableCell style={judgmentStyle(Judgment.Draw)}>{props.status.draw}</TableCell>
</TableRow>
</TableBody>
</Table>
);
type JyankenBoxProps = {
actionPon: (te: number) => void;
};
const JyankenBox: React.FC<JyankenBoxProps> = (props) => {
const style = { marginLeft: 20 };
return (
<div style={{ marginTop: 40, marginBottom: 30, marginLeft: 50 }}>
<Button
variant="contained"
id="btn-guu"
onClick={() => props.actionPon(Te.Guu)}
style={style}
>
グー
</Button>
<Button
variant="contained"
id="btn-choki"
onClick={() => props.actionPon(Te.Choki)}
style={style}
>
チョキ
</Button>
<Button
variant="contained"
id="btn-paa"
onClick={() => props.actionPon(Te.Paa)}
style={style}
>
パー
</Button>
</div>
);
};
type ScoreListProps = {
scores: Score[];
};
const ScoreList: React.FC<ScoreListProps> = (props) => (
<Table>
<TableHead>
<TableRow>
<TableCell>時間</TableCell>
<TableCell>人間</TableCell>
<TableCell>コンピュータ</TableCell>
<TableCell>結果</TableCell>
</TableRow>
</TableHead>
<TableBody>
{props.scores.map((score, ix) => (
<ScoreListItem key={ix} score={score} />
))}
</TableBody>
</Table>
);
type ScoreListItemProps = {
score: Score;
};
const ScoreListItem: React.FC<ScoreListItemProps> = (props) => {
const teString = ["グー", "チョキ", "パー"];
const judgmentString = ["引き分け", "勝ち", "負け"];
const dateHHMMSS = (d: Date) => d.toTimeString().substring(0, 8);
const { created_at, human, computer, judgment } = props.score;
return (
<TableRow>
<TableCell style={judgmentStyle(judgment)}>{dateHHMMSS(created_at)}</TableCell>
<TableCell style={judgmentStyle(judgment)}>{teString[human]}</TableCell>
<TableCell style={judgmentStyle(judgment)}>{teString[computer]}</TableCell>
<TableCell style={judgmentStyle(judgment)}>{judgmentString[judgment]}</TableCell>
</TableRow>
);
};
export default App;
- Jyanken.ts
export enum Te {
Guu,
Choki,
Paa
}
export enum Judgment {
Draw,
Win,
Lose
}
export type Score = {
human: Te;
computer: Te;
created_at: Date;
judgment: Judgment;
}
export type Statuses ={
draw: number;
win: number;
lose: number;
}
export default class Jyanken {
scores: Score[];
statuses: number[];
constructor() {
this.scores = [];
this.statuses = [0, 0, 0];
}
pon(human_hand: number): void {
const computer_hand = Math.floor(Math.random() * 3);
const judgment = (computer_hand - human_hand + 3) % 3;
this.scores.push({
human: human_hand,
computer: computer_hand,
created_at: new Date(),
judgment: judgment
});
this.statuses[judgment]++;
}
getScores(): Score[] {
return this.scores.slice().reverse();
}
getStatuses(): Statuses {
return {
draw: this.statuses[Judgment.Draw],
win: this.statuses[Judgment.Win],
lose: this.statuses[Judgment.Lose]
};
}
}