T-CREATOR

ユーザー体験を劇的に変える!React で実装するマイクロインタラクション事例集

ユーザー体験を劇的に変える!React で実装するマイクロインタラクション事例集

「なぜこのボタンをクリックしても反応が薄いのだろう?」「フォームの入力がうまくいったのか分からない」—— こうした小さな違和感が、実はユーザーの離脱率を大きく左右していることをご存知でしょうか。

現代の Web アプリケーションにおいて、機能的な要件を満たすだけでは不十分です。ユーザーが操作の一つひとつに対して「気持ちよさ」を感じられるかどうかが、アプリの成功を左右する重要な要因となっています。

今回は、React アプリケーションでユーザー体験を劇的に向上させるマイクロインタラクションの実装方法を、効果別に分類して詳しく解説いたします。単なる見た目の装飾ではなく、ユーザーの操作に対する適切なフィードバックを提供する実践的な手法をご紹介します。

背景 - マイクロインタラクションが UX に与える影響

マイクロインタラクションとは、ユーザーの小さな操作に対して提供される瞬間的なフィードバックや反応のことです。ボタンをクリックしたときの視覚的変化、入力フィールドにフォーカスしたときの色の変化、エラーメッセージの表示方法など、日常的に触れる細かな演出がすべてマイクロインタラクションに該当します。

Google や Apple などの大手企業が、これらの細部にまで膨大な時間と労力をかけているのには明確な理由があります。優れたマイクロインタラクションは以下のような効果をもたらします:

ユーザビリティの向上

操作の結果が即座に視覚的に伝わることで、ユーザーは安心して次のアクションを実行できます。特に複雑な操作フローにおいて、この安心感は継続的な利用につながります。

エラー防止と早期発見

入力内容のリアルタイム検証や、操作可能な要素の明確な表示により、ユーザーのミスを未然に防ぐことができます。

ブランド体験の向上

統一感のあるマイクロインタラクションは、ブランドの印象を強化し、プロフェッショナルな印象を与えます。

課題 - 従来の React アプリで見落とされがちなユーザー体験

多くの React 開発者が機能実装に集中するあまり、ユーザー体験の細部が軽視されがちです。実際の開発現場でよく見られる問題をいくつか挙げてみましょう。

フィードバック不足による操作不安

ボタンをクリックしても何も起こらない、処理中なのか完了したのか分からないという状況は、ユーザーに不安を与えます。特に重要な操作(決済、データ送信など)では、この不安が離脱につながることもあります。

状態変化の唐突さ

突然コンテンツが現れたり消えたりすることで、ユーザーは何が起こったのか理解できず、混乱を招きます。

操作可能領域の不明確さ

どの要素がクリック可能で、どの要素が単なる表示なのかが分からないと、ユーザーは迷いながら操作することになります。

これらの問題は、技術的には何も間違っていないにも関わらず、ユーザー体験を大きく損なう要因となっています。

解決策 - 効果的なマイクロインタラクション実装戦略

マイクロインタラクションの実装を成功させるためには、以下の戦略的アプローチが有効です。

目的別分類による実装

すべてのマイクロインタラクションを一律に扱うのではなく、その目的に応じて適切な実装方法を選択することが重要です。フィードバック系、ガイダンス系、継続性向上系の 3 つに分類し、それぞれに最適化されたアプローチを取ります。

パフォーマンス重視の技術選択

美しいアニメーションも、パフォーマンスが悪ければユーザー体験を損ないます。CSS transforms、requestAnimationFrame、React のライフサイクルを適切に活用し、滑らかで軽快な動作を実現します。

アクセシビリティへの配慮

すべてのユーザーが快適に利用できるよう、モーションの停止オプションや、キーボード操作への対応も考慮した実装を心がけます。

測定可能な改善

実装前後でのユーザー行動の変化を測定し、効果を定量的に評価できる仕組みを構築します。

フィードバック系マイクロインタラクション

フィードバック系マイクロインタラクションは、ユーザーの操作に対する即座の反応を提供することで、操作の確実性と安心感を与えます。

ボタンクリック時の視覚的フィードバック

現代の Web アプリケーションにおいて、ボタンのクリック反応は最も重要なマイクロインタラクションの一つです。以下のリップル効果付きボタンは、Material Design のガイドラインに従った自然な反応を提供します。

typescriptimport { useState, useRef, useCallback } from 'react';

interface RippleButtonProps {
  children: React.ReactNode;
  onClick?: () => void;
  disabled?: boolean;
  variant?: 'primary' | 'secondary' | 'danger';
}

interface RippleEffect {
  x: number;
  y: number;
  id: number;
}

function RippleButton({
  children,
  onClick,
  disabled = false,
  variant = 'primary',
}: RippleButtonProps) {
  const [ripples, setRipples] = useState<RippleEffect[]>(
    []
  );
  const [isPressed, setIsPressed] = useState(false);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const rippleId = useRef(0);

  const handleClick = useCallback(
    (e: React.MouseEvent<HTMLButtonElement>) => {
      if (disabled) return;

      const button = buttonRef.current;
      if (!button) return;

      const rect = button.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;

      const newRipple: RippleEffect = {
        x,
        y,
        id: rippleId.current++,
      };

      setRipples((prev) => [...prev, newRipple]);
      setIsPressed(true);

      // リップル効果の削除
      setTimeout(() => {
        setRipples((prev) =>
          prev.filter(
            (ripple) => ripple.id !== newRipple.id
          )
        );
      }, 600);

      // プレス状態の解除
      setTimeout(() => setIsPressed(false), 150);

      onClick?.();
    },
    [disabled, onClick]
  );

  return (
    <button
      ref={buttonRef}
      onClick={handleClick}
      disabled={disabled}
      className={`ripple-button ${variant} ${
        isPressed ? 'pressed' : ''
      }`}
      style={{
        position: 'relative',
        overflow: 'hidden',
        transform: isPressed ? 'scale(0.98)' : 'scale(1)',
        transition: 'transform 0.15s ease-out',
      }}
    >
      {children}
      {ripples.map((ripple) => (
        <span
          key={ripple.id}
          className='ripple-effect'
          style={{
            position: 'absolute',
            left: ripple.x,
            top: ripple.y,
            width: '2px',
            height: '2px',
            borderRadius: '50%',
            backgroundColor: 'rgba(255, 255, 255, 0.6)',
            transform: 'scale(0)',
            animation: 'ripple-animation 0.6s ease-out',
            pointerEvents: 'none',
          }}
        />
      ))}
    </button>
  );
}

