はじめに
前回まで
Next.js+Tailwind CSS+Storybookの環境構築を完了させました。
今回は各コンポーネントを用意していこうと思います。
- 環境構築
- Storybookで管理しながらコンポーネントを作成
- APIの呼び出し
- コンポーネントを組み合わせてレイアウト構築
完成図
今回は下記のコンポーネントを作ることにしました。
テキストボックス、ボタン、抽選パネルを使い回すことになりそうです。
- ヘッダー
- セレクトボックス
- テキストボックス
- ボタン
- 抽選パネル

コンポーネントを作成する
今回はTailwind CSSでデザイン設定しつつ、Storybookで確認&管理してみます。
カスタムクラス、画像
カスタムクラスとしてmainBlue,mainLightGray,mainDarkGrayを用意しました。
共通カラーとして使います。
tailwind.css config
import type { Config } from "tailwindcss";
export default {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}"],
theme: {
extend: {
colors: {
mainBlue: "#00459c",
mainLightGray: "#b6b6b9",
mainDarkGray: "#85868a",
},
},
},
plugins: [],
} satisfies Config;
画像
ロゴ画像を用意しました。
白文字&透過背景なので見えない!

ヘッダー
Header.tsx
import Image from "next/image";
export const Header = () => {
return (
<header className="w-full px-5 py-3 shadow-lg bg-mainBlue">
<Image src="/images/logo.svg" width={217} height={48} alt="logo" />
</header>
);
};
Next.jsのImageコンポーネントを使ってロゴを表示させました。
設定したクラスは下記のCSS設定を意味します。
- w-full →
width: 100% - px-5, py-3 →
padding: 20px 12px - shadow-lg →
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1) - bg-mainBlue →
background-color: #00459c
Header.stories
import type { Meta, StoryObj } from "@storybook/react";
import { Header } from "./Header";
const meta = {
title: "Lottery/Header",
component: Header,
tags: ["autodocs"],
} satisfies Meta<typeof Header>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {};

渡すpropsが無いので、storyは1つです。
セレクトボックス
Select.tsx
import React, { useState } from "react";
export interface SelectProps {
name: string;
options: { key: string, value: string, label: string }[];
defaultValue: string;
label?: string;
onSelectChange?: (value: string) => void;
}
export const Select = ({ name, options = [], defaultValue, label = "", onSelectChange, ...props }: SelectProps) => {
const [selected, setSelected] = useState(defaultValue);
const handleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
if (onSelectChange) {
onSelectChange(event.target.value);
}
setSelected(event.target.value)
};
return (
<div className="grid gap-y-1">
<label htmlFor={name} className="text-sm text-mainDarkGray">
{label}
</label>
<select value={selected} name={name} onChange={handleChange} className={["h-9","p-2", "rounded-md", "box-border", "border-solid", "border", "border-mainLightGray"].join(" ")} {...props}>
{
options.map(option => {
return <option key={option.key} value={option.value}>{ option.label}</option>
})
}
</select>
</div>
);
};
ラベルを表示切替えできるようにしました。
大枠の<div>には下記のCSS設定をしています。
- grid →
display: grid - gap-y-1 →
row-gap: 4px
``の枠線には3つのクラスが必要でした。
- border-solid →
border-solid - border →
border-width: 1px - border-mainLightGray →
border-color: #b6b6b9
Select.stories.ts
import type { Meta, StoryObj } from "@storybook/react";
import { Select } from "./Select";
const meta = {
title: "Lottery/Select",
component: Select,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
} satisfies Meta<typeof Select>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Label: Story = {
args: {
name: "nums",
label: "桁数",
options: [{ key: "digit1", value: "1", label: "1桁" }, { key: "digit2", value: "2", label: "2桁" }, { key: "digit3", value: "3", label: "3桁" }],
defaultValue: "digit1",
onSelectChange: (value: string) => {console.log(`select is ${value}`)}
},
};
export const NotLabel: Story = {
args: {
name: "nums",
options: [{ key: "digit1", value: "1", label: "1桁" }, { key: "digit2", value: "2", label: "2桁" }, { key: "digit3", value: "3", label: "3桁" }],
defaultValue: "digit1",
onSelectChange: (value: string) => {console.log(`select is ${value}`)}
},
};

ラベルあり、ラベル無しのstoryを用意しました。
テキストボックス
Input.tsx
import React, {useState} from "react";
export interface InputProps {
name: string;
label?: string;
defaultValue?: string;
placeholder?: string;
onInputChange?: (value: string) => void;
}
export const Input = ({ name, label = "", defaultValue="", placeholder = "", onInputChange, ...props }: InputProps) => {
const [inputed, setInputed] = useState(defaultValue);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (onInputChange) {
onInputChange(event.target.value);
}
setInputed(event.target.value);
};
return (
<div className="grid gap-y-1">
<label htmlFor={name} className="text-sm text-mainDarkGray">
{label}
</label>
<input type="text" name={name} value={inputed} placeholder={placeholder} onChange={handleChange} className={["h-9","p-2", "rounded-md", "box-border", "border-solid", "border", "border-mainLightGray"].join(" ")} {...props} />
</div>
);
};
Selectと同様、ラベルを表示切替できるようにしました。
クラス設定もほぼ同じです。
Input.stories.ts
import type { Meta, StoryObj } from "@storybook/react";
import { Input } from "./Input";
const meta = {
title: "Lottery/Input",
component: Input,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
} satisfies Meta<typeof Input>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Label: Story = {
args: {
name: "max",
label: "最大値",
placeholder: "抽選番号の最大値",
onInputChange: (value: string) => {console.log(`Input is ${value}`)}
},
};
export const NotLabel: Story = {
args: {
name: "max",
placeholder: "抽選番号の最大値",
onInputChange: (value: string) => {console.log(`Input is ${value}`)}
},
};

