Base UI by MUI実践ガイド2025 - スタイルレスUIで自由なデザインシステム構築
MUIの新しいBase UIでヘッドレスコンポーネントを活用したデザインシステムの構築方法を実践解説。Radix UI、Headless UIとの比較から実装例、移行戦略まで包括的にカバーします。
MUIから独立したスタイルレスコンポーネントライブラリBase UIの使い方を徹底解説。アクセシビリティを保ちながら独自のデザインシステムを構築する方法を実例付きで紹介します。
Material-UI で知られる MUI チームが開発した Base UI は、スタイルを持たない高品質な Reactコンポーネントライブラリです。デザインシステムの制約から解放され、完全にカスタマイズ可能な UI を構築できます。
Base UI は、機能とアクセシビリティに焦点を当てた「見た目のない」コンポーネントライブラリです。Material UI のような既定のスタイルがないため、デザインの完全な自由度を提供します。
特徴 | Material UI | Base UI | 適用シーン |
---|---|---|---|
デザインシステム | Material Design | なし(自由) | 独自ブランド構築 |
スタイル | 組み込み済み | スタイルレス | カスタムデザイン |
カスタマイズ性 | 制限あり | 完全自由 | 特殊なUI要件 |
バンドルサイズ | 大きい(~100KB) | 小さい(~30KB) | パフォーマンス重視 |
学習曲線 | 緩やか | やや急 | チームスキルに依存 |
開発速度 | 高速 | 中速 | プロジェクト規模 |
Base UI は、アクセシビリティとユーザビリティのベストプラクティスを維持しながら、デザインの完全な自由を提供します。
# npmを使用
npm install @mui/base
# yarnを使用
yarn add @mui/base
# pnpmを使用
pnpm add @mui/base
# 追加の依存関係(オプション)
npm install clsx # クラス名の条件付き結合
// tsconfig.json
{
"compilerOptions": {
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
}
}
// types.d.ts
declare module '@mui/base' {
// カスタム型定義を追加
}
/* globals.css - リセットとベーススタイル */
*,
*::before,
*::after {
box-sizing: border-box;
}
:root {
/* カスタムプロパティの定義 */
--color-primary: #3b82f6;
--color-secondary: #8b5cf6;
--color-background: #ffffff;
--color-text: #1f2937;
/* スペーシング */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
/* トランジション */
--transition-fast: 150ms ease-in-out;
--transition-normal: 300ms ease-in-out;
}
/* ダークモード */
@media (prefers-color-scheme: dark) {
:root {
--color-background: #1f2937;
--color-text: #f9fafb;
}
}
src/
├── components/
│ ├── base/ # Base UIコンポーネントのラッパー
│ │ ├── Button/
│ │ ├── Input/
│ │ ├── Modal/
│ │ └── Select/
│ ├── ui/ # アプリケーション固有のコンポーネント
│ └── layout/ # レイアウトコンポーネント
├── styles/
│ ├── globals.css
│ ├── variables.css
│ └── utilities.css
├── hooks/ # カスタムフック
└── utils/ # ユーティリティ関数
import { Button } from '@mui/base/Button';
function BasicButton() {
return (
<Button>
Click me
</Button>
);
}
import { Button } from '@mui/base/Button';
import { styled } from '@emotion/styled';
import clsx from 'clsx';
const StyledButton = styled(Button)`
background-color: var(--color-primary);
color: white;
padding: var(--spacing-sm) var(--spacing-md);
border: none;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
&:hover {
background-color: #2563eb;
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
function CustomButton({ variant = 'primary', size = 'md', ...props }) {
return (
<StyledButton
className={clsx({
[`variant-${variant}`]: variant,
[`size-${size}`]: size,
})}
{...props}
/>
);
}
import { Button } from '@mui/base/Button';
function BasicButton() {
return (
<Button>
Click me
</Button>
);
}
import { Button } from '@mui/base/Button';
import { styled } from '@emotion/styled';
import clsx from 'clsx';
const StyledButton = styled(Button)`
background-color: var(--color-primary);
color: white;
padding: var(--spacing-sm) var(--spacing-md);
border: none;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
&:hover {
background-color: #2563eb;
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
&:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
function CustomButton({ variant = 'primary', size = 'md', ...props }) {
return (
<StyledButton
className={clsx({
[`variant-${variant}`]: variant,
[`size-${size}`]: size,
})}
{...props}
/>
);
}
import { Modal } from '@mui/base/Modal';
import { FocusTrap } from '@mui/base/FocusTrap';
import { Portal } from '@mui/base/Portal';
import { useSpring, animated } from '@react-spring/web';
import { forwardRef, ReactNode, useEffect } from 'react';
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
size?: 'sm' | 'md' | 'lg' | 'full';
}
const Backdrop = forwardRef<HTMLDivElement, { open: boolean }>(
({ open, ...props }, ref) => {
const styles = useSpring({
opacity: open ? 1 : 0,
config: { duration: 200 },
});
return (
<animated.div
ref={ref}
style={styles}
className="modal-backdrop"
{...props}
/>
);
}
);
const ModalContent = forwardRef<HTMLDivElement, ModalProps>(
({ open, size = 'md', children, ...props }, ref) => {
const styles = useSpring({
transform: open ? 'scale(1)' : 'scale(0.95)',
opacity: open ? 1 : 0,
config: { tension: 300, friction: 30 },
});
return (
<animated.div
ref={ref}
style={styles}
className={`modal-content modal-${size}`}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
{...props}
>
{children}
</animated.div>
);
}
);
export function AccessibleModal({ open, onClose, title, children, size }: ModalProps) {
// ESCキーでモーダルを閉じる
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape' && open) {
onClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [open, onClose]);
// bodyのスクロールを制御
useEffect(() => {
if (open) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [open]);
return (
<Portal>
<Modal
open={open}
onClose={onClose}
slots={{
backdrop: Backdrop,
root: ModalContent,
}}
slotProps={{
root: { size },
}}
>
<FocusTrap open={open}>
<div className="modal-inner">
<header className="modal-header">
<h2 id="modal-title">{title}</h2>
<button
onClick={onClose}
className="modal-close"
aria-label="モーダルを閉じる"
>
×
</button>
</header>
<div className="modal-body">{children}</div>
</div>
</FocusTrap>
</Modal>
</Portal>
);
}
import { Input } from '@mui/base/Input';
import { forwardRef, useState } from 'react';
interface TextFieldProps {
label: string;
error?: string;
helperText?: string;
required?: boolean;
}
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
({ label, error, helperText, required, ...props }, ref) => {
const [focused, setFocused] = useState(false);
const hasError = Boolean(error);
return (
<div className="form-field">
<label className="form-label">
{label}
{required && <span className="required">*</span>}
</label>
<Input
ref={ref}
className={clsx('form-input', {
'has-error': hasError,
'is-focused': focused,
})}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
aria-invalid={hasError}
aria-describedby={
hasError ? 'error-message' : 'helper-text'
}
{...props}
/>
{hasError && (
<span id="error-message" className="error-text" role="alert">
{error}
</span>
)}
{helperText && !hasError && (
<span id="helper-text" className="helper-text">
{helperText}
</span>
)}
</div>
);
}
);
import { Select, Option } from '@mui/base';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
interface SelectFieldProps {
label: string;
options: Array<{ value: string; label: string }>;
placeholder?: string;
}
export function SelectField({ label, options, placeholder }: SelectFieldProps) {
return (
<div className="form-field">
<label className="form-label">{label}</label>
<Select
className="custom-select"
placeholder={placeholder}
slots={{
root: 'button',
listbox: 'ul',
popup: 'div',
}}
slotProps={{
root: {
className: 'select-button',
},
listbox: {
className: 'select-listbox',
},
popup: {
className: 'select-popup',
},
}}
>
<span className="select-value" />
<ChevronDownIcon className="select-icon" />
{options.map((option) => (
<Option
key={option.value}
value={option.value}
className="select-option"
>
{option.label}
</Option>
))}
</Select>
</div>
);
}
import { Switch } from '@mui/base/Switch';
import { useState } from 'react';
interface ToggleSwitchProps {
label: string;
description?: string;
defaultChecked?: boolean;
onChange?: (checked: boolean) => void;
}
export function ToggleSwitch({
label,
description,
defaultChecked = false,
onChange,
}: ToggleSwitchProps) {
const [checked, setChecked] = useState(defaultChecked);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newChecked = event.target.checked;
setChecked(newChecked);
onChange?.(newChecked);
};
return (
<div className="switch-container">
<div className="switch-content">
<label htmlFor="custom-switch" className="switch-label">
{label}
</label>
{description && (
<p className="switch-description">{description}</p>
)}
</div>
<Switch
id="custom-switch"
checked={checked}
onChange={handleChange}
slotProps={{
root: {
className: 'switch-root',
},
input: {
'aria-label': label,
},
thumb: {
className: 'switch-thumb',
},
track: {
className: 'switch-track',
},
}}
/>
</div>
);
}
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
const formSchema = z.object({
name: z.string().min(2, '名前は2文字以上で入力してください'),
email: z.string().email('有効なメールアドレスを入力してください'),
role: z.string().min(1, '役割を選択してください'),
notifications: z.boolean(),
});
type FormData = z.infer<typeof formSchema>;
export function UserForm() {
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
name: '',
email: '',
role: '',
notifications: false,
},
});
const onSubmit = async (data: FormData) => {
await new Promise((resolve) => setTimeout(resolve, 2000));
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="user-form">
<Controller
name="name"
control={control}
render={({ field }) => (
<TextField
{...field}
label="名前"
error={errors.name?.message}
required
/>
)}
/>
<Controller
name="email"
control={control}
render={({ field }) => (
<TextField
{...field}
type="email"
label="メールアドレス"
error={errors.email?.message}
required
/>
)}
/>
<Controller
name="role"
control={control}
render={({ field }) => (
<SelectField
{...field}
label="役割"
options={[
{ value: 'admin', label: '管理者' },
{ value: 'user', label: 'ユーザー' },
{ value: 'guest', label: 'ゲスト' },
]}
error={errors.role?.message}
/>
)}
/>
<Controller
name="notifications"
control={control}
render={({ field: { value, onChange } }) => (
<ToggleSwitch
label="通知を受け取る"
description="重要な更新情報をメールで受け取ります"
checked={value}
onChange={onChange}
/>
)}
/>
<button
type="submit"
disabled={isSubmitting}
className="submit-button"
>
{isSubmitting ? '送信中...' : '送信'}
</button>
</form>
);
}
チャートを読み込み中...
import { Button, Input, Modal } from '@mui/base';
import { twMerge } from 'tailwind-merge';
import clsx from 'clsx';
// Tailwind CSSを使用したボタンコンポーネント
interface TailwindButtonProps {
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
className?: string;
children: React.ReactNode;
}
export function TailwindButton({
variant = 'primary',
size = 'md',
className,
children,
...props
}: TailwindButtonProps) {
const baseStyles = 'inline-flex items-center justify-center font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50';
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-600',
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300 focus-visible:ring-gray-400',
ghost: 'hover:bg-gray-100 hover:text-gray-900 focus-visible:ring-gray-400',
};
const sizes = {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4',
lg: 'h-12 px-6 text-lg',
};
return (
<Button
className={twMerge(
baseStyles,
variants[variant],
sizes[size],
className
)}
{...props}
>
{children}
</Button>
);
}
// Tailwind CSSを使用したカードコンポーネント
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={twMerge(
'rounded-lg border bg-card text-card-foreground shadow-sm',
className
)}
{...props}
/>
);
}
// 複合コンポーネントパターン
export function UserCard({ user }: { user: User }) {
return (
<Card className="p-6">
<div className="flex items-center space-x-4">
<img
src={user.avatar}
alt={user.name}
className="h-12 w-12 rounded-full"
/>
<div>
<h3 className="text-lg font-semibold">{user.name}</h3>
<p className="text-sm text-gray-500">{user.email}</p>
</div>
</div>
<div className="mt-4 flex space-x-2">
<TailwindButton size="sm">メッセージ</TailwindButton>
<TailwindButton variant="ghost" size="sm">
プロフィール
</TailwindButton>
</div>
</Card>
);
}
Base UI は WCAG 2.1 AA レベルの準拠を目指していますが、実装時には追加の配慮が必要です。
手法 | 削減効果 | 実装難易度 | 推奨度 |
---|---|---|---|
Tree Shaking | ~40% | 低 | ★★★★★ |
Dynamic Import | ~30% | 中 | ★★★★☆ |
Component分割 | ~20% | 中 | ★★★★☆ |
CSS最適化 | ~15% | 低 | ★★★★★ |
色、タイポグラフィ、スペーシングの標準化
再利用可能なコンポーネントライブラリの作成
Storybookでコンポーネントカタログ作成
ダークモード対応、カスタムテーマ機能
Base UI は、アクセシビリティと機能性を犠牲にすることなく、完全にカスタマイズ可能な UI を構築できる強力なツールです。スタイルレスアプローチにより、独自のデザインシステムを自由に実装できます。
特に、独自のブランドアイデンティティを持つプロジェクトや、既存のデザインシステムとの統合が必要な場合、Base UI は理想的な選択肢となるでしょう。