上記の実装では、クリックされた位置からリップル効果が広がる演出を CSS アニメーションと組み合わせて実現しています。transform: scale() を使用することで、GPU アクセラレーションを活用し、滑らかな動作を保証しています。

CSS スタイルの定義も重要な要素です:

css.ripple-button {
  padding: 12px 24px;
  border: none;
  border-radius: 8px;
  font-size: 16px;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.2s ease-out;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.ripple-button.primary {
  background-color: #3b82f6;
  color: white;
}

.ripple-button.secondary {
  background-color: #e5e7eb;
  color: #374151;
}

.ripple-button.danger {
  background-color: #ef4444;
  color: white;
}

.ripple-button:hover:not(:disabled) {
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
  transform: translateY(-1px);
}

.ripple-button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

@keyframes ripple-animation {
  to {
    transform: scale(40);
    opacity: 0;
  }
}

入力フィールドのフォーカス・バリデーション反応

入力フィールドにおけるマイクロインタラクションは、ユーザーの入力体験を大きく左右します。以下の実装では、フォーカス状態の視覚的フィードバックと、リアルタイムバリデーションを組み合わせています。

typescriptimport { useState, useCallback, useEffect } from 'react';

interface ValidatedInputProps {
  label: string;
  type?: 'text' | 'email' | 'password';
  value: string;
  onChange: (value: string) => void;
  validator?: (value: string) => string | null;
  required?: boolean;
}

function ValidatedInput({
  label,
  type = 'text',
  value,
  onChange,
  validator,
  required = false,
}: ValidatedInputProps) {
  const [isFocused, setIsFocused] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [isValid, setIsValid] = useState(false);
  const [hasBeenBlurred, setHasBeenBlurred] =
    useState(false);

  const validateValue = useCallback(
    (inputValue: string) => {
      if (required && !inputValue) {
        return 'This field is required';
      }

      if (validator) {
        return validator(inputValue);
      }

      // デフォルトバリデーション
      if (type === 'email' && inputValue) {
        const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
        if (!emailRegex.test(inputValue)) {
          return 'Please enter a valid email address';
        }
      }

      return null;
    },
    [required, validator, type]
  );

  useEffect(() => {
    const errorMessage = validateValue(value);
    setError(errorMessage);
    setIsValid(!errorMessage && value.length > 0);
  }, [value, validateValue]);

  const handleFocus = () => {
    setIsFocused(true);
  };

  const handleBlur = () => {
    setIsFocused(false);
    setHasBeenBlurred(true);
  };

  const handleChange = (
    e: React.ChangeEvent<HTMLInputElement>
  ) => {
    onChange(e.target.value);
  };

  const showError = error && hasBeenBlurred && !isFocused;
  const showSuccess =
    isValid && hasBeenBlurred && !isFocused;

  return (
    <div className='input-container'>
      <div className='input-wrapper'>
        <input
          type={type}
          value={value}
          onChange={handleChange}
          onFocus={handleFocus}
          onBlur={handleBlur}
          className={`input-field ${
            isFocused ? 'focused' : ''
          } ${showError ? 'error' : ''} ${
            showSuccess ? 'success' : ''
          }`}
          placeholder=' '
        />
        <label
          className={`input-label ${
            isFocused || value ? 'active' : ''
          }`}
        >
          {label} {required && '*'}
        </label>
        <div className='input-underline' />
      </div>

      {showError && (
        <div className='error-message animate-in'>
          <svg width='16' height='16' viewBox='0 0 16 16'>
            <path
              d='M8 0C3.58 0 0 3.58 0 8s3.58 8 8 8 8-3.58 8-8S12.42 0 8 0zm4.25 10.61L10.61 12.25 8 9.64 5.39 12.25 3.75 10.61 6.36 8 3.75 5.39 5.39 3.75 8 6.36l2.61-2.61 1.64 1.64L9.64 8l2.61 2.61z'
              fill='#ef4444'
            />
          </svg>
          {error}
        </div>
      )}

      {showSuccess && (
        <div className='success-message animate-in'>
          <svg width='16' height='16' viewBox='0 0 16 16'>
            <path
              d='M8 0C3.58 0 0 3.58 0 8s3.58 8 8 8 8-3.58 8-8S12.42 0 8 0zm-1.25 11.25L2.5 7 4.06 5.44 6.75 8.13l5.19-5.19L13.5 4.5l-6.75 6.75z'
              fill='#10b981'
            />
          </svg>
          Input is valid
        </div>
      )}
    </div>
  );
}

対応する CSS スタイルは以下のようになります:

css.input-container {
  position: relative;
  margin-bottom: 24px;
}

.input-wrapper {
  position: relative;
}

.input-field {
  width: 100%;
  padding: 16px 12px 8px;
  border: 2px solid #e5e7eb;
  border-radius: 8px;
  font-size: 16px;
  background-color: white;
  transition: all 0.2s ease-out;
  outline: none;
}

.input-field:focus,
.input-field.focused {
  border-color: #3b82f6;
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}

.input-field.error {
  border-color: #ef4444;
  box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}

.input-field.success {
  border-color: #10b981;
  box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1);
}

