目次
モダンなWebアプリケーションにおいて、検索機能はユーザビリティの核心となる要素です。この記事では、DaisyUIとTailwind CSSを組み合わせて、美しく機能的な検索インターフェースを実装する方法を詳しく解説します。
DaisyUIとは
DaisyUIは、Tailwind CSSをベースとしたコンポーネントライブラリです。事前に定義されたデザインシステムを活用することで、一貫性のあるUIを素早く構築できます。
主な特徴:
- Tailwind CSS完全互換: 既存のTailwindクラスと組み合わせ可能
- テーマシステム: ダークモード対応やカスタムテーマが簡単
- アクセシビリティ: WAI-ARIA準拠のコンポーネント
- 軽量: 必要な分だけバンドルされる
セットアップ
インストール
npm install daisyui
Tailwind CSS設定
// tailwind.config.js
module.exports = {
content: [
'./pages/**/*.{js,ts,jsx,tsx}',
'./components/**/*.{js,ts,jsx,tsx}',
],
plugins: [require('daisyui')],
daisyui: {
themes: ['light', 'dark'],
},
}
基本的な検索コンポーネント
まずは、DaisyUIのinputコンポーネントを使用した基本的な検索フォームを作成します:
export const BasicSearchComponent = () => {
const [searchQuery, setSearchQuery] = useState('')
return (
<div className="form-control w-full max-w-xs">
<label className="label">
<span className="label-text">記事を検索</span>
</label>
<div className="relative">
<input
type="text"
placeholder="キーワードを入力..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="input input-bordered w-full pr-12"
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
</div>
)
}
高度な検索UIの実装
ステート管理とインタラクション
import { useState, useEffect, useRef } from 'react'
export const AdvancedSearchComponent = () => {
const [isSearchOpen, setIsSearchOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [isComposing, setIsComposing] = useState(false)
const searchRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
// 外部クリックでドロップダウンを閉じる
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(event.target as Node)) {
setIsSearchOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
// フォーカス時に入力フィールドを自動選択
useEffect(() => {
if (isSearchOpen && inputRef.current) {
inputRef.current.focus()
}
}, [isSearchOpen])
return (
<div className="w-full max-w-xl mx-auto mb-8 relative" ref={searchRef}>
<div className="form-control">
<div className="relative">
<input
ref={inputRef}
type="text"
value={searchQuery}
onChange={handleInputChange}
onFocus={() => setIsSearchOpen(true)}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={handleCompositionEnd}
placeholder="記事を検索..."
className="input input-bordered input-md w-full pr-12 focus:input-primary transition-all duration-200 hover:shadow-md"
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-base-content opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</div>
{/* ローディングスピナー */}
{isLoading && (
<div className="absolute right-10 top-1/2 transform -translate-y-1/2">
<span className="loading loading-spinner loading-sm text-primary"></span>
</div>
)}
</div>
{/* 検索結果ドロップダウン */}
{isSearchOpen && searchResults.length > 0 && (
<SearchResultsDropdown
results={searchResults}
onItemClick={() => setIsSearchOpen(false)}
/>
)}
{/* 結果なしの表示 */}
{isSearchOpen && searchQuery.length > 2 && searchResults.length === 0 && !isLoading && (
<NoResultsDisplay />
)}
</div>
)
}
検索結果ドロップダウン
interface SearchResult {
url: string
title: string
excerpt: string
}
interface SearchResultsDropdownProps {
results: SearchResult[]
onItemClick: () => void
}
const SearchResultsDropdown = ({ results, onItemClick }: SearchResultsDropdownProps) => {
return (
<div className="absolute z-50 w-full mt-2 left-0 right-0">
<div className="card bg-base-100 shadow-xl border border-base-300">
<div className="card-body p-0 max-h-96 overflow-y-auto">
{results.map((result, index) => (
<a
key={index}
href={result.url}
className="block px-4 py-3 hover:bg-base-200 transition-colors duration-150 border-b border-base-200 last:border-b-0"
onClick={onItemClick}
>
<h3
className="font-semibold text-base-content text-sm mb-1 line-clamp-1"
dangerouslySetInnerHTML={{ __html: result.title }}
/>
<div
className="text-xs text-base-content opacity-60 line-clamp-2 [&_mark]:bg-yellow-200 [&_mark]:text-gray-900 [&_mark]:font-semibold [&_mark]:px-0.5 [&_mark]:rounded"
dangerouslySetInnerHTML={{ __html: result.excerpt }}
/>
</a>
))}
</div>
</div>
</div>
)
}
結果なし表示コンポーネント
const NoResultsDisplay = () => {
return (
<div className="absolute z-50 w-full mt-2 left-0 right-0">
<div className="card bg-base-100 shadow-xl border border-base-300">
<div className="card-body p-8 text-center">
<div className="text-4xl mb-3">🔍</div>
<p className="text-base-content font-medium">検索結果が見つかりませんでした</p>
<p className="text-sm text-base-content opacity-60 mt-1">別のキーワードで試してみてください</p>
</div>
</div>
</div>
)
}
レスポンシブデザインとアクセシビリティ
モバイル対応
const ResponsiveSearchComponent = () => {
return (
<div className="w-full max-w-xl mx-auto mb-8 relative">
{/* デスクトップ版 */}
<div className="hidden md:block">
<input
className="input input-bordered input-lg w-full pr-12 focus:input-primary"
placeholder="記事を検索..."
/>
</div>
{/* モバイル版 */}
<div className="md:hidden">
<input
className="input input-bordered input-md w-full pr-10 focus:input-primary"
placeholder="検索..."
/>
</div>
</div>
)
}
キーボードナビゲーション
const AccessibleSearchComponent = () => {
const [selectedIndex, setSelectedIndex] = useState(-1)
const [searchResults, setSearchResults] = useState([])
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex(prev =>
prev < searchResults.length - 1 ? prev + 1 : prev
)
break
case 'ArrowUp':
e.preventDefault()
setSelectedIndex(prev => prev > 0 ? prev - 1 : -1)
break
case 'Enter':
e.preventDefault()
if (selectedIndex >= 0 && searchResults[selectedIndex]) {
window.location.href = searchResults[selectedIndex].url
}
break
case 'Escape':
setIsSearchOpen(false)
setSelectedIndex(-1)
break
}
}
return (
<div>
<input
onKeyDown={handleKeyDown}
role="combobox"
aria-expanded={isSearchOpen}
aria-haspopup="listbox"
aria-autocomplete="list"
aria-describedby="search-help"
className="input input-bordered w-full"
/>
<div id="search-help" className="sr-only">
矢印キーで選択、Enterで開く、Escapeで閉じる
</div>
{isSearchOpen && (
<ul role="listbox" className="menu bg-base-100 shadow-lg">
{searchResults.map((result, index) => (
<li
key={index}
role="option"
aria-selected={selectedIndex === index}
className={selectedIndex === index ? 'bg-base-200' : ''}
>
<a href={result.url}>{result.title}</a>
</li>
))}
</ul>
)}
</div>
)
}
カスタムテーマの作成
テーマ定義
// tailwind.config.js
module.exports = {
daisyui: {
themes: [
{
'custom-search': {
'primary': '#3b82f6',
'primary-focus': '#2563eb',
'primary-content': '#ffffff',
'secondary': '#f59e0b',
'accent': '#10b981',
'neutral': '#1f2937',
'base-100': '#ffffff',
'base-200': '#f9fafb',
'base-300': '#e5e7eb',
'base-content': '#1f2937',
},
},
],
},
}
テーマ切り替え機能
const ThemeAwareSearchComponent = () => {
const [theme, setTheme] = useState('light')
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
}, [theme])
return (
<div className="space-y-4">
<div className="flex justify-end">
<div className="form-control">
<label className="label cursor-pointer">
<span className="label-text mr-2">ダークモード</span>
<input
type="checkbox"
className="toggle toggle-primary"
onChange={(e) => setTheme(e.target.checked ? 'dark' : 'light')}
/>
</label>
</div>
</div>
<div className="card bg-base-100 shadow-xl">
<div className="card-body">
<SearchComponent />
</div>
</div>
</div>
)
}
パフォーマンス最適化
デバウンス機能
import { useMemo } from 'react'
import { debounce } from 'lodash'
const OptimizedSearchComponent = () => {
const [searchQuery, setSearchQuery] = useState('')
const debouncedSearch = useMemo(
() => debounce(async (query: string) => {
if (query.length > 0) {
await handleSearch(query)
}
}, 300),
[]
)
useEffect(() => {
debouncedSearch(searchQuery)
return () => debouncedSearch.cancel()
}, [searchQuery, debouncedSearch])
return (
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="input input-bordered w-full"
placeholder="高速検索..."
/>
)
}
バーチャルスクロール(大量の結果用)
import { FixedSizeList as List } from 'react-window'
const VirtualizedSearchResults = ({ results }: { results: SearchResult[] }) => {
const Row = ({ index, style }: { index: number; style: any }) => (
<div style={style} className="px-4 py-2 border-b border-base-200">
<a href={results[index].url} className="block hover:bg-base-200">
<h3 className="font-semibold">{results[index].title}</h3>
<p className="text-sm opacity-60">{results[index].excerpt}</p>
</a>
</div>
)
return (
<div className="card bg-base-100 shadow-xl">
<List
height={400}
itemCount={results.length}
itemSize={80}
className="w-full"
>
{Row}
</List>
</div>
)
}
まとめ
DaisyUIとTailwind CSSを組み合わせることで、以下のような利点を持つ検索UIを効率的に実装できます:
主な利点
- 一貫性のあるデザイン: DaisyUIのデザインシステム
- アクセシビリティ: WAI-ARIA準拠のコンポーネント
- レスポンシブ: モバイルファーストなデザイン
- カスタマイズ性: Tailwindの柔軟性を活用
- パフォーマンス: 最適化された実装
実装のポイント
- 適切なコンポーネント選択: DaisyUIのinput、card、menuを活用
- 状態管理: React hooksを使った効率的な状態管理
- ユーザビリティ: キーボードナビゲーションとフォーカス管理
- パフォーマンス: デバウンス処理とバーチャルスクロール
この組み合わせにより、モダンで使いやすい検索インターフェースを短時間で構築できるでしょう。