ブログ記事

Base UI by MUI実践ガイド - スタイルレスUIで自由なデザインシステム構築

MUIから独立したスタイルレスコンポーネントライブラリBase UIの使い方を徹底解説。アクセシビリティを保ちながら独自のデザインシステムを構築する方法を実例付きで紹介します。

7分で読めます
R
Rina
Daily Hack 編集長
Web開発
Base UI MUI React デザインシステム アクセシビリティ
Base UI by MUI実践ガイド - スタイルレスUIで自由なデザインシステム構築のヒーロー画像

Material-UI で知られる MUI チームが開発した Base UI は、スタイルを持たない高品質な Reactコンポーネントライブラリです。デザインシステムの制約から解放され、完全にカスタマイズ可能な UI を構築できます。

この記事で学べること

  • Base UI の基本概念と Material UI との違い
  • アクセシブルなコンポーネントの実装方法
  • 独自のデザインシステムの構築手法
  • Tailwind CSS や CSS-in-JS との統合

Base UIとは?スタイルレスUIの新しいアプローチ

Base UI は、機能とアクセシビリティに焦点を当てた「見た目のない」コンポーネントライブラリです。Material UI のような既定のスタイルがないため、デザインの完全な自由度を提供します。

Material UIとBase UIの違い

Material UIとBase UIの比較
特徴 Material UI Base UI 適用シーン
デザインシステム Material Design なし(自由) 独自ブランド構築
スタイル 組み込み済み スタイルレス カスタムデザイン
カスタマイズ性 制限あり 完全自由 特殊なUI要件
バンドルサイズ 大きい(~100KB) 小さい(~30KB) パフォーマンス重視
学習曲線 緩やか やや急 チームスキルに依存
開発速度 高速 中速 プロジェクト規模

Base UI は、アクセシビリティとユーザビリティのベストプラクティスを維持しながら、デザインの完全な自由を提供します。

MUI Team 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/              # ユーティリティ関数

コンポーネントの実装パターン

1. カスタムボタンコンポーネント

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}
    />
  );
}
基本的なBase UI Button
import { Button } from '@mui/base/Button';

function BasicButton() {
  return (
    <Button>
      Click me
    </Button>
  );
}
スタイル付きカスタム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}
    />
  );
}

2. 高度なモーダル実装

AccessibleModal.tsx
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>
);
}

3. アクセシブルなフォームコンポーネント

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>
  );
}

スタイリング戦略

様々なスタイリング手法との統合

Base UIスタイリングアプローチ

チャートを読み込み中...

Tailwind CSSとの統合例

TailwindStyledComponents.tsx
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 レベルの準拠を目指していますが、実装時には追加の配慮が必要です。

アクセシビリティチェックリスト

キーボードナビゲーション 100 %
完了
スクリーンリーダー対応 100 %
完了
カラーコントラスト 95 %
フォーカス管理 90 %

パフォーマンス最適化

バンドルサイズの最適化

Base UIのバンドルサイズ最適化手法
手法 削減効果 実装難易度 推奨度
Tree Shaking ~40% ★★★★★
Dynamic Import ~30% ★★★★☆
Component分割 ~20% ★★★★☆
CSS最適化 ~15% ★★★★★

実践的なプロジェクト例

デザインシステムの構築

デザイントークンの定義

色、タイポグラフィ、スペーシングの標準化

Base UIコンポーネントのラップ

再利用可能なコンポーネントライブラリの作成

ドキュメント作成

Storybookでコンポーネントカタログ作成

テーマシステムの実装

ダークモード対応、カスタムテーマ機能

まとめ

Base UI は、アクセシビリティと機能性を犠牲にすることなく、完全にカスタマイズ可能な UI を構築できる強力なツールです。スタイルレスアプローチにより、独自のデザインシステムを自由に実装できます。

Base UIを選ぶべき理由

  • 完全なデザインの自由度:制約のないカスタマイズ
  • 軽量なバンドルサイズ:必要な機能のみ含む
  • アクセシビリティ保証:WCAG 準拠の実装
  • 型安全性:TypeScriptファーストの設計

特に、独自のブランドアイデンティティを持つプロジェクトや、既存のデザインシステムとの統合が必要な場合、Base UI は理想的な選択肢となるでしょう。

Rinaのプロフィール画像

Rina

Daily Hack 編集長

フルスタックエンジニアとして10年以上の経験を持つ。 大手IT企業やスタートアップでの開発経験を活かし、 実践的で即効性のある技術情報を日々発信中。 特にWeb開発、クラウド技術、AI活用に精通。

この記事は役に立ちましたか?

あなたのフィードバックが記事の改善に役立ちます

この記事は役に立ちましたか?

Daily Hackでは、開発者の皆様に役立つ情報を毎日発信しています。