.input-label {
  position: absolute;
  left: 12px;
  top: 16px;
  color: #6b7280;
  font-size: 16px;
  pointer-events: none;
  transition: all 0.2s ease-out;
  transform-origin: top left;
}

.input-label.active {
  top: 8px;
  font-size: 12px;
  color: #3b82f6;
  transform: scale(0.85);
}

.input-underline {
  position: absolute;
  bottom: 0;
  left: 0;
  height: 2px;
  width: 100%;
  background-color: #3b82f6;
  transform: scaleX(0);
  transition: transform 0.2s ease-out;
}

.input-field:focus + .input-label + .input-underline {
  transform: scaleX(1);
}

.error-message,
.success-message {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 8px;
  font-size: 14px;
}

.error-message {
  color: #ef4444;
}

.success-message {
  color: #10b981;
}

.animate-in {
  animation: slide-in 0.3s ease-out;
}

@keyframes slide-in {
  from {
    opacity: 0;
    transform: translateY(-10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

成功・エラー時の状態表示

操作の結果をユーザーに明確に伝えるための状態表示システムです。以下の実装では、トーストメッセージの形で成功・エラー状態を表示します。

typescriptimport { useState, useEffect, useCallback } from 'react';

interface ToastMessage {
  id: string;
  type: 'success' | 'error' | 'info' | 'warning';
  title: string;
  message: string;
  duration?: number;
}

interface ToastContextType {
  showToast: (toast: Omit<ToastMessage, 'id'>) => void;
  hideToast: (id: string) => void;
}

const ToastContext =
  React.createContext<ToastContextType | null>(null);

function ToastProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const [toasts, setToasts] = useState<ToastMessage[]>([]);

  const showToast = useCallback(
    (toast: Omit<ToastMessage, 'id'>) => {
      const id = Math.random().toString(36).substr(2, 9);
      const newToast: ToastMessage = {
        ...toast,
        id,
        duration: toast.duration || 5000,
      };

      setToasts((prev) => [...prev, newToast]);

      // 自動削除
      setTimeout(() => {
        hideToast(id);
      }, newToast.duration);
    },
    []
  );

  const hideToast = useCallback((id: string) => {
    setToasts((prev) =>
      prev.map((toast) =>
        toast.id === id
          ? { ...toast, removing: true }
          : toast
      )
    );

    // アニメーション完了後に完全削除
    setTimeout(() => {
      setToasts((prev) =>
        prev.filter((toast) => toast.id !== id)
      );
    }, 300);
  }, []);

  return (
    <ToastContext.Provider value={{ showToast, hideToast }}>
      {children}
      <div className='toast-container'>
        {toasts.map((toast) => (
          <ToastItem
            key={toast.id}
            toast={toast}
            onClose={() => hideToast(toast.id)}
          />
        ))}
      </div>
    </ToastContext.Provider>
  );
}

function ToastItem({
  toast,
  onClose,
}: {
  toast: ToastMessage & { removing?: boolean };
  onClose: () => void;
}) {
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    // マウント時のアニメーション
    setTimeout(() => setIsVisible(true), 10);
  }, []);

  useEffect(() => {
    if (toast.removing) {
      setIsVisible(false);
    }
  }, [toast.removing]);

  const getIcon = () => {
    switch (toast.type) {
      case 'success':
        return (
          <svg width='20' height='20' viewBox='0 0 20 20'>
            <path
              d='M10 0C4.48 0 0 4.48 0 10s4.48 10 10 10 10-4.48 10-10S15.52 0 10 0zm-1.5 14.5L3 9l1.41-1.41L8.5 11.67l7.09-7.09L17 6l-8.5 8.5z'
              fill='#10b981'
            />
          </svg>
        );
      case 'error':
        return (
          <svg width='20' height='20' viewBox='0 0 20 20'>
            <path
              d='M10 0C4.48 0 0 4.48 0 10s4.48 10 10 10 10-4.48 10-10S15.52 0 10 0zm5 13.59L13.59 15 10 11.41 6.41 15 5 13.59 8.59 10 5 6.41 6.41 5 10 8.59 13.59 5 15 6.41 11.41 10 15 13.59z'
              fill='#ef4444'
            />
          </svg>
        );
      case 'warning':
        return (
          <svg width='20' height='20' viewBox='0 0 20 20'>
            <path
              d='M10 0C4.48 0 0 4.48 0 10s4.48 10 10 10 10-4.48 10-10S15.52 0 10 0zm1 15H9v-2h2v2zm0-4H9V5h2v6z'
              fill='#f59e0b'
            />
          </svg>
        );
      default:
        return (
          <svg width='20' height='20' viewBox='0 0 20 20'>
            <path
              d='M10 0C4.48 0 0 4.48 0 10s4.48 10 10 10 10-4.48 10-10S15.52 0 10 0zm1 15H9v-2h2v2zm0-4H9V5h2v6z'
              fill='#3b82f6'
            />
          </svg>
        );
    }
  };

  return (
    <div
      className={`toast-item ${toast.type} ${
        isVisible ? 'visible' : ''
      }`}
    >
      <div className='toast-icon'>{getIcon()}</div>
      <div className='toast-content'>
        <div className='toast-title'>{toast.title}</div>
        <div className='toast-message'>{toast.message}</div>
      </div>
      <button className='toast-close' onClick={onClose}>
        <svg width='16' height='16' viewBox='0 0 16 16'>
          <path
            d='M8 0C3.58 0 0 3.58 0 8s3.58 8 8 8 8-3.58 8-8S12.42 0 8 0zm4.25 10.61L10.61 12.25 8 9.64 5.39 12.25 3.75 10.61 6.36 8 3.75 5.39 5.39 3.75 8 6.36l2.61-2.61 1.64 1.64L9.64 8l2.61 2.61z'
            fill='#6b7280'
          />
        </svg>
      </button>
    </div>
  );
}

