Tailwind CSS と Alpine.js で動的 UI を作るベストプラクティス

Web 開発の現場では、ユーザーの体験を向上させるために動的な UI 実装が不可欠となっています。しかし、大規模なフレームワークを導入すると、プロジェクトの複雑性やバンドルサイズが課題となることがあります。
そこで注目されているのが、Alpine.js と Tailwind CSS の組み合わせです。この軽量でありながら強力なペアは、最小限の学習コストで高品質な動的 UI を実現できるため、多くの開発チームで採用されています。本記事では、Alpine.js と Tailwind CSS を使った動的 UI 開発のベストプラクティスについて、基礎から実践的な活用方法まで詳しく解説いたします。
Alpine.js と Tailwind CSS の相性
軽量フレームワークの組み合わせメリット
Alpine.js と Tailwind CSS は、共に軽量性を重視して設計されており、相互に補完し合う特徴があります。以下の表は、主要な組み合わせメリットを示しています。
メリット | Alpine.js | Tailwind CSS | 相乗効果 |
---|---|---|---|
軽量性 | 15KB(gzip) | 必要な分のみ生成 | 50KB 未満での動的 UI 実現 |
学習コスト | 最小限の API | 直感的なクラス名 | 1 週間程度で習得可能 |
開発効率 | 宣言的記述 | ユーティリティファースト | デザインとロジックの統合 |
保守性 | HTML に集約 | 一貫したスタイリング | コンポーネント化しやすい |
他の組み合わせとの比較検討
実際の開発プロジェクトでは、様々な技術スタックが検討されます。Alpine.js + Tailwind CSS の組み合わせと他の選択肢を比較してみましょう。
技術スタック | バンドルサイズ | 学習コスト | 開発速度 | 適用場面 |
---|---|---|---|---|
Alpine.js + Tailwind CSS | 50KB 未満 | 低 | 高 | 軽量な動的 UI |
React + Styled Components | 200KB 以上 | 高 | 中 | 大規模 SPA |
Vue.js + Vuetify | 150KB 以上 | 中 | 高 | 中規模アプリ |
jQuery + Bootstrap | 100KB 以上 | 低 | 中 | レガシー対応 |
企業での採用事例
多くの企業がこの組み合わせを活用して、効率的な Web 開発を実現しています。
企業名 | 活用場面 | 効果 |
---|---|---|
Basecamp | メール管理ツール | 開発時間 60%短縮 |
Laravel | 公式ドキュメント | ページ読み込み速度向上 |
GitHub | 設定画面 | ユーザー体験向上 |
Shopify | 管理画面 | 保守コスト削減 |
開発環境の構築
基本セットアップ
Alpine.js と Tailwind CSS を組み合わせた開発環境を構築します。まず、プロジェクトの初期化から始めましょう。
bash# プロジェクトの初期化
mkdir alpine-tailwind-project
cd alpine-tailwind-project
yarn init -y
# 必要なパッケージのインストール
yarn add -D tailwindcss postcss autoprefixer
yarn add alpinejs
# Tailwind CSS の設定ファイル生成
npx tailwindcss init -p
基本的な HTML 構造を作成します。
html<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>Alpine.js + Tailwind CSS プロジェクト</title>
<link href="./dist/output.css" rel="stylesheet" />
</head>
<body class="bg-gray-100">
<!-- Alpine.js コンポーネントをここに配置 -->
<div x-data="{ message: 'Hello Alpine.js!' }">
<h1
class="text-3xl font-bold text-center py-8"
x-text="message"
></h1>
</div>
<!-- Alpine.js スクリプトの読み込み -->
<script
defer
src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"
></script>
</body>
</html>
CDN vs npm/yarn インストール
開発環境に応じて、最適なインストール方法を選択することが重要です。
html<!-- CDN版の利用(開発・プロトタイプ向け) -->
<script
defer
src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"
></script>
<script src="https://cdn.tailwindcss.com"></script>
javascript// npm/yarn インストール版(本格運用向け)
// main.js
import Alpine from 'alpinejs';
import './styles.css';
// Alpine.js の初期化
Alpine.start();
// グローバルデータの設定
Alpine.data('app', () => ({
isLoading: false,
currentUser: null,
// 共通メソッド
toggleLoading() {
this.isLoading = !this.isLoading;
},
}));
開発ツールの最適化
効率的な開発環境を構築するために、開発ツールを最適化します。
javascript// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
server: {
port: 3000,
open: true,
},
build: {
rollupOptions: {
output: {
manualChunks: {
alpine: ['alpinejs'],
},
},
},
},
});
json// package.json
{
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"css:build": "tailwindcss -i ./src/input.css -o ./dist/output.css --watch"
}
}
基本的な動的コンポーネント実装
データバインディングの実装
Alpine.js での基本的なデータバインディングを実装します。
html<!-- 基本的なデータバインディング -->
<div
x-data="{
user: {
name: '田中太郎',
email: 'tanaka@example.com',
age: 30
},
isEditing: false
}"
>
<!-- 表示モード -->
<div
x-show="!isEditing"
class="bg-white p-6 rounded-lg shadow-md"
>
<h2 class="text-xl font-semibold mb-4">ユーザー情報</h2>
<p class="text-gray-600 mb-2">
<span class="font-medium">名前:</span>
<span x-text="user.name"></span>
</p>
<p class="text-gray-600 mb-2">
<span class="font-medium">メール:</span>
<span x-text="user.email"></span>
</p>
<p class="text-gray-600 mb-4">
<span class="font-medium">年齢:</span>
<span x-text="user.age"></span>歳
</p>
<button
@click="isEditing = true"
class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition-colors"
>
編集
</button>
</div>
<!-- 編集モード -->
<div
x-show="isEditing"
class="bg-white p-6 rounded-lg shadow-md"
>
<h2 class="text-xl font-semibold mb-4">
ユーザー情報編集
</h2>
<div class="space-y-4">
<div>
<label
class="block text-sm font-medium text-gray-700 mb-1"
>
名前
</label>
<input
type="text"
x-model="user.name"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label
class="block text-sm font-medium text-gray-700 mb-1"
>
メール
</label>
<input
type="email"
x-model="user.email"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label
class="block text-sm font-medium text-gray-700 mb-1"
>
年齢
</label>
<input
type="number"
x-model.number="user.age"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div class="mt-6 flex space-x-3">
<button
@click="isEditing = false"
class="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600 transition-colors"
>
保存
</button>
<button
@click="isEditing = false"
class="bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600 transition-colors"
>
キャンセル
</button>
</div>
</div>
</div>
条件付きレンダリング
様々な条件に基づいて要素を表示・非表示を制御します。
html<!-- 条件付きレンダリングの実装 -->
<div
x-data="{
notifications: [
{ type: 'success', message: '保存が完了しました', id: 1 },
{ type: 'error', message: '入力に誤りがあります', id: 2 },
{ type: 'warning', message: '確認が必要です', id: 3 }
],
showNotifications: true,
currentFilter: 'all'
}"
>
<!-- フィルター操作 -->
<div class="mb-4 flex space-x-2">
<button
@click="currentFilter = 'all'"
:class="currentFilter === 'all' ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-700'"
class="px-3 py-1 rounded text-sm"
>
すべて
</button>
<button
@click="currentFilter = 'success'"
:class="currentFilter === 'success' ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-700'"
class="px-3 py-1 rounded text-sm"
>
成功
</button>
<button
@click="currentFilter = 'error'"
:class="currentFilter === 'error' ? 'bg-red-500 text-white' : 'bg-gray-200 text-gray-700'"
class="px-3 py-1 rounded text-sm"
>
エラー
</button>
<button
@click="showNotifications = !showNotifications"
class="bg-gray-500 text-white px-3 py-1 rounded text-sm"
>
表示切替
</button>
</div>
<!-- 通知リスト -->
<div x-show="showNotifications" class="space-y-2">
<template
x-for="notification in notifications.filter(n => currentFilter === 'all' || n.type === currentFilter)"
>
<div
:class="{
'bg-green-100 border-green-400 text-green-700': notification.type === 'success',
'bg-red-100 border-red-400 text-red-700': notification.type === 'error',
'bg-yellow-100 border-yellow-400 text-yellow-700': notification.type === 'warning'
}"
class="p-3 rounded border-l-4"
>
<p x-text="notification.message"></p>
</div>
</template>
</div>
<!-- 通知がない場合のメッセージ -->
<div
x-show="showNotifications && notifications.filter(n => currentFilter === 'all' || n.type === currentFilter).length === 0"
class="text-center text-gray-500 py-8"
>
<p>該当する通知はありません</p>
</div>
</div>
イベント処理とアニメーション
ユーザーの操作に応じたイベント処理とスムーズなアニメーション実装を行います。
html<!-- イベント処理とアニメーション -->
<div
x-data="{
cards: [
{ id: 1, title: 'カード1', content: '最初のカードです', isExpanded: false },
{ id: 2, title: 'カード2', content: '2番目のカードです', isExpanded: false },
{ id: 3, title: 'カード3', content: '3番目のカードです', isExpanded: false }
],
activeCard: null
}"
>
<div class="space-y-4">
<template x-for="card in cards">
<div
@click="card.isExpanded = !card.isExpanded; activeCard = card.isExpanded ? card.id : null"
class="bg-white rounded-lg shadow-md cursor-pointer transition-transform hover:scale-105"
>
<!-- カードヘッダー -->
<div class="p-4 flex justify-between items-center">
<h3
class="text-lg font-semibold"
x-text="card.title"
></h3>
<svg
:class="card.isExpanded ? 'rotate-180' : ''"
class="w-5 h-5 transform transition-transform duration-300"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
></path>
</svg>
</div>
<!-- 展開コンテンツ -->
<div
x-show="card.isExpanded"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95"
class="px-4 pb-4"
>
<p
x-text="card.content"
class="text-gray-600"
></p>
<button
@click.stop="alert('カード' + card.id + 'のアクションが実行されました')"
class="mt-3 bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition-colors"
>
アクション
</button>
</div>
</div>
</template>
</div>
</div>
実用的な UI コンポーネント集
モーダル・ドロワー実装
実際のプロジェクトでよく使用されるモーダルとドロワーを実装します。
html<!-- モーダル実装 -->
<div
x-data="{
isModalOpen: false,
modalData: null,
openModal(data) {
this.modalData = data;
this.isModalOpen = true;
document.body.style.overflow = 'hidden';
},
closeModal() {
this.isModalOpen = false;
document.body.style.overflow = 'auto';
this.modalData = null;
}
}"
>
<!-- モーダル開く操作 -->
<div class="p-6 space-y-4">
<button
@click="openModal({ title: 'プロフィール設定', type: 'profile' })"
class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition-colors"
>
プロフィール設定
</button>
<button
@click="openModal({ title: '削除確認', type: 'confirm' })"
class="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600 transition-colors"
>
削除確認
</button>
</div>
<!-- モーダルオーバーレイ -->
<div
x-show="isModalOpen"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="closeModal()"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
>
<!-- モーダルコンテンツ -->
<div
x-show="isModalOpen"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform scale-95"
x-transition:enter-end="opacity-100 transform scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 transform scale-100"
x-transition:leave-end="opacity-0 transform scale-95"
@click.stop
class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4"
>
<!-- ヘッダー -->
<div class="px-6 py-4 border-b border-gray-200">
<h2
class="text-xl font-semibold text-gray-800"
x-text="modalData?.title"
></h2>
</div>
<!-- コンテンツ -->
<div class="px-6 py-4">
<template x-if="modalData?.type === 'profile'">
<div class="space-y-4">
<div>
<label
class="block text-sm font-medium text-gray-700 mb-1"
>
名前
</label>
<input
type="text"
placeholder="名前を入力"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label
class="block text-sm font-medium text-gray-700 mb-1"
>
メール
</label>
<input
type="email"
placeholder="メールアドレスを入力"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</template>
<template x-if="modalData?.type === 'confirm'">
<div>
<p class="text-gray-600 mb-4">
この操作は取り消せません。本当に削除しますか?
</p>
<div
class="bg-red-50 border border-red-200 rounded-md p-3"
>
<p class="text-red-700 text-sm">
⚠️ 削除されたデータは復元できません
</p>
</div>
</div>
</template>
</div>
<!-- フッター -->
<div
class="px-6 py-4 border-t border-gray-200 flex justify-end space-x-3"
>
<button
@click="closeModal()"
class="bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600 transition-colors"
>
キャンセル
</button>
<button
@click="closeModal()"
:class="modalData?.type === 'confirm' ? 'bg-red-500 hover:bg-red-600' : 'bg-blue-500 hover:bg-blue-600'"
class="text-white px-4 py-2 rounded transition-colors"
>
<span
x-text="modalData?.type === 'confirm' ? '削除' : '保存'"
></span>
</button>
</div>
</div>
</div>
</div>
フォームバリデーション
リアルタイムバリデーション機能付きフォームを実装します。
html<!-- フォームバリデーション -->
<div
x-data="{
formData: {
name: '',
email: '',
password: '',
confirmPassword: ''
},
errors: {},
isSubmitting: false,
validateField(field, value) {
this.errors[field] = '';
switch(field) {
case 'name':
if (value.length < 2) {
this.errors.name = '名前は2文字以上で入力してください';
}
break;
case 'email':
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
this.errors.email = '有効なメールアドレスを入力してください';
}
break;
case 'password':
if (value.length < 8) {
this.errors.password = 'パスワードは8文字以上で入力してください';
}
if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(value)) {
this.errors.password = '大文字、小文字、数字を含めてください';
}
break;
case 'confirmPassword':
if (value !== this.formData.password) {
this.errors.confirmPassword = 'パスワードが一致しません';
}
break;
}
},
validateForm() {
Object.keys(this.formData).forEach(key => {
this.validateField(key, this.formData[key]);
});
return Object.keys(this.errors).every(key => !this.errors[key]);
},
async submitForm() {
if (!this.validateForm()) {
return;
}
this.isSubmitting = true;
try {
// API呼び出しのシミュレーション
await new Promise(resolve => setTimeout(resolve, 2000));
// 成功時の処理
alert('登録が完了しました');
this.formData = { name: '', email: '', password: '', confirmPassword: '' };
this.errors = {};
} catch (error) {
this.errors.submit = '登録に失敗しました。もう一度お試しください。';
} finally {
this.isSubmitting = false;
}
}
}"
>
<form
@submit.prevent="submitForm()"
class="max-w-md mx-auto bg-white p-6 rounded-lg shadow-md space-y-4"
>
<h2
class="text-2xl font-bold text-center text-gray-800 mb-6"
>
ユーザー登録
</h2>
<!-- 名前入力 -->
<div>
<label
class="block text-sm font-medium text-gray-700 mb-1"
>
名前 <span class="text-red-500">*</span>
</label>
<input
type="text"
x-model="formData.name"
@blur="validateField('name', formData.name)"
@input="validateField('name', formData.name)"
:class="errors.name ? 'border-red-500 focus:ring-red-500' : 'border-gray-300 focus:ring-blue-500'"
class="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
placeholder="田中太郎"
/>
<p
x-show="errors.name"
x-text="errors.name"
class="text-red-500 text-sm mt-1"
></p>
</div>
<!-- メール入力 -->
<div>
<label
class="block text-sm font-medium text-gray-700 mb-1"
>
メールアドレス <span class="text-red-500">*</span>
</label>
<input
type="email"
x-model="formData.email"
@blur="validateField('email', formData.email)"
@input="validateField('email', formData.email)"
:class="errors.email ? 'border-red-500 focus:ring-red-500' : 'border-gray-300 focus:ring-blue-500'"
class="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
placeholder="tanaka@example.com"
/>
<p
x-show="errors.email"
x-text="errors.email"
class="text-red-500 text-sm mt-1"
></p>
</div>
<!-- パスワード入力 -->
<div>
<label
class="block text-sm font-medium text-gray-700 mb-1"
>
パスワード <span class="text-red-500">*</span>
</label>
<input
type="password"
x-model="formData.password"
@blur="validateField('password', formData.password)"
@input="validateField('password', formData.password)"
:class="errors.password ? 'border-red-500 focus:ring-red-500' : 'border-gray-300 focus:ring-blue-500'"
class="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
placeholder="8文字以上"
/>
<p
x-show="errors.password"
x-text="errors.password"
class="text-red-500 text-sm mt-1"
></p>
</div>
<!-- パスワード確認 -->
<div>
<label
class="block text-sm font-medium text-gray-700 mb-1"
>
パスワード確認 <span class="text-red-500">*</span>
</label>
<input
type="password"
x-model="formData.confirmPassword"
@blur="validateField('confirmPassword', formData.confirmPassword)"
@input="validateField('confirmPassword', formData.confirmPassword)"
:class="errors.confirmPassword ? 'border-red-500 focus:ring-red-500' : 'border-gray-300 focus:ring-blue-500'"
class="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
placeholder="パスワードを再入力"
/>
<p
x-show="errors.confirmPassword"
x-text="errors.confirmPassword"
class="text-red-500 text-sm mt-1"
></p>
</div>
<!-- 送信エラー -->
<div
x-show="errors.submit"
class="bg-red-50 border border-red-200 rounded-md p-3"
>
<p
x-text="errors.submit"
class="text-red-700 text-sm"
></p>
</div>
<!-- 送信ボタン -->
<button
type="submit"
:disabled="isSubmitting || Object.keys(errors).some(key => errors[key])"
class="w-full py-3 px-4 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
<span x-show="!isSubmitting">登録</span>
<span
x-show="isSubmitting"
class="flex items-center justify-center"
>
<svg
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
登録中...
</span>
</button>
</form>
</div>
タブ・アコーディオン
タブとアコーディオンコンポーネントを実装します。
html<!-- タブコンポーネント -->
<div
x-data="{
activeTab: 'profile',
tabs: [
{ id: 'profile', label: 'プロフィール', icon: '👤' },
{ id: 'settings', label: '設定', icon: '⚙️' },
{ id: 'notifications', label: '通知', icon: '🔔' }
]
}"
>
<div class="bg-white rounded-lg shadow-md">
<!-- タブヘッダー -->
<div class="border-b border-gray-200">
<nav class="flex space-x-8 px-6">
<template x-for="tab in tabs">
<button
@click="activeTab = tab.id"
:class="activeTab === tab.id ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'"
class="py-4 px-1 border-b-2 font-medium text-sm transition-colors"
>
<span
x-text="tab.icon + ' ' + tab.label"
></span>
</button>
</template>
</nav>
</div>
<!-- タブコンテンツ -->
<div class="p-6">
<!-- プロフィールタブ -->
<div
x-show="activeTab === 'profile'"
class="space-y-4"
>
<h3 class="text-lg font-semibold">
プロフィール設定
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label
class="block text-sm font-medium text-gray-700 mb-1"
>
名前
</label>
<input
type="text"
value="田中太郎"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label
class="block text-sm font-medium text-gray-700 mb-1"
>
部署
</label>
<select
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option>開発部</option>
<option>営業部</option>
<option>管理部</option>
</select>
</div>
</div>
</div>
<!-- 設定タブ -->
<div
x-show="activeTab === 'settings'"
class="space-y-4"
>
<h3 class="text-lg font-semibold">
アカウント設定
</h3>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700"
>メール通知</span
>
<button
x-data="{ enabled: true }"
@click="enabled = !enabled"
:class="enabled ? 'bg-blue-500' : 'bg-gray-300'"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
>
<span
:class="enabled ? 'translate-x-6' : 'translate-x-1'"
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
>
</span>
</button>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700"
>プッシュ通知</span
>
<button
x-data="{ enabled: false }"
@click="enabled = !enabled"
:class="enabled ? 'bg-blue-500' : 'bg-gray-300'"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
>
<span
:class="enabled ? 'translate-x-6' : 'translate-x-1'"
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
>
</span>
</button>
</div>
</div>
</div>
<!-- 通知タブ -->
<div
x-show="activeTab === 'notifications'"
class="space-y-4"
>
<h3 class="text-lg font-semibold">通知設定</h3>
<div class="space-y-3">
<div
class="bg-blue-50 border border-blue-200 rounded-md p-4"
>
<h4 class="font-medium text-blue-800">
新着メッセージ
</h4>
<p class="text-sm text-blue-600">
3件の未読メッセージがあります
</p>
</div>
<div
class="bg-green-50 border border-green-200 rounded-md p-4"
>
<h4 class="font-medium text-green-800">
システム更新
</h4>
<p class="text-sm text-green-600">
システムが正常に更新されました
</p>
</div>
</div>
</div>
</div>
</div>
</div>
高度な動的 UI 実装
状態管理パターン
複雑な状態管理を効率的に行うパターンを実装します。
javascript// 状態管理用のグローバルストア
Alpine.store('appState', {
// ユーザー状態
user: {
id: null,
name: '',
email: '',
role: 'user',
},
// アプリケーション状態
app: {
isLoading: false,
currentPage: 'dashboard',
sidebarOpen: false,
notifications: [],
},
// アクション
setUser(userData) {
this.user = { ...this.user, ...userData };
},
setLoading(status) {
this.app.isLoading = status;
},
addNotification(notification) {
this.app.notifications.push({
id: Date.now(),
timestamp: new Date().toISOString(),
...notification,
});
},
removeNotification(id) {
this.app.notifications = this.app.notifications.filter(
(n) => n.id !== id
);
},
navigateTo(page) {
this.app.currentPage = page;
},
});
// 使用例
document.addEventListener('alpine:init', () => {
Alpine.data('dashboard', () => ({
// ストアの状態を参照
get user() {
return this.$store.appState.user;
},
get isLoading() {
return this.$store.appState.app.isLoading;
},
get notifications() {
return this.$store.appState.app.notifications;
},
// メソッド
async loadUserData() {
this.$store.appState.setLoading(true);
try {
// API呼び出しのシミュレーション
const response = await fetch('/api/user');
const userData = await response.json();
this.$store.appState.setUser(userData);
this.$store.appState.addNotification({
type: 'success',
message: 'ユーザー情報を読み込みました',
});
} catch (error) {
this.$store.appState.addNotification({
type: 'error',
message: 'ユーザー情報の読み込みに失敗しました',
});
} finally {
this.$store.appState.setLoading(false);
}
},
}));
});
API 連携とローディング状態
非同期データ処理とローディング状態を適切に管理します。
html<!-- API連携コンポーネント -->
<div
x-data="{
items: [],
isLoading: false,
error: null,
page: 1,
hasMore: true,
async fetchItems(loadMore = false) {
if (this.isLoading) return;
this.isLoading = true;
this.error = null;
try {
const response = await fetch(`/api/items?page=${this.page}&limit=10`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (loadMore) {
this.items = [...this.items, ...data.items];
} else {
this.items = data.items;
}
this.hasMore = data.hasMore;
} catch (error) {
this.error = error.message;
console.error('データ取得エラー:', error);
} finally {
this.isLoading = false;
}
},
async loadMore() {
if (!this.hasMore || this.isLoading) return;
this.page++;
await this.fetchItems(true);
},
async refresh() {
this.page = 1;
this.hasMore = true;
await this.fetchItems(false);
}
}"
x-init="fetchItems()"
>
<div class="max-w-4xl mx-auto p-6">
<!-- ヘッダー -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">
データ一覧
</h1>
<button
@click="refresh()"
:disabled="isLoading"
class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 disabled:bg-gray-400 transition-colors"
>
<span x-show="!isLoading">更新</span>
<span x-show="isLoading" class="flex items-center">
<svg
class="animate-spin -ml-1 mr-2 h-4 w-4"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
更新中...
</span>
</button>
</div>
<!-- エラー表示 -->
<div
x-show="error"
class="bg-red-50 border border-red-200 rounded-md p-4 mb-6"
>
<div class="flex">
<div class="flex-shrink-0">
<svg
class="h-5 w-5 text-red-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
エラーが発生しました
</h3>
<p
class="text-sm text-red-700 mt-1"
x-text="error"
></p>
</div>
</div>
</div>
<!-- データ一覧 -->
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
>
<template x-for="item in items">
<div
class="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow"
>
<h3
class="text-lg font-semibold text-gray-800 mb-2"
x-text="item.title"
></h3>
<p
class="text-gray-600 mb-4"
x-text="item.description"
></p>
<div class="flex justify-between items-center">
<span
class="text-sm text-gray-500"
x-text="new Date(item.createdAt).toLocaleDateString()"
></span>
<button
class="bg-blue-500 text-white px-3 py-1 rounded text-sm hover:bg-blue-600 transition-colors"
>
詳細
</button>
</div>
</div>
</template>
</div>
<!-- ローディング状態 -->
<div
x-show="isLoading && items.length === 0"
class="text-center py-12"
>
<div
class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"
></div>
<p class="mt-4 text-gray-600">
データを読み込み中...
</p>
</div>
<!-- 無限スクロール -->
<div
x-show="hasMore && !isLoading && items.length > 0"
class="text-center mt-8"
>
<button
@click="loadMore()"
class="bg-gray-500 text-white px-6 py-2 rounded hover:bg-gray-600 transition-colors"
>
さらに読み込む
</button>
</div>
<!-- 読み込み中(追加読み込み) -->
<div
x-show="isLoading && items.length > 0"
class="text-center mt-8"
>
<div
class="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"
></div>
<p class="mt-2 text-gray-600">
追加データを読み込み中...
</p>
</div>
<!-- データがない場合 -->
<div
x-show="!isLoading && items.length === 0 && !error"
class="text-center py-12"
>
<svg
class="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-4m-12 0h4m6 0a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
<p class="mt-4 text-gray-600">
データが見つかりません
</p>
</div>
</div>
</div>
レスポンシブ対応の動的調整
画面サイズに応じて動的に UI を調整する実装を行います。
html<!-- レスポンシブ対応コンポーネント -->
<div
x-data="{
isMobile: window.innerWidth < 768,
isTablet: window.innerWidth >= 768 && window.innerWidth < 1024,
isDesktop: window.innerWidth >= 1024,
init() {
this.updateScreenSize();
window.addEventListener('resize', this.updateScreenSize.bind(this));
},
updateScreenSize() {
this.isMobile = window.innerWidth < 768;
this.isTablet = window.innerWidth >= 768 && window.innerWidth < 1024;
this.isDesktop = window.innerWidth >= 1024;
},
getGridCols() {
if (this.isMobile) return 1;
if (this.isTablet) return 2;
return 3;
},
getCardSize() {
if (this.isMobile) return 'small';
if (this.isTablet) return 'medium';
return 'large';
}
}"
>
<div class="container mx-auto p-4">
<!-- レスポンシブヘッダー -->
<div class="flex justify-between items-center mb-6">
<h1
:class="isMobile ? 'text-xl' : 'text-2xl'"
class="font-bold text-gray-800"
>
レスポンシブダッシュボード
</h1>
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-600">
画面サイズ:
<span
x-text="isMobile ? 'Mobile' : isTablet ? 'Tablet' : 'Desktop'"
class="font-medium"
></span>
</span>
</div>
</div>
<!-- 動的グリッド -->
<div
:class="`grid grid-cols-${getGridCols()} gap-${isMobile ? '4' : '6'}`"
>
<template x-for="i in 6">
<div
:class="{
'p-4': getCardSize() === 'small',
'p-6': getCardSize() === 'medium',
'p-8': getCardSize() === 'large'
}"
class="bg-white rounded-lg shadow-md"
>
<h3
:class="isMobile ? 'text-lg' : 'text-xl'"
class="font-semibold text-gray-800 mb-2"
>
カード <span x-text="i"></span>
</h3>
<p
:class="isMobile ? 'text-sm' : 'text-base'"
class="text-gray-600 mb-4"
>
画面サイズに応じて調整されるコンテンツです。
</p>
<button
:class="isMobile ? 'text-sm px-3 py-1' : 'text-base px-4 py-2'"
class="bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
詳細
</button>
</div>
</template>
</div>
<!-- レスポンシブナビゲーション -->
<div class="mt-8">
<template x-if="isMobile">
<!-- モバイル用タブナビゲーション -->
<div
class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 z-50"
>
<div class="flex justify-around py-2">
<button
class="flex flex-col items-center py-2 px-3 text-blue-500"
>
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"
></path>
</svg>
<span class="text-xs">ダッシュボード</span>
</button>
<button
class="flex flex-col items-center py-2 px-3 text-gray-500"
>
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
></path>
</svg>
<span class="text-xs">統計</span>
</button>
<button
class="flex flex-col items-center py-2 px-3 text-gray-500"
>
<svg
class="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
></path>
</svg>
<span class="text-xs">プロフィール</span>
</button>
</div>
</div>
</template>
<template x-if="!isMobile">
<!-- デスクトップ/タブレット用ナビゲーション -->
<div class="bg-white rounded-lg shadow-md p-6">
<div class="flex justify-center space-x-8">
<button
class="flex items-center space-x-2 px-4 py-2 bg-blue-500 text-white rounded"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"
></path>
</svg>
<span>ダッシュボード</span>
</button>
<button
class="flex items-center space-x-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2-2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
></path>
</svg>
<span>統計</span>
</button>
<button
class="flex items-center space-x-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
></path>
</svg>
<span>プロフィール</span>
</button>
</div>
</div>
</template>
</div>
</div>
</div>
パフォーマンス最適化
バンドルサイズ最適化
Alpine.js と Tailwind CSS のバンドルサイズを最小化する設定を行います。
javascript// vite.config.js - バンドル最適化設定
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
// Alpine.js を独立したチャンクに分離
alpine: ['alpinejs'],
// 大きなライブラリを分離
vendor: ['axios', 'date-fns'],
},
},
},
// 圧縮設定
minify: 'terser',
terserOptions: {
compress: {
// 未使用コードの削除
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.log'],
},
},
},
});
javascript// tailwind.config.js - CSS最適化設定
module.exports = {
// 本番環境でのCSS最適化
content: [
'./src/**/*.{html,js,ts,jsx,tsx}',
'./components/**/*.{html,js,ts,jsx,tsx}',
'./pages/**/*.{html,js,ts,jsx,tsx}',
],
// JIT(Just-In-Time)モードの有効化
mode: 'jit',
// 未使用CSSの除去
purge: {
enabled: process.env.NODE_ENV === 'production',
content: [
'./src/**/*.html',
'./src/**/*.js',
'./src/**/*.ts',
],
// 動的クラスの保持
safelist: [
'text-red-500',
'bg-blue-500',
'border-green-500',
// Alpine.js で動的に使用されるクラス
/^(hover|focus|active):/,
/^(sm|md|lg|xl):/,
],
},
plugins: [
// 不要なプラグインを除去
require('@tailwindcss/forms'),
// require('@tailwindcss/typography'), // 使用しない場合は除去
],
};
実際のプロジェクトで発生するビルドエラーの例:
bash# PurgeCSS による誤った削除エラー
Error: Class 'dynamic-class-blue-500' was purged but is being used
# 解決方法: safelist に追加
# tailwind.config.js
safelist: [
'dynamic-class-blue-500',
{ pattern: /bg-(red|green|blue)-(100|500|900)/ }
]
再レンダリング最適化
Alpine.js での効率的な再レンダリングを実装します。
html<!-- 効率的なリストレンダリング -->
<div
x-data="{
items: [],
searchQuery: '',
sortBy: 'name',
// 計算プロパティでフィルタリング・ソート
get filteredItems() {
let filtered = this.items.filter(item =>
item.name.toLowerCase().includes(this.searchQuery.toLowerCase())
);
// ソート処理
return filtered.sort((a, b) => {
if (this.sortBy === 'name') {
return a.name.localeCompare(b.name);
} else if (this.sortBy === 'date') {
return new Date(b.createdAt) - new Date(a.createdAt);
}
return 0;
});
},
// パフォーマンス監視用
renderCount: 0,
init() {
// レンダリング回数をカウント
this.$watch('filteredItems', () => {
this.renderCount++;
console.log('Re-render count:', this.renderCount);
});
}
}"
>
<!-- 検索・ソート操作 -->
<div class="mb-4 flex space-x-4">
<input
type="search"
x-model.debounce.300ms="searchQuery"
placeholder="検索..."
class="px-3 py-2 border border-gray-300 rounded-md"
/>
<select
x-model="sortBy"
class="px-3 py-2 border border-gray-300 rounded-md"
>
<option value="name">名前順</option>
<option value="date">日付順</option>
</select>
<span class="text-sm text-gray-500 self-center">
レンダリング回数: <span x-text="renderCount"></span>
</span>
</div>
<!-- 最適化されたリスト表示 -->
<div class="space-y-2">
<template x-for="item in filteredItems" :key="item.id">
<div class="bg-white p-4 rounded-lg shadow-sm border">
<h3 x-text="item.name" class="font-semibold"></h3>
<p
x-text="item.description"
class="text-gray-600 text-sm"
></p>
<span
x-text="new Date(item.createdAt).toLocaleDateString()"
class="text-xs text-gray-500"
></span>
</div>
</template>
</div>
</div>
プロダクション向け設定
本番環境での最適化設定を実装します。
javascript// package.json - ビルドスクリプト最適化
{
"scripts": {
"dev": "vite --mode development",
"build": "vite build --mode production",
"build:analyze": "vite build --mode production && npx vite-bundle-analyzer dist",
"preview": "vite preview",
"css:minify": "postcss src/styles.css -o dist/styles.min.css"
},
"dependencies": {
"alpinejs": "^3.13.3"
},
"devDependencies": {
"tailwindcss": "^3.3.6",
"postcss": "^8.4.32",
"autoprefixer": "^10.4.16",
"cssnano": "^6.0.1",
"vite": "^5.0.0"
}
}
javascript// postcss.config.js - CSS最適化
module.exports = {
plugins: [
require('tailwindcss'),
require('autoprefixer'),
// 本番環境でのCSS最適化
...(process.env.NODE_ENV === 'production'
? [
require('cssnano')({
preset: [
'default',
{
discardComments: { removeAll: true },
normalizeWhitespace: true,
minifySelectors: true,
},
],
}),
]
: []),
],
};
実際のビルド時に発生するエラーと対処法:
bash# Alpine.js の動的ディレクティブエラー
Alpine.js error: Cannot read properties of undefined (reading 'x-data')
# 解決方法: 初期化タイミングの調整
<script>
document.addEventListener('DOMContentLoaded', () => {
if (typeof Alpine !== 'undefined') {
Alpine.start();
}
});
</script>
bash# Tailwind CSS のクラス競合エラー
Warning: Duplicate utilities detected
# 解決方法: !important の使用
<div class="!bg-blue-500 hover:!bg-blue-600">
重要なスタイルの適用
</div>
パフォーマンス監視とデバッグ
プロダクション環境でのパフォーマンス監視機能を実装します。
javascript// パフォーマンス監視機能
Alpine.data('performanceMonitor', () => ({
// パフォーマンス計測
measurements: [],
init() {
// Alpine.js のライフサイクル監視
this.$nextTick(() => {
this.measureRenderTime();
});
// メモリ使用量監視
this.monitorMemoryUsage();
},
measureRenderTime() {
const startTime = performance.now();
this.$nextTick(() => {
const endTime = performance.now();
const renderTime = endTime - startTime;
this.measurements.push({
timestamp: new Date().toISOString(),
renderTime: Math.round(renderTime * 100) / 100,
type: 'render',
});
// 5秒以上のレンダリングを警告
if (renderTime > 5000) {
console.warn(
'Slow rendering detected:',
renderTime,
'ms'
);
}
});
},
monitorMemoryUsage() {
if ('memory' in performance) {
const memInfo = performance.memory;
this.measurements.push({
timestamp: new Date().toISOString(),
usedJSHeapSize: memInfo.usedJSHeapSize,
totalJSHeapSize: memInfo.totalJSHeapSize,
type: 'memory',
});
}
},
getAverageRenderTime() {
const renderMeasurements = this.measurements.filter(
(m) => m.type === 'render'
);
if (renderMeasurements.length === 0) return 0;
const total = renderMeasurements.reduce(
(sum, m) => sum + m.renderTime,
0
);
return (
Math.round(
(total / renderMeasurements.length) * 100
) / 100
);
},
}));
実際のエラーケースと対処法
開発中によく遭遇するエラーと解決方法をまとめました。
bash# Alpine.js の初期化エラー
Uncaught ReferenceError: Alpine is not defined
# 解決方法
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<!-- または -->
<script>
import Alpine from 'alpinejs'
window.Alpine = Alpine
Alpine.start()
</script>
bash# Tailwind CSS のクラス名エラー
Class 'bg-custom-blue' does not exist
# 解決方法: カスタムカラーの定義
// tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
'custom-blue': '#1e40af'
}
}
}
}
bash# レスポンシブ対応エラー
TypeError: Cannot read properties of undefined (reading 'innerWidth')
# 解決方法: 安全な初期化
x-data="{
screenWidth: typeof window !== 'undefined' ? window.innerWidth : 1024,
init() {
if (typeof window !== 'undefined') {
this.screenWidth = window.innerWidth;
window.addEventListener('resize', () => {
this.screenWidth = window.innerWidth;
});
}
}
}"
まとめ
本記事では、Alpine.js と Tailwind CSS を組み合わせた動的 UI 開発について、基礎から実践的な活用方法まで詳しく解説いたしました。この軽量でありながら強力な組み合わせは、以下のような優れた特徴を持っています。
主要なメリット
項目 | 効果 | 具体的な数値 |
---|---|---|
バンドルサイズ | 軽量化 | 50KB 未満での動的 UI 実現 |
学習コスト | 短期習得 | 1 週間程度で基本的な実装可能 |
開発効率 | 高速開発 | 従来比 60%の時間短縮 |
保守性 | 簡単メンテナンス | HTML に集約された宣言的記述 |
実装時の重要ポイント
パフォーマンス最適化では、JIT モードの活用と適切な PurgeCSS 設定により、本番環境でのバンドルサイズを最小化できます。特に、動的に生成されるクラス名は safelist に適切に登録することが重要です。
状態管理については、Alpine.store を活用したグローバル状態管理により、複雑なアプリケーションでも効率的に開発できます。API 連携やローディング状態の管理も、適切なエラーハンドリングと組み合わせることで、ユーザー体験を大幅に向上させることができます。
レスポンシブ対応では、画面サイズに応じた動的な UI 調整により、すべてのデバイスで最適な表示を実現できます。特に、モバイルファーストのアプローチを採用することで、現代的な Web アプリケーションの要求に応えられます。
今後の活用展望
Alpine.js と Tailwind CSS の組み合わせは、今後も Web 開発の現場で重要な選択肢となることが予想されます。特に、軽量性とパフォーマンスが重視される現代において、この技術スタックの価値はますます高まっています。
プロジェクトの規模や要件に応じて、適切に活用していただければ、効率的で保守性の高い動的 UI を実現できるでしょう。本記事でご紹介した実装パターンやベストプラクティスを参考に、ぜひ実際のプロジェクトでお試しください。
関連リンク
- article
【対処法】Cursorで発生する「You've saved $102 on API model usage this month with Pro...」エラーの原因と対応
- article
Vue.js で作るモダンなフォームバリデーション
- article
Jest で setTimeout・setInterval をテストするコツ
- article
Playwright MCP でクロスリージョン同時テストを実現する
- article
Tailwind CSS と Alpine.js で動的 UI を作るベストプラクティス
- article
Storybook を Next.js プロジェクトに最短で導入する方法
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来
- review
人類はなぜ地球を支配できた?『サピエンス全史 上巻』ユヴァル・ノア・ハラリが解き明かす驚愕の真実
- review
え?世界はこんなに良くなってた!『FACTFULNESS』ハンス・ロスリングが暴く 10 の思い込みの正体