こちらもラベルあり、ラベル無しのstoryを用意しました。
ボタン
Button.tsx
export interface ButtonProps {
label: string;
primary?: boolean;
disabled?: boolean;
size?: "small" | "medium" | "large";
onClick?: () => void;
}
export const Button = ({label, primary = true, disabled= false, size= "medium", ...props}: ButtonProps) => {
const mode = primary ? ["text-white", "bg-mainBlue"] : ["text-mainBlue", "bg-white"];
const isDisabled = disabled ? "opacity-30" : "";
const sizeList = {
small: ["w-12", "py-1", "text-xs"],
medium: ["w-24", "py-2", "text-base"],
large: ["w-48", "py-4", "text-xl"],
};
return (
<button type="button" className={[ "text-center","rounded-md", "box-border", "divide-solid", "border", "border-mainBlue", `${isDisabled}`, ...sizeList[size], ...mode].join(" ")} disabled={disabled} {...props}>
{label}
</button>
);
};
create-next-appのサンプルにあったButtonコンポーネントを修正して使っています。(丁度良かった!)
サイズを大中小から選んで使います。
primaryカラーやdisabledの設定が可能です。
disabled時は透過率を30%になるようクラス設定しました。
- opacity-30 →
opacity: 30%
Button.stories.ts
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./Button";
const meta = {
title: "Lottery/Button",
component: Button,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {
label: "抽選",
primary: true,
},
};
export const PrimarySmall: Story = {
args: {
label: "抽選",
primary: true,
size: "small"
},
};
export const PrimaryLarge: Story = {
args: {
label: "抽選",
primary: true,
size: "large"
},
};
export const PrimaryDisabled: Story = {
args: {
label: "抽選",
primary: true,
disabled: true,
},
};
export const Secondary: Story = {
args: {
label: "抽選済み",
primary: false,
},
};
export const SecondaryDisabled: Story = {
args: {
label: "抽選済み",
primary: false,
disabled: true,
},
};

ボタンサイズ、primary、disabledを組み合わせて用意しました。
「Storybookで管理しているぞ!」という気持ちになりました。
抽選パネル
Panel.tsx
import { useState, useEffect, useCallback } from "react";
import { Button } from "../button/Button";
export interface PanelProps {
url?: string;
getNumber?: () => string | number | Promise<string | number>;
drawTrigger: boolean;
resetTrigger: number;
}
const defaultGetNumber = () => {
return Math.floor(Math.random() * 10);
};
export const Panel = ({ getNumber = defaultGetNumber, drawTrigger, resetTrigger }: PanelProps) => {
const [number, setNumber] = useState<number | string>("?");
const [isFetched, setIsFetched] = useState(Number.isInteger(false));
// 数字を抽選
const drawNumber = useCallback(async () => {
const randomNumber = await getNumber()
setNumber(randomNumber);
setIsFetched(true);
}, [getNumber])
const mode = isFetched ? ["text-white", "bg-mainBlue"] : ["text-mainBlue", "bg-white"];
const label = isFetched ? "抽選済み" : "抽選";
// 外部からの抽選処理
useEffect(() => {
if (drawTrigger && !isFetched) {
drawNumber();
}
}, [drawTrigger, isFetched, drawNumber]);
// 外部からのリセット処理
useEffect(() => {
setNumber("?");
setIsFetched(false);
}, [resetTrigger]);
return (
<div className={["w-fit", "grid", "gap-y-3", "px-3", " py-4", "rounded-lg", "box-border", "shadow-lg"].join(" ")}>
<div className={["w-auto", "grid", "justify-items-center", "items-center", "aspect-square", "rounded-xl", "box-border", "divide-solid", "border-2", "border-mainBlue", "text-5xl", "font-bold", ...mode].join(" ")}>{number}</div>
<Button label={label} primary={!isFetched} disabled={isFetched} onClick={drawNumber} />
</div>
);
};
一括抽選やリセットに対応できるよう、それぞれpropsにトリガーを用意しました。
パネルに描画する数字の取得方法はpropsで渡せるようにしました。
最終的にはaxiosを使ってAPIから数字を取得する予定です。
パネルを正方形にするため、下記のクラスを設定しています。
- aspect-square →
aspect-ratio: 1 / 1
Panel.stories.ts
import type { Meta, StoryObj } from "@storybook/react";
import { Panel } from "./Panel";
const meta = {
title: "Lottery/Panel",
component: Panel,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
} satisfies Meta<typeof Panel>;
export default meta;
type Story = StoryObj<typeof meta>;
export const NotDrawed: Story = {
args: {
drawTrigger: false,
resetTrigger: 0
},
};
export const Drawed: Story = {
args: {
drawTrigger: true,
resetTrigger: 0
},
};

数字描画前と後の2種類を用意しました。
コンポーネントの作成完了!
おわりに
コンポーネント作成は以上です。
次回はAPIを呼び出せるように設定していきます。