この実装により、ユーザーの操作に対する明確なフィードバックが提供され、操作の成功・失敗を直感的に理解できます。

ガイダンス系マイクロインタラクション

ガイダンス系マイクロインタラクションは、ユーザーが迷わずに操作を完了できるよう、適切な情報とナビゲーションを提供します。

ツールチップ・ヘルプ表示

コンテキストに応じたヘルプ情報を表示するツールチップの実装です。ユーザーが必要な時に必要な情報を得られるよう設計されています。

typescriptimport { useState, useRef, useEffect } from 'react';

interface TooltipProps {
  content: string;
  placement?: 'top' | 'bottom' | 'left' | 'right';
  delay?: number;
  children: React.ReactNode;
}

function Tooltip({
  content,
  placement = 'top',
  delay = 500,
  children,
}: TooltipProps) {
  const [isVisible, setIsVisible] = useState(false);
  const [position, setPosition] = useState({ x: 0, y: 0 });
  const triggerRef = useRef<HTMLDivElement>(null);
  const tooltipRef = useRef<HTMLDivElement>(null);
  const timeoutRef = useRef<NodeJS.Timeout>();

  const calculatePosition = () => {
    const trigger = triggerRef.current;
    const tooltip = tooltipRef.current;

    if (!trigger || !tooltip) return;

    const triggerRect = trigger.getBoundingClientRect();
    const tooltipRect = tooltip.getBoundingClientRect();

    let x = 0;
    let y = 0;

    switch (placement) {
      case 'top':
        x =
          triggerRect.left +
          triggerRect.width / 2 -
          tooltipRect.width / 2;
        y = triggerRect.top - tooltipRect.height - 8;
        break;
      case 'bottom':
        x =
          triggerRect.left +
          triggerRect.width / 2 -
          tooltipRect.width / 2;
        y = triggerRect.bottom + 8;
        break;
      case 'left':
        x = triggerRect.left - tooltipRect.width - 8;
        y =
          triggerRect.top +
          triggerRect.height / 2 -
          tooltipRect.height / 2;
        break;
      case 'right':
        x = triggerRect.right + 8;
        y =
          triggerRect.top +
          triggerRect.height / 2 -
          tooltipRect.height / 2;
        break;
    }

    // 画面外に出ないよう調整
    x = Math.max(
      8,
      Math.min(x, window.innerWidth - tooltipRect.width - 8)
    );
    y = Math.max(
      8,
      Math.min(
        y,
        window.innerHeight - tooltipRect.height - 8
      )
    );

    setPosition({ x, y });
  };

  const showTooltip = () => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }

    timeoutRef.current = setTimeout(() => {
      setIsVisible(true);
      setTimeout(calculatePosition, 10);
    }, delay);
  };

  const hideTooltip = () => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }
    setIsVisible(false);
  };

  useEffect(() => {
    if (isVisible) {
      calculatePosition();

      const handleResize = () => calculatePosition();
      window.addEventListener('resize', handleResize);

      return () => {
        window.removeEventListener('resize', handleResize);
      };
    }
  }, [isVisible, placement]);

  return (
    <>
      <div
        ref={triggerRef}
        onMouseEnter={showTooltip}
        onMouseLeave={hideTooltip}
        onFocus={showTooltip}
        onBlur={hideTooltip}
        style={{ display: 'inline-block' }}
      >
        {children}
      </div>

      {isVisible && (
        <div
          ref={tooltipRef}
          className={`tooltip ${placement} visible`}
          style={{
            position: 'fixed',
            left: position.x,
            top: position.y,
            zIndex: 9999,
          }}
        >
          {content}
        </div>
      )}
    </>
  );
}

対応する CSS スタイルです:

css.tooltip {
  background-color: #1f2937;
  color: white;
  padding: 8px 12px;
  border-radius: 6px;
  font-size: 14px;
  max-width: 200px;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  opacity: 0;
  transform: scale(0.95);
  transition: opacity 0.2s ease-out, transform 0.2s ease-out;
  pointer-events: none;
}

.tooltip.visible {
  opacity: 1;
  transform: scale(1);
}

.tooltip::before {
  content: '';
  position: absolute;
  width: 0;
  height: 0;
  border: 6px solid transparent;
}

.tooltip.top::before {
  bottom: -12px;
  left: 50%;
  transform: translateX(-50%);
  border-top-color: #1f2937;
}

.tooltip.bottom::before {
  top: -12px;
  left: 50%;
  transform: translateX(-50%);
  border-bottom-color: #1f2937;
}

.tooltip.left::before {
  right: -12px;
  top: 50%;
  transform: translateY(-50%);
  border-left-color: #1f2937;
}

.tooltip.right::before {
  left: -12px;
  top: 50%;
  transform: translateY(-50%);
  border-right-color: #1f2937;
}

操作手順の視覚的ガイド

複雑な操作フローにおいて、ユーザーが迷わずに進めるよう、現在の位置と次のステップを明確に示すガイド機能です。

typescriptimport { useState, useEffect } from 'react';

interface StepGuideProps {
  steps: Array<{
    id: string;
    title: string;
    description: string;
    selector: string;
    action?: 'click' | 'input' | 'scroll';
  }>;
  onComplete?: () => void;
  onSkip?: () => void;
}

function StepGuide({
  steps,
  onComplete,
  onSkip,
}: StepGuideProps) {
  const [currentStep, setCurrentStep] = useState(0);
  const [isVisible, setIsVisible] = useState(true);
  const [targetPosition, setTargetPosition] = useState({
    x: 0,
    y: 0,
  });

  useEffect(() => {
    if (currentStep >= steps.length) {
      onComplete?.();
      return;
    }

    const step = steps[currentStep];
    const targetElement = document.querySelector(
      step.selector
    );

    if (targetElement) {
      const rect = targetElement.getBoundingClientRect();
      setTargetPosition({
        x: rect.left + rect.width / 2,
        y: rect.top + rect.height / 2,
      });

      // 要素をハイライト表示
      targetElement.classList.add('step-highlight');

      // スクロールして要素を表示
      targetElement.scrollIntoView({
        behavior: 'smooth',
        block: 'center',
      });

      return () => {
        targetElement.classList.remove('step-highlight');
      };
    }
  }, [currentStep, steps, onComplete]);

  const nextStep = () => {
    if (currentStep < steps.length - 1) {
      setCurrentStep(currentStep + 1);
    } else {
      onComplete?.();
    }
  };

  const prevStep = () => {
    if (currentStep > 0) {
      setCurrentStep(currentStep - 1);
    }
  };

  const skipTour = () => {
    setIsVisible(false);
    onSkip?.();
  };

  if (!isVisible || currentStep >= steps.length) {
    return null;
  }

  const step = steps[currentStep];

  return (
    <>
      {/* オーバーレイ */}
      <div className='step-overlay' />

      {/* ガイドカード */}
      <div
        className='step-guide-card'
        style={{
          position: 'fixed',
          left: targetPosition.x + 20,
          top: targetPosition.y - 100,
          zIndex: 10000,
        }}
      >
        <div className='step-header'>
          <span className='step-counter'>
            {currentStep + 1} / {steps.length}
          </span>
          <button
            onClick={skipTour}
            className='skip-button'
          >
            Skip Tour
          </button>
        </div>

        <h3 className='step-title'>{step.title}</h3>
        <p className='step-description'>
          {step.description}
        </p>

        {step.action && (
          <div className='step-action'>
            <span className='action-icon'>
              {step.action === 'click' && '👆'}
              {step.action === 'input' && '⌨️'}
              {step.action === 'scroll' && '📜'}
            </span>
            <span className='action-text'>
              {step.action === 'click' && 'Click here'}
              {step.action === 'input' && 'Type here'}
              {step.action === 'scroll' && 'Scroll here'}
            </span>
          </div>
        )}

        <div className='step-controls'>
          <button
            onClick={prevStep}
            disabled={currentStep === 0}
            className='step-button secondary'
          >
            Previous
          </button>
          <button
            onClick={nextStep}
            className='step-button primary'
          >
            {currentStep === steps.length - 1
              ? 'Finish'
              : 'Next'}
          </button>
        </div>
      </div>

      {/* ポインター */}
      <div
        className='step-pointer'
        style={{
          position: 'fixed',
          left: targetPosition.x - 10,
          top: targetPosition.y - 10,
          zIndex: 10001,
        }}
      />
    </>
  );
}

注意を引く要素のハイライト

重要な要素や新機能を効果的にハイライトするためのスポットライト機能です。

typescriptimport { useState, useEffect, useRef } from 'react';

interface SpotlightProps {
  targetSelector: string;
  title: string;
  description: string;
  onClose?: () => void;
  pulse?: boolean;
}

function Spotlight({
  targetSelector,
  title,
  description,
  onClose,
  pulse = true,
}: SpotlightProps) {
  const [isActive, setIsActive] = useState(false);
  const [targetRect, setTargetRect] =
    useState<DOMRect | null>(null);
  const overlayRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const targetElement =
      document.querySelector(targetSelector);
    if (!targetElement) return;

    const rect = targetElement.getBoundingClientRect();
    setTargetRect(rect);
    setIsActive(true);

    // 要素にクラスを追加
    targetElement.classList.add('spotlight-target');

    const handleResize = () => {
      const newRect = targetElement.getBoundingClientRect();
      setTargetRect(newRect);
    };

    window.addEventListener('resize', handleResize);

    return () => {
      window.removeEventListener('resize', handleResize);
      targetElement.classList.remove('spotlight-target');
    };
  }, [targetSelector]);

  const handleClose = () => {
    setIsActive(false);
    setTimeout(() => onClose?.(), 300);
  };

  if (!isActive || !targetRect) return null;

  return (
    <div
      ref={overlayRef}
      className={`spotlight-overlay ${
        pulse ? 'pulse' : ''
      }`}
      style={{
        position: 'fixed',
        top: 0,
        left: 0,
        right: 0,
        bottom: 0,
        zIndex: 9999,
        pointerEvents: 'none',
      }}
    >
      {/* スポットライト効果 */}
      <svg
        width='100%'
        height='100%'
        style={{ position: 'absolute', top: 0, left: 0 }}
      >
        <defs>
          <mask id='spotlight-mask'>
            <rect width='100%' height='100%' fill='white' />
            <ellipse
              cx={targetRect.left + targetRect.width / 2}
              cy={targetRect.top + targetRect.height / 2}
              rx={targetRect.width / 2 + 20}
              ry={targetRect.height / 2 + 20}
              fill='black'
            />
          </mask>
        </defs>
        <rect
          width='100%'
          height='100%'
          fill='rgba(0, 0, 0, 0.8)'
          mask='url(#spotlight-mask)'
        />
      </svg>

      {/* 説明カード */}
      <div
        className='spotlight-card'
        style={{
          position: 'absolute',
          left: targetRect.left + targetRect.width + 20,
          top: targetRect.top,
          pointerEvents: 'all',
        }}
      >
        <div className='spotlight-header'>
          <h3 className='spotlight-title'>{title}</h3>
          <button
            onClick={handleClose}
            className='spotlight-close'
          >
            ×
          </button>
        </div>
        <p className='spotlight-description'>
          {description}
        </p>
        <button
          onClick={handleClose}
          className='spotlight-button'
        >
          Got it!
        </button>
      </div>
    </div>
  );
}

対応する CSS スタイル:

css.step-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.5);
  z-index: 9999;
}

.step-guide-card {
  background: white;
  border-radius: 12px;
  padding: 20px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
  max-width: 300px;
  animation: guide-appear 0.3s ease-out;
}

.step-highlight {
  position: relative;
  z-index: 10000;
  box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.5);
  border-radius: 8px;
}

.step-pointer {
  width: 20px;
  height: 20px;
  background-color: #3b82f6;
  border-radius: 50%;
  animation: pointer-pulse 2s infinite;
}

.spotlight-target {
  position: relative;
  z-index: 10000;
}

.spotlight-overlay.pulse .spotlight-target {
  animation: spotlight-pulse 2s infinite;
}

.spotlight-card {
  background: white;
  border-radius: 12px;
  padding: 20px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
  max-width: 280px;
  animation: card-slide-in 0.3s ease-out;
}

@keyframes guide-appear {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@keyframes pointer-pulse {
  0%,
  100% {
    transform: scale(1);
    opacity: 1;
  }
  50% {
    transform: scale(1.5);
    opacity: 0.7;
  }
}

@keyframes spotlight-pulse {
  0%,
  100% {
    box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7);
  }
  50% {
    box-shadow: 0 0 0 20px rgba(59, 130, 246, 0);
  }
}

@keyframes card-slide-in {
  from {
    opacity: 0;
    transform: translateX(20px);
  }
  to {
    opacity: 1;
    transform: translateX(0);
  }
}

これらのガイダンス系マイクロインタラクションにより、ユーザーは迷うことなく操作を完了できます。

継続性向上系マイクロインタラクション

継続性向上系マイクロインタラクションは、操作の流れを自然に保ち、ユーザーが集中を切らすことなく目的を達成できるよう支援します。

スムーズなページ遷移

ページ間の遷移を滑らかに行うことで、アプリケーションの連続性を保ちます。以下の実装では、共通要素の位置を維持しながら画面遷移を行います。

typescriptimport { useState, useRef, useEffect } from 'react';
import { useRouter } from 'next/router';

interface PageTransitionProps {
  children: React.ReactNode;
  className?: string;
}

function PageTransition({
  children,
  className = '',
}: PageTransitionProps) {
  const [isTransitioning, setIsTransitioning] =
    useState(false);
  const [direction, setDirection] = useState<
    'forward' | 'backward'
  >('forward');
  const router = useRouter();
  const previousPath = useRef<string>('');

  useEffect(() => {
    const handleRouteChangeStart = (url: string) => {
      // 遷移方向を判定
      const currentDepth = router.asPath.split('/').length;
      const nextDepth = url.split('/').length;

      setDirection(
        nextDepth > currentDepth ? 'forward' : 'backward'
      );
      setIsTransitioning(true);
    };

    const handleRouteChangeComplete = () => {
      setTimeout(() => {
        setIsTransitioning(false);
      }, 300);
    };

    router.events.on(
      'routeChangeStart',
      handleRouteChangeStart
    );
    router.events.on(
      'routeChangeComplete',
      handleRouteChangeComplete
    );

    return () => {
      router.events.off(
        'routeChangeStart',
        handleRouteChangeStart
      );
      router.events.off(
        'routeChangeComplete',
        handleRouteChangeComplete
      );
    };
  }, [router]);

  return (
    <div
      className={`page-transition ${className} ${
        isTransitioning ? 'transitioning' : ''
      } ${direction}`}
    >
      {children}
    </div>
  );
}

interface SharedElementProps {
  id: string;
  children: React.ReactNode;
  className?: string;
}

function SharedElement({
  id,
  children,
  className = '',
}: SharedElementProps) {
  const elementRef = useRef<HTMLDivElement>(null);
  const [isAnimating, setIsAnimating] = useState(false);

  useEffect(() => {
    const element = elementRef.current;
    if (!element) return;

    // 共通要素の位置を記録
    const rect = element.getBoundingClientRect();
    sessionStorage.setItem(
      `shared-element-${id}`,
      JSON.stringify({
        x: rect.left,
        y: rect.top,
        width: rect.width,
        height: rect.height,
      })
    );

    // 前のページからの位置情報を取得
    const previousData = sessionStorage.getItem(
      `shared-element-${id}-previous`
    );
    if (previousData) {
      const previous = JSON.parse(previousData);
      const current = {
        x: rect.left,
        y: rect.top,
        width: rect.width,
        height: rect.height,
      };

      if (
        previous.x !== current.x ||
        previous.y !== current.y
      ) {
        setIsAnimating(true);

        // 初期位置を設定
        element.style.transform = `translate(${
          previous.x - current.x
        }px, ${previous.y - current.y}px) scale(${
          previous.width / current.width
        })`;

        // アニメーション開始
        requestAnimationFrame(() => {
          element.style.transition =
            'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)';
          element.style.transform =
            'translate(0, 0) scale(1)';

          setTimeout(() => {
            setIsAnimating(false);
            element.style.transition = '';
            element.style.transform = '';
          }, 300);
        });
      }

      sessionStorage.removeItem(
        `shared-element-${id}-previous`
      );
    }
  }, [id]);

  const handleClick = () => {
    const element = elementRef.current;
    if (!element) return;

    const rect = element.getBoundingClientRect();
    sessionStorage.setItem(
      `shared-element-${id}-previous`,
      JSON.stringify({
        x: rect.left,
        y: rect.top,
        width: rect.width,
        height: rect.height,
      })
    );
  };

  return (
    <div
      ref={elementRef}
      className={`shared-element ${className} ${
        isAnimating ? 'animating' : ''
      }`}
      data-shared-id={id}
      onClick={handleClick}
    >
      {children}
    </div>
  );
}

コンテンツの段階的表示

コンテンツを段階的に表示することで、ユーザーの認知負荷を軽減し、情報の理解を促進します。

typescriptimport { useState, useEffect, useRef } from 'react';

interface StaggeredListProps {
  items: Array<{
    id: string;
    content: React.ReactNode;
  }>;
  delay?: number;
  duration?: number;
  threshold?: number;
}

function StaggeredList({
  items,
  delay = 100,
  duration = 300,
  threshold = 0.1,
}: StaggeredListProps) {
  const [visibleItems, setVisibleItems] = useState<
    Set<string>
  >(new Set());
  const containerRef = useRef<HTMLDivElement>(null);
  const observerRef = useRef<IntersectionObserver>();

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            const itemId =
              entry.target.getAttribute('data-item-id');
            if (itemId) {
              const itemIndex = items.findIndex(
                (item) => item.id === itemId
              );

              // 段階的に表示するためのタイマー
              setTimeout(() => {
                setVisibleItems(
                  (prev) => new Set([...prev, itemId])
                );
              }, itemIndex * delay);
            }
          }
        });
      },
      { threshold }
    );

    observerRef.current = observer;

    return () => {
      observer.disconnect();
    };
  }, [items, delay, threshold]);

  useEffect(() => {
    const container = containerRef.current;
    if (!container || !observerRef.current) return;

    const itemElements = container.querySelectorAll(
      '[data-item-id]'
    );
    itemElements.forEach((element) => {
      observerRef.current?.observe(element);
    });

    return () => {
      itemElements.forEach((element) => {
        observerRef.current?.unobserve(element);
      });
    };
  }, [items]);

  return (
    <div ref={containerRef} className='staggered-list'>
      {items.map((item) => (
        <div
          key={item.id}
          data-item-id={item.id}
          className={`staggered-item ${
            visibleItems.has(item.id) ? 'visible' : ''
          }`}
          style={{
            transitionDuration: `${duration}ms`,
            transitionDelay: visibleItems.has(item.id)
              ? '0ms'
              : `${delay}ms`,
          }}
        >
          {item.content}
        </div>
      ))}
    </div>
  );
}

操作の連続性を保つ演出

フォームの送信や複数ステップの操作において、ユーザーが現在の状況を把握できるよう支援します。

typescriptimport { useState, useEffect, useCallback } from 'react';

interface ProgressiveFormProps {
  steps: Array<{
    id: string;
    title: string;
    component: React.ReactNode;
    validation?: () => boolean;
  }>;
  onComplete?: (data: any) => void;
}

function ProgressiveForm({
  steps,
  onComplete,
}: ProgressiveFormProps) {
  const [currentStep, setCurrentStep] = useState(0);
  const [completedSteps, setCompletedSteps] = useState<
    Set<number>
  >(new Set());
  const [isTransitioning, setIsTransitioning] =
    useState(false);
  const [formData, setFormData] = useState({});

  const nextStep = useCallback(() => {
    const currentStepConfig = steps[currentStep];

    if (
      currentStepConfig.validation &&
      !currentStepConfig.validation()
    ) {
      return;
    }

    setIsTransitioning(true);
    setCompletedSteps(
      (prev) => new Set([...prev, currentStep])
    );

    setTimeout(() => {
      if (currentStep < steps.length - 1) {
        setCurrentStep(currentStep + 1);
      } else {
        onComplete?.(formData);
      }
      setIsTransitioning(false);
    }, 300);
  }, [currentStep, steps, formData, onComplete]);

  const prevStep = useCallback(() => {
    if (currentStep > 0) {
      setIsTransitioning(true);
      setTimeout(() => {
        setCurrentStep(currentStep - 1);
        setIsTransitioning(false);
      }, 300);
    }
  }, [currentStep]);

  return (
    <div className='progressive-form'>
      {/* プログレスバー */}
      <div className='progress-header'>
        <div className='progress-bar'>
          <div
            className='progress-fill'
            style={{
              width: `${
                ((currentStep + 1) / steps.length) * 100
              }%`,
            }}
          />
        </div>
        <div className='progress-steps'>
          {steps.map((step, index) => (
            <div
              key={step.id}
              className={`progress-step ${
                index === currentStep ? 'current' : ''
              } ${
                completedSteps.has(index) ? 'completed' : ''
              }`}
            >
              <div className='step-circle'>
                {completedSteps.has(index)
                  ? '✓'
                  : index + 1}
              </div>
              <span className='step-title'>
                {step.title}
              </span>
            </div>
          ))}
        </div>
      </div>

      {/* ステップコンテンツ */}
      <div className='step-content'>
        <div
          className={`step-container ${
            isTransitioning ? 'transitioning' : ''
          }`}
          style={{
            transform: `translateX(-${currentStep * 100}%)`,
          }}
        >
          {steps.map((step, index) => (
            <div
              key={step.id}
              className={`step-panel ${
                index === currentStep ? 'active' : ''
              }`}
            >
              {step.component}
            </div>
          ))}
        </div>
      </div>

      {/* ナビゲーション */}
      <div className='step-navigation'>
        <button
          onClick={prevStep}
          disabled={currentStep === 0}
          className='nav-button secondary'
        >
          Previous
        </button>
        <button
          onClick={nextStep}
          className='nav-button primary'
        >
          {currentStep === steps.length - 1
            ? 'Complete'
            : 'Next'}
        </button>
      </div>
    </div>
  );
}

対応する CSS スタイル:

css.page-transition {
  position: relative;
  overflow: hidden;
}

.page-transition.transitioning.forward {
  animation: slide-in-right 0.3s ease-out;
}

.page-transition.transitioning.backward {
  animation: slide-in-left 0.3s ease-out;
}

.shared-element.animating {
  z-index: 1000;
}

.staggered-list {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.staggered-item {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 0.3s ease-out, transform 0.3s ease-out;
}

.staggered-item.visible {
  opacity: 1;
  transform: translateY(0);
}

.progressive-form {
  max-width: 600px;
  margin: 0 auto;
  padding: 24px;
}

.progress-header {
  margin-bottom: 32px;
}

.progress-bar {
  height: 4px;
  background-color: #e5e7eb;
  border-radius: 2px;
  overflow: hidden;
  margin-bottom: 24px;
}

.progress-fill {
  height: 100%;
  background-color: #3b82f6;
  transition: width 0.3s ease-out;
}

.progress-steps {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.progress-step {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
}

.step-circle {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  background-color: #e5e7eb;
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: 500;
  font-size: 14px;
  transition: all 0.2s ease-out;
}

.progress-step.current .step-circle {
  background-color: #3b82f6;
  color: white;
}

.progress-step.completed .step-circle {
  background-color: #10b981;
  color: white;
}

.step-content {
  position: relative;
  height: 400px;
  overflow: hidden;
  margin-bottom: 24px;
}

.step-container {
  display: flex;
  width: 100%;
  height: 100%;
  transition: transform 0.3s ease-out;
}

.step-panel {
  flex: 0 0 100%;
  padding: 20px;
}

.step-navigation {
  display: flex;
  justify-content: space-between;
  gap: 16px;
}

.nav-button {
  padding: 12px 24px;
  border: none;
  border-radius: 8px;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.2s ease-out;
}

.nav-button.primary {
  background-color: #3b82f6;
  color: white;
}

.nav-button.secondary {
  background-color: #e5e7eb;
  color: #374151;
}

.nav-button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

@keyframes slide-in-right {
  from {
    transform: translateX(100%);
  }
  to {
    transform: translateX(0);
  }
}

@keyframes slide-in-left {
  from {
    transform: translateX(-100%);
  }
  to {
    transform: translateX(0);
  }
}

具体例 - 実装コードと効果測定

以下は、実際のプロジェクトで活用できる実装例と、その効果を測定するための手法です。

実装時によく発生するエラーと対処法

Memory Leak Warning

vbnetWarning: Can't perform a React state update on an unmounted component.
This is a no-op, but it indicates a memory leak in your application.

解決法:

typescriptuseEffect(() => {
  let isMounted = true;

  const handleAnimation = () => {
    if (isMounted) {
      // アニメーション処理
    }
  };

  return () => {
    isMounted = false;
  };
}, []);

Performance Warning

sqlWarning: setState(...): Can only update a mounted or mounting component.

解決法:

typescriptconst [isAnimating, setIsAnimating] = useState(false);

useEffect(() => {
  const timeoutId = setTimeout(() => {
    setIsAnimating(false);
  }, 300);

  return () => {
    clearTimeout(timeoutId);
  };
}, []);

効果測定のための実装

マイクロインタラクションの効果を定量的に測定するための実装例です:

typescriptimport { useEffect, useRef } from 'react';

interface AnalyticsProps {
  eventName: string;
  interactionType: 'click' | 'hover' | 'focus' | 'scroll';
  children: React.ReactNode;
}

function InteractionAnalytics({
  eventName,
  interactionType,
  children,
}: AnalyticsProps) {
  const elementRef = useRef<HTMLDivElement>(null);
  const startTime = useRef<number>(0);

  useEffect(() => {
    const element = elementRef.current;
    if (!element) return;

    const handleInteractionStart = () => {
      startTime.current = performance.now();
    };

    const handleInteractionEnd = () => {
      const duration =
        performance.now() - startTime.current;

      // 分析データの送信
      analytics.track(eventName, {
        interactionType,
        duration,
        timestamp: Date.now(),
        userAgent: navigator.userAgent,
      });
    };

    element.addEventListener(
      interactionType,
      handleInteractionStart
    );
    element.addEventListener(
      `${interactionType}end`,
      handleInteractionEnd
    );

    return () => {
      element.removeEventListener(
        interactionType,
        handleInteractionStart
      );
      element.removeEventListener(
        `${interactionType}end`,
        handleInteractionEnd
      );
    };
  }, [eventName, interactionType]);

  return <div ref={elementRef}>{children}</div>;
}

使い分けの指針

#マイクロインタラクション種類適用場面実装難易度UX 効果
1リップル効果ボタン重要なアクション⭐⭐⭐⭐⭐
2バリデーション付き入力フォーム全般⭐⭐⭐⭐⭐⭐⭐
3トーストメッセージ操作結果の通知⭐⭐⭐⭐⭐⭐⭐
4ツールチップ説明が必要な要素⭐⭐⭐⭐⭐
5ステップガイド複雑な操作フロー⭐⭐⭐⭐⭐⭐⭐⭐⭐
6スポットライト新機能の紹介⭐⭐⭐⭐⭐⭐⭐
7ページ遷移SPA の画面切替⭐⭐⭐⭐⭐⭐⭐⭐⭐
8段階的表示コンテンツリスト⭐⭐⭐⭐⭐
9プログレシブフォーム多段階入力⭐⭐⭐⭐⭐⭐⭐⭐⭐

まとめ

マイクロインタラクションは、単なる装飾ではなく、ユーザー体験を劇的に向上させる重要な機能です。適切に実装されたマイクロインタラクションは、以下のような効果をもたらします:

操作の安心感向上

ユーザーの操作に対する即座のフィードバックにより、操作の成功・失敗が明確になり、安心して次のアクションを実行できます。

学習コストの削減

直感的なガイダンスにより、ユーザーは迷うことなく操作を完了でき、アプリケーションの学習コストが大幅に削減されます。

継続的な利用促進

滑らかで自然な操作体験により、ユーザーはアプリケーションを継続的に利用する意欲が向上します。

実装時は、パフォーマンスとアクセシビリティに配慮し、ユーザーの多様なニーズに対応することが重要です。また、効果測定を通じて継続的な改善を行い、真にユーザーに価値をもたらすマイクロインタラクションを構築しましょう。

マイクロインタラクションの力を活用して、ユーザーが愛用するアプリケーションを作り上げてください。

関連リンク