mcpField
M
mobile-design

mobile-design

github.com/manutejデザイン・UI62547 回インストール40 週間前に更新
リポジトリ

mobile-design について

mobile-designは、モバイルWebやネイティブアプリの設計に使うスキルです。レスポンシブレイアウト、タッチ操作、各プラットフォームの慣習、アクセシビリティやパフォーマンスを扱います。

主な機能

  1. 01モバイルファーストの設計原則
  2. 02タッチターゲットとジェスチャーパターン
  3. 03iOS・Androidのナビゲーション慣習
  4. 04レスポンシブブレークポイントとセーフエリア
  5. 05アクセシビリティとパフォーマンスの指針

活用シーン

  1. 01モバイルECの商品一覧の設計
  2. 02ネイティブアプリのナビゲーション設計
  3. 03小画面向けデータテーブルの調整
  4. 04モバイル画面のアクセシビリティ監査

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/manutej/luxor-claude-marketplace --skill mobile-design

インストール後、Claude Code で/mobile-designを入力して読み込まれたか確認します。

同梱ファイルEXAMPLES.mdREADME.md

詳細

モバイルデザインスキル

このスキルを使用する場面

次のような作業にこのスキルを使用してください。

  • モバイルファーストの Web アプリケーション:モバイルユーザー体験を優先するレスポンシブなウェブサイトの構築
  • ネイティブモバイルアプリ:プラットフォーム固有のパターンを用いた iOS または Android アプリケーションの設計
  • プログレッシブウェブアプリ(PWA):ブラウザーでアプリのような体験を実現
  • ハイブリッドモバイルアプリケーション:React Native、Flutter などのフレームワークを使用したクロスプラットフォームアプリの開発
  • レスポンシブデザインシステム:デバイス間でシームレスに適応するコンポーネントの作成
  • タッチファーストのインターフェース:マウスやキーボードではなく、タッチスクリーンでの操作を前提とした設計
  • モバイルコマース:小さな画面に最適化されたショッピング体験の構築
  • モバイルダッシュボード:データ量の多いインターフェースをモバイルで利用できるように調整
  • ジェスチャーベースのインターフェース:スワイプ、ピンチなどのタッチジェスチャーの実装
  • アクセシビリティ監査:モバイルインターフェースがアクセシビリティ基準を満たしていることの確認

このスキルを使うと、ネイティブアプリのように感じられ、パフォーマンスが高く、スマートフォンやタブレットでユーザーを満足させるモバイル体験を作成できます。

基本概念

モバイルファーストのデザイン哲学

モバイルファーストのデザインでは、最小の画面から始め、大きなデバイス向けに段階的に機能を拡張します。

モバイルファーストが有効な理由

  • 重要なコンテンツや機能の優先順位付けが促される
  • デフォルトでパフォーマンスが向上する(軽量なアセット、シンプルなレイアウト)
  • 縮小するよりも拡張するほうが簡単
  • 実際のユーザー行動を反映する(モバイルのトラフィックはデスクトップを上回ることが多い)
  • すべてのデバイスでコア機能が動作することを保証する

モバイルファーストとデスクトップファーストの比較

/* Mobile-First Approach (Recommended) */
/* Base styles for mobile */
.container {
  padding: 16px;
  font-size: 14px;
}

/* Tablet enhancements */
@media (min-width: 768px) {
  .container {
    padding: 24px;
    font-size: 16px;
  }
}

/* Desktop enhancements */
@media (min-width: 1024px) {
  .container {
    padding: 32px;
    max-width: 1200px;
    margin: 0 auto;
  }
}

/* Desktop-First Approach (Not Recommended) */
/* Base styles for desktop */
.container {
  padding: 32px;
  max-width: 1200px;
  margin: 0 auto;
  font-size: 16px;
}

/* Tablet overrides */
@media (max-width: 1023px) {
  .container {
    padding: 24px;
  }
}

/* Mobile overrides */
@media (max-width: 767px) {
  .container {
    padding: 16px;
    font-size: 14px;
  }
}
タッチターゲットと操作性

タッチターゲットの最小サイズ

  • Apple:44×44 ポイント(iOS Human Interface Guidelines)
  • Google:48×48 dp(Material Design)
  • Microsoft:40×40 ピクセル(Windows Phone)
  • 推奨:最小 48×48 ピクセル、最適 56×56 ピクセル

タッチターゲット間の間隔

  • インタラクティブ要素間は最低 8px
  • よく使うコントロールでは 12~16px が最適
  • 種類の異なるボタン(例:キャンセルと確定)であれば、端から端まで隙間なく配置してもかまいません

親指の操作範囲

モバイル画面には、操作しやすさに基づく 3 つのゾーンがあります。

  1. 操作しやすいゾーン(緑):画面下部の 3 分の 1、中央 — 親指が最も届きやすい範囲
  2. 手を伸ばすゾーン(黄):中央のエリア — 少し手を伸ばす必要がある範囲
  3. 操作しにくいゾーン(赤):画面上部の角 — 片手では最も届きにくい範囲

デザインへの反映

  • 主要なアクションは操作しやすいゾーン(下部中央)に配置します
  • 破壊的なアクションは操作しにくいゾーン(画面上部の角)に配置します
  • ナビゲーションは通常、画面上部または下部に配置し、中央には配置しません
  • 左利きと右利きの両方のユーザーを考慮します
// React Native: Bottom-aligned primary action (easy zone)
<View style={styles.container}>
  <ScrollView style={styles.content}>
    {/* Main content */}
  </ScrollView>

  <View style={styles.bottomActions}>
    <TouchableOpacity style={styles.primaryButton}>
      <Text>Continue</Text>
    </TouchableOpacity>
  </View>
</View>

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  content: {
    flex: 1,
  },
  bottomActions: {
    padding: 16,
    paddingBottom: 32, // Extra padding for iPhone home indicator
    backgroundColor: '#fff',
    borderTopWidth: 1,
    borderTopColor: '#e0e0e0',
  },
  primaryButton: {
    height: 56, // Optimal touch target
    borderRadius: 28,
    backgroundColor: '#007AFF',
    justifyContent: 'center',
    alignItems: 'center',
  },
});
ビューポートと画面に関する考慮事項

一般的なモバイルのブレークポイント

/* Extra small devices (phones, 320px - 479px) */
@media (min-width: 320px) { }

/* Small devices (large phones, 480px - 767px) */
@media (min-width: 480px) { }

/* Medium devices (tablets, 768px - 1023px) */
@media (min-width: 768px) { }

/* Large devices (small laptops, 1024px - 1279px) */
@media (min-width: 1024px) { }

/* Extra large devices (desktops, 1280px and up) */
@media (min-width: 1280px) { }

ビューポートのメタタグ


<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, user-scalable=yes">


<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">

セーフエリア(iPhone X 以降)

/* Account for notch and home indicator */
.header {
  padding-top: env(safe-area-inset-top);
  padding-left: env(safe-area-inset-left);
  padding-right: env(safe-area-inset-right);
}

.footer {
  padding-bottom: env(safe-area-inset-bottom);
  padding-left: env(safe-area-inset-left);
  padding-right: env(safe-area-inset-right);
}

タッチ操作

タップ(主要な操作)

シングルタップ

  • ボタン、リンク、リスト項目での主要なアクション
  • すぐに視覚的なフィードバックを返す必要があります(遅延 0~100ms)
  • 最小サイズ:48×48 ピクセル
// React: Tap with visual feedback
import { useState } from 'react';

function TapButton({ onPress, children }) {
  const [isPressed, setIsPressed] = useState(false);

  return (
    <button
      className={`tap-button ${isPressed ? 'pressed' : ''}`}
      onTouchStart={() => setIsPressed(true)}
      onTouchEnd={() => setIsPressed(false)}
      onTouchCancel={() => setIsPressed(false)}
      onClick={onPress}
    >
      {children}
    </button>
  );
}

// CSS
.tap-button {
  padding: 16px 24px;
  background: #007AFF;
  color: white;
  border: none;
  border-radius: 8px;
  font-size: 16px;
  min-height: 48px;
  transition: transform 0.1s, background 0.1s;
  -webkit-tap-highlight-color: transparent;
}

.tap-button.pressed {
  transform: scale(0.96);
  background: #0051D5;
}

.tap-button:active {
  transform: scale(0.96);
}

ダブルタップ

  • ズームイン/ズームアウト(地図、画像)
  • いいね/お気に入り(Instagram、Twitter)
  • 使用頻度は低いため、必要な場合にのみ使用します

iOS でのダブルタップによるズームの防止

/* Prevent double-tap zoom while allowing pinch zoom */
touch-action: manipulation;
スワイプジェスチャー

横方向のスワイプ

  • 画面やページ間の移動
  • アクションの表示(スワイプして削除、スワイプしてアーカイブ)
  • カードやモーダルの閉じる操作
  • タブの切り替え
// React: Swipeable list item
import { useState } from 'react';

function SwipeableListItem({ children, onDelete, onArchive }) {
  const [touchStart, setTouchStart] = useState(null);
  const [touchEnd, setTouchEnd] = useState(null);
  const [translateX, setTranslateX] = useState(0);

  const minSwipeDistance = 50;

  const onTouchStart = (e) => {
    setTouchEnd(null);
    setTouchStart(e.targetTouches[0].clientX);
  };

  const onTouchMove = (e) => {
    setTouchEnd(e.targetTouches[0].clientX);
    const distance = touchStart - e.targetTouches[0].clientX;
    setTranslateX(-distance);
  };

  const onTouchEnd = () => {
    if (!touchStart || !touchEnd) return;

    const distance = touchStart - touchEnd;
    const isLeftSwipe = distance > minSwipeDistance;
    const isRightSwipe = distance < -minSwipeDistance;

    if (isLeftSwipe) {
      setTranslateX(-80); // Show actions
    } else if (isRightSwipe) {
      setTranslateX(0); // Reset
    } else {
      setTranslateX(0); // Snap back
    }
  };

  return (
    <div className="swipeable-item-container">
      <div className="swipe-actions">
        <button onClick={onArchive} className="archive-btn">Archive</button>
        <button onClick={onDelete} className="delete-btn">Delete</button>
      </div>

      <div
        className="swipeable-item"
        style={{ transform: `translateX(${translateX}px)` }}
        onTouchStart={onTouchStart}
        onTouchMove={onTouchMove}
        onTouchEnd={onTouchEnd}
      >
        {children}
      </div>
    </div>
  );
}

縦方向のスワイプ

  • プルして更新(画面上部から下方向にスワイプ)
  • コンテンツのスクロール
  • ボトムシートやモーダルを閉じる(下方向にスワイプ)
// Pull to Refresh
function PullToRefresh({ onRefresh, children }) {
  const [pulling, setPulling] = useState(false);
  const [pullDistance, setPullDistance] = useState(0);
  const threshold = 80;

  const handleTouchStart = (e) => {
    if (window.scrollY === 0) {
      setPulling(true);
    }
  };

  const handleTouchMove = (e) => {
    if (pulling && window.scrollY === 0) {
      const distance = e.touches[0].clientY - e.touches[0].target.getBoundingClientRect().top;
      setPullDistance(Math.min(distance, threshold * 1.5));
    }
  };

  const handleTouchEnd = () => {
    if (pullDistance >= threshold) {
      onRefresh();
    }
    setPulling(false);
    setPullDistance(0);
  };

  return (
    <div
      onTouchStart={handleTouchStart}
      onTouchMove={handleTouchMove}
      onTouchEnd={handleTouchEnd}
    >
      {pullDistance > 0 && (
        <div className="pull-indicator" style={{ height: pullDistance }}>
          {pullDistance >= threshold ? '↻ Release to refresh' : '↓ Pull to refresh'}
        </div>
      )}
      {children}
    </div>
  );
}
ピンチとスプレッド(ズーム)

次の用途に使用します。

  • 画像ギャラリー
  • 地図
  • PDF ビューアー
  • ズーム可能なコンテンツ全般
// React: Pinch to Zoom
function PinchZoomImage({ src, alt }) {
  const [scale, setScale] = useState(1);
  const [lastScale, setLastScale] = useState(1);

  const handleTouchMove = (e) => {
    if (e.touches.length === 2) {
      e.preventDefault();

      const touch1 = e.touches[0];
      const touch2 = e.touches[1];

      const distance = Math.hypot(
        touch1.clientX - touch2.clientX,
        touch1.clientY - touch2.clientY
      );

      if (lastDistance) {
        const newScale = lastScale * (distance / lastDistance);
        setScale(Math.max(1, Math.min(newScale, 4))); // Limit 1x to 4x
      }

      lastDistance = distance;
    }
  };

  const handleTouchEnd = () => {
    setLastScale(scale);
    lastDistance = null;
  };

  let lastDistance = null;

  return (
    <div
      className="pinch-zoom-container"
      onTouchMove={handleTouchMove}
      onTouchEnd={handleTouchEnd}
    >
      <img
        src={src}
        alt={alt}
        style={{
          transform: `scale(${scale})`,
          transition: lastDistance ? 'none' : 'transform 0.2s',
        }}
      />
    </div>
  );
}
長押し

次の用途に使用します。

  • コンテキストメニュー
  • 項目の選択モード
  • ドラッグ&ドロップの開始
  • その他のオプション
// React: Long Press Handler
function useLongPress(callback, ms = 500) {
  const [startLongPress, setStartLongPress] = useState(false);

  useEffect(() => {
    let timerId;
    if (startLongPress) {
      timerId = setTimeout(callback, ms);
    } else {
      clearTimeout(timerId);
    }

    return () => {
      clearTimeout(timerId);
    };
  }, [startLongPress, callback, ms]);

  return {
    onTouchStart: () => setStartLongPress(true),
    onTouchEnd: () => setStartLongPress(false),
    onTouchMove: () => setStartLongPress(false),
  };
}

// Usage
function LongPressItem({ item }) {
  const longPressProps = useLongPress(() => {
    console.log('Long press detected!');
    // Show context menu
  }, 500);

  return (
    <div {...longPressProps} className="long-press-item">
      {item.name}
    </div>
  );
}
ドラッグ&ドロップ
// React Native: Drag and Drop
import { PanResponder, Animated } from 'react-native';

function DraggableCard({ children }) {
  const pan = useRef(new Animated.ValueXY()).current;

  const panResponder = useRef(
    PanResponder.create({
      onStartShouldSetPanResponder: () => true,
      onPanResponderGrant: () => {
        pan.setOffset({
          x: pan.x._value,
          y: pan.y._value,
        });
      },
      onPanResponderMove: Animated.event(
        [null, { dx: pan.x, dy: pan.y }],
        { useNativeDriver: false }
      ),
      onPanResponderRelease: () => {
        pan.flattenOffset();
        Animated.spring(pan, {
          toValue: { x: 0, y: 0 },
          useNativeDriver: true,
        }).start();
      },
    })
  ).current;

  return (
    <Animated.View
      {...panResponder.panHandlers}
      style={{
        transform: [{ translateX: pan.x }, { translateY: pan.y }],
      }}
    >
      {children}
    </Animated.View>
  );
}

ナビゲーションパターン

タブバーによるナビゲーション

下部タブバー(iOS標準、Androidでも一般的):

// React Native: Bottom Tab Navigation
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import Icon from 'react-native-vector-icons/Ionicons';

const Tab = createBottomTabNavigator();

function AppNavigator() {
  return (
    <Tab.Navigator
      screenOptions={({ route }) => ({
        tabBarIcon: ({ focused, color, size }) => {
          let iconName;

          if (route.name === 'Home') {
            iconName = focused ? 'home' : 'home-outline';
          } else if (route.name === 'Search') {
            iconName = focused ? 'search' : 'search-outline';
          } else if (route.name === 'Profile') {
            iconName = focused ? 'person' : 'person-outline';
          }

          return <Icon name={iconName} size={size} color={color} />;
        },
        tabBarActiveTintColor: '#007AFF',
        tabBarInactiveTintColor: '#8E8E93',
        tabBarStyle: {
          height: 88, // Account for safe area
          paddingBottom: 34, // iPhone home indicator
        },
      })}
    >
      <Tab.Screen name="Home" component={HomeScreen} />
      <Tab.Screen name="Search" component={SearchScreen} />
      <Tab.Screen name="Profile" component={ProfileScreen} />
    </Tab.Navigator>
  );
}

ベストプラクティス:

  • タブは最大3〜5個にします
  • 常にラベルを表示します(アイコンだけに頼らないでください)
  • 選択中のタブを明確に強調します
  • タブは常に表示しておきます
  • 最も重要なセクションを左側に配置します(左から右に書く言語の場合)
ハンバーガーメニュー(ドロワーナビゲーション)
// React Native: Drawer Navigation
import { createDrawerNavigator } from '@react-navigation/drawer';

const Drawer = createDrawerNavigator();

function DrawerNavigator() {
  return (
    <Drawer.Navigator
      screenOptions={{
        drawerPosition: 'left',
        drawerType: 'slide',
        drawerStyle: {
          width: 280,
        },
        headerShown: true,
      }}
    >
      <Drawer.Screen
        name="Home"
        component={HomeScreen}
        options={{
          drawerIcon: ({ color, size }) => (
            <Icon name="home-outline" color={color} size={size} />
          ),
        }}
      />
      <Drawer.Screen
        name="Settings"
        component={SettingsScreen}
        options={{
          drawerIcon: ({ color, size }) => (
            <Icon name="settings-outline" color={color} size={size} />
          ),
        }}
      />
    </Drawer.Navigator>
  );
}

使用する場面:

  • セカンダリナビゲーション
  • ナビゲーション項目が多い場合(6個以上)
  • あまり利用されない機能
  • 設定やアカウントの項目

避ける場面:

  • メインナビゲーションが必要な場合
  • ユーザーがすべてのセクションにすばやくアクセスする必要がある場合
  • メインセクションが5個以下の場合(代わりにタブを使用します)
ボトムシートとモーダル

ボトムシート(マテリアルデザイン):

// React: Bottom Sheet
function BottomSheet({ isOpen, onClose, children }) {
  const [startY, setStartY] = useState(0);
  const [currentY, setCurrentY] = useState(0);

  const handleTouchStart = (e) => {
    setStartY(e.touches[0].clientY);
  };

  const handleTouchMove = (e) => {
    const delta = e.touches[0].clientY - startY;
    if (delta > 0) { // Only allow downward drag
      setCurrentY(delta);
    }
  };

  const handleTouchEnd = () => {
    if (currentY > 100) { // Threshold for closing
      onClose();
    }
    setCurrentY(0);
  };

  if (!isOpen) return null;

  return (
    <>
      <div className="bottom-sheet-backdrop" onClick={onClose} />
      <div
        className="bottom-sheet"
        style={{ transform: `translateY(${currentY}px)` }}
        onTouchStart={handleTouchStart}
        onTouchMove={handleTouchMove}
        onTouchEnd={handleTouchEnd}
      >
        <div className="bottom-sheet-handle" />
        <div className="bottom-sheet-content">
          {children}
        </div>
      </div>
    </>
  );
}

// CSS
.bottom-sheet-backdrop {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.5);
  z-index: 999;
}

.bottom-sheet {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  background: white;
  border-radius: 20px 20px 0 0;
  padding: 16px;
  max-height: 80vh;
  z-index: 1000;
  transition: transform 0.3s;
}

.bottom-sheet-handle {
  width: 40px;
  height: 4px;
  background: #D1D1D6;
  border-radius: 2px;
  margin: 8px auto 16px;
}

全画面モーダル:

// iOS-style modal with slide-up animation
function Modal({ isOpen, onClose, children }) {
  if (!isOpen) return null;

  return (
    <div className="modal-overlay">
      <div className="modal-container">
        <div className="modal-header">
          <button onClick={onClose} className="modal-close">
            Done
          </button>
        </div>
        <div className="modal-content">
          {children}
        </div>
      </div>
    </div>
  );
}

// CSS
.modal-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(0, 0, 0, 0.5);
  z-index: 1000;
  animation: fadeIn 0.3s;
}

.modal-container {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: white;
  animation: slideUp 0.3s;
}

@keyframes slideUp {
  from {
    transform: translateY(100%);
  }
  to {
    transform: translateY(0);
  }
}
スタックナビゲーション
// React Navigation: Stack Navigator
import { createStackNavigator } from '@react-navigation/stack';

const Stack = createStackNavigator();

function StackNavigator() {
  return (
    <Stack.Navigator
      screenOptions={{
        headerStyle: {
          backgroundColor: '#007AFF',
        },
        headerTintColor: '#fff',
        headerTitleStyle: {
          fontWeight: 'bold',
        },
        cardStyleInterpolator: ({ current, layouts }) => {
          return {
            cardStyle: {
              transform: [
                {
                  translateX: current.progress.interpolate({
                    inputRange: [0, 1],
                    outputRange: [layouts.screen.width, 0],
                  }),
                },
              ],
            },
          };
        },
      }}
    >
      <Stack.Screen name="List" component={ListScreen} />
      <Stack.Screen name="Details" component={DetailsScreen} />
      <Stack.Screen name="Edit" component={EditScreen} />
    </Stack.Navigator>
  );
}

モバイル UI コンポーネント

カード

マテリアルデザインのカード:

function Card({ image, title, subtitle, description, actions }) {
  return (
    <div className="card">
      {image && (
        <div className="card-media">
          <img src={image} alt={title} />
        </div>
      )}

      <div className="card-content">
        <h3 className="card-title">{title}</h3>
        {subtitle && <p className="card-subtitle">{subtitle}</p>}
        <p className="card-description">{description}</p>
      </div>

      {actions && (
        <div className="card-actions">
          {actions}
        </div>
      )}
    </div>
  );
}

// CSS
.card {
  background: white;
  border-radius: 12px;
  overflow: hidden;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  margin-bottom: 16px;
}

.card-media img {
  width: 100%;
  height: 200px;
  object-fit: cover;
}

.card-content {
  padding: 16px;
}

.card-title {
  font-size: 20px;
  font-weight: 600;
  margin: 0 0 8px 0;
}

.card-subtitle {
  font-size: 14px;
  color: #666;
  margin: 0 0 8px 0;
}

.card-description {
  font-size: 14px;
  line-height: 1.5;
  color: #333;
}

.card-actions {
  padding: 8px 16px 16px;
  display: flex;
  gap: 8px;
}
リスト

iOS スタイルのリスト:

function IOSList({ items, onItemPress }) {
  return (
    <div className="ios-list">
      {items.map((item, index) => (
        <div
          key={item.id}
          className="ios-list-item"
          onClick={() => onItemPress(item)}
        >
          {item.icon && (
            <div className="ios-list-icon">{item.icon}</div>
          )}

          <div className="ios-list-content">
            <div className="ios-list-title">{item.title}</div>
            {item.subtitle && (
              <div className="ios-list-subtitle">{item.subtitle}</div>
            )}
          </div>

          {item.badge && (
            <div className="ios-list-badge">{item.badge}</div>
          )}

          <div className="ios-list-chevron">›</div>
        </div>
      ))}
    </div>
  );
}

// CSS
.ios-list {
  background: white;
  border-radius: 12px;
  overflow: hidden;
}

.ios-list-item {
  display: flex;
  align-items: center;
  padding: 12px 16px;
  min-height: 56px;
  border-bottom: 0.5px solid #E5E5EA;
  -webkit-tap-highlight-color: transparent;
}

.ios-list-item:active {
  background: #F2F2F7;
}

.ios-list-item:last-child {
  border-bottom: none;
}

.ios-list-icon {
  width: 32px;
  height: 32px;
  margin-right: 12px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.ios-list-content {
  flex: 1;
}

.ios-list-title {
  font-size: 17px;
  color: #000;
}

.ios-list-subtitle {
  font-size: 15px;
  color: #8E8E93;
  margin-top: 2px;
}

.ios-list-badge {
  background: #FF3B30;
  color: white;
  font-size: 13px;
  font-weight: 600;
  padding: 2px 8px;
  border-radius: 12px;
  margin-right: 8px;
}

.ios-list-chevron {
  font-size: 24px;
  color: #C7C7CC;
}
フォーム

モバイルに最適化されたフォーム:

function MobileForm() {
  return (
    <form className="mobile-form">
      <div className="form-group">
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          inputMode="email"
          autoComplete="email"
          placeholder="[email protected]"
        />
      </div>

      <div className="form-group">
        <label htmlFor="phone">Phone</label>
        <input
          id="phone"
          type="tel"
          inputMode="tel"
          autoComplete="tel"
          placeholder="(555) 123-4567"
        />
      </div>

      <div className="form-group">
        <label htmlFor="amount">Amount</label>
        <input
          id="amount"
          type="number"
          inputMode="decimal"
          placeholder="0.00"
        />
      </div>

      <button type="submit" className="submit-button">
        Submit
      </button>
    </form>
  );
}

// CSS
.mobile-form {
  padding: 16px;
}

.form-group {
  margin-bottom: 24px;
}

.form-group label {
  display: block;
  font-size: 14px;
  font-weight: 600;
  margin-bottom: 8px;
  color: #333;
}

.form-group input {
  width: 100%;
  height: 56px;
  padding: 16px;
  font-size: 16px; /* Prevents zoom on iOS */
  border: 2px solid #E5E5EA;
  border-radius: 12px;
  background: white;
  -webkit-appearance: none;
}

.form-group input:focus {
  outline: none;
  border-color: #007AFF;
}

.submit-button {
  width: 100%;
  height: 56px;
  background: #007AFF;
  color: white;
  border: none;
  border-radius: 12px;
  font-size: 17px;
  font-weight: 600;
}

モバイルキーボード向けの入力タイプ:


<input type="email" inputmode="email">


<input type="number" inputmode="numeric">


<input type="number" inputmode="decimal">


<input type="tel" inputmode="tel">


<input type="url" inputmode="url">


<input type="search" inputmode="search">
アクションシート
// iOS-style Action Sheet
function ActionSheet({ isOpen, onClose, title, options }) {
  if (!isOpen) return null;

  return (
    <>
      <div className="action-sheet-backdrop" onClick={onClose} />
      <div className="action-sheet">
        {title && <div className="action-sheet-title">{title}</div>}

        <div className="action-sheet-options">
          {options.map((option, index) => (
            <button
              key={index}
              className={`action-sheet-option ${option.destructive ? 'destructive' : ''}`}
              onClick={() => {
                option.onPress();
                onClose();
              }}
            >
              {option.icon && <span className="option-icon">{option.icon}</span>}
              {option.label}
            </button>
          ))}
        </div>

        <button className="action-sheet-cancel" onClick={onClose}>
          Cancel
        </button>
      </div>
    </>
  );
}

// CSS
.action-sheet {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  background: transparent;
  z-index: 1001;
  padding: 8px;
  animation: slideUp 0.3s;
}

.action-sheet-title {
  background: rgba(255, 255, 255, 0.95);
  padding: 16px;
  text-align: center;
  border-radius: 14px 14px 0 0;
  font-size: 13px;
  color: #8E8E93;
}

.action-sheet-options {
  background: rgba(255, 255, 255, 0.95);
  border-radius: 14px;
  overflow: hidden;
  margin-bottom: 8px;
}

.action-sheet-option {
  width: 100%;
  padding: 16px;
  background: transparent;
  border: none;
  border-bottom: 0.5px solid #E5E5EA;
  font-size: 20px;
  color: #007AFF;
  -webkit-tap-highlight-color: transparent;
}

.action-sheet-option:active {
  background: rgba(0, 0, 0, 0.05);
}

.action-sheet-option.destructive {
  color: #FF3B30;
}

.action-sheet-cancel {
  width: 100%;
  padding: 16px;
  background: rgba(255, 255, 255, 0.95);
  border: none;
  border-radius: 14px;
  font-size: 20px;
  font-weight: 600;
  color: #007AFF;
}

プラットフォームの規約

iOS ヒューマンインターフェイスガイドライン

ナビゲーションバー:

  • 高さ:44pt(ステータスバーを除く)
  • 大きなタイトル:折りたたみ可能なヘッダー、52pt
  • 戻るボタンには常に前の画面のタイトルを表示します
  • アクションボタンは右揃えにします

タブバー:

  • 高さ:49pt(セーフエリアを除く)
  • タブは最大5個
  • タブアイコンにバッジ通知を表示
  • 選択中のタブにはアクセントカラーを使用

タイポグラフィ:

  • SF Pro(システムフォント)
  • Dynamic Type への対応が必須
  • フォントサイズ:11pt〜34pt
  • ウェイトの階層:Regular、Medium、Semibold、Bold

色:

  • システムカラーはライトモード/ダークモードに合わせて変化します
  • タップ可能な要素には青(#007AFF)
  • 破壊的なアクションには赤(#FF3B30)
  • セマンティックカラー:label、secondaryLabel、tertiaryLabel

間隔:

  • 最小マージン:16pt
  • 標準の間隔:8pt、16pt、24pt、32pt
  • コンポーネントのパディング:左右16pt、上下12pt
// SwiftUI: iOS Navigation
struct ContentView: View {
    var body: some View {
        NavigationView {
            List(items) { item in
                NavigationLink(destination: DetailView(item: item)) {
                    HStack {
                        Image(systemName: item.icon)
                            .foregroundColor(.accentColor)

                        VStack(alignment: .leading) {
                            Text(item.title)
                                .font(.headline)
                            Text(item.subtitle)
                                .font(.subheadline)
                                .foregroundColor(.secondary)
                        }
                    }
                    .padding(.vertical, 8)
                }
            }
            .navigationTitle("Items")
            .navigationBarTitleDisplayMode(.large)
        }
    }
}
マテリアルデザイン(Android)

アプリバー:

  • 高さ:56dp(タブレットでは64dp)
  • 標高:4dp
  • 左側にハンバーガーアイコンまたは戻る矢印を配置
  • タイトルは中央揃えまたは左揃え
  • 右側にアクションアイコンを配置(最大3個)

下部ナビゲーション:

  • 高さ:56dp
  • 移動先は3〜5個
  • アイコンにテキストラベルを付けます
  • 選択中のインジケーターを表示

FAB(フローティングアクションボタン):

  • サイズ:56×56dp(標準)、40×40dp(ミニ)
  • 端から16dpの位置に配置
  • 主要なアクションのみに使用
  • 拡張 FAB にはテキストラベルが含まれます

タイポグラフィ:

  • Roboto フォントファミリー
  • スケール:12sp〜96sp
  • 行の高さ:フォントサイズの1.5倍
  • 字間はサイズによって異なります

標高:

  • 影の深さで階層を示します
  • 0dp:平面
  • 1〜8dp:浮き上がったコンポーネント
  • 16〜24dp:モーダルとダイアログ

間隔:

  • 4dp グリッドシステム
  • キーライン:端から16dp、72dp
  • コンポーネント間の間隔:8dp、16dp、24dp
// Jetpack Compose: Material Design
@Composable
fun MaterialCard(item: Item) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp),
        elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Text(
                text = item.title,
                style = MaterialTheme.typography.headlineSmall
            )

            Spacer(modifier = Modifier.height(8.dp))

            Text(
                text = item.description,
                style = MaterialTheme.typography.bodyMedium,
                color = MaterialTheme.colorScheme.onSurfaceVariant
            )

            Spacer(modifier = Modifier.height(16.dp))

            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.End
            ) {
                TextButton(onClick = { /* Action */ }) {
                    Text("ACTION")
                }
            }
        }
    }
}

アクセシビリティ

タッチターゲットのサイズ

WCAG 2.1 レベル AAA:

  • 最小サイズ:44×44ピクセル
  • 推奨サイズ:48×48ピクセル以上
  • ターゲット間の最小間隔:8px
// Accessible button component
function AccessibleButton({ children, onPress, variant = 'primary' }) {
  return (
    <button
      className={`accessible-button ${variant}`}
      onClick={onPress}
      style={{
        minWidth: '48px',
        minHeight: '48px',
        padding: '12px 24px',
      }}
    >
      {children}
    </button>
  );
}
スクリーンリーダーのサポート

セマンティック HTML:

function AccessibleMobileNav() {
  return (
    <nav role="navigation" aria-label="Main navigation">
      <ul>
        <li>
          <a href="/home" aria-current="page">
            <Icon name="home" aria-hidden="true" />
            <span>Home</span>
          </a>
        </li>
        <li>
          <a href="/search">
            <Icon name="search" aria-hidden="true" />
            <span>Search</span>
          </a>
        </li>
      </ul>
    </nav>
  );
}

React Native のアクセシビリティ:

import { View, Text, TouchableOpacity } from 'react-native';

function AccessibleCard({ title, description, onPress }) {
  return (
    <TouchableOpacity
      accessible={true}
      accessibilityLabel={`${title}. ${description}`}
      accessibilityRole="button"
      accessibilityHint="Double tap to view details"
      onPress={onPress}
    >
      <View>
        <Text>{title}</Text>
        <Text>{description}</Text>
      </View>
    </TouchableOpacity>
  );
}
色のコントラスト

WCAG AA の要件:

  • 通常のテキスト:コントラスト比4.5:1
  • 大きなテキスト(18pt以上):コントラスト比3:1
  • UI コンポーネント:コントラスト比3:1
/* Good contrast examples */
.primary-button {
  background: #0066CC; /* Blue */
  color: #FFFFFF; /* White - 6.4:1 ratio */
}

.secondary-button {
  background: #FFFFFF; /* White */
  color: #333333; /* Dark gray - 12.6:1 ratio */
  border: 2px solid #333333;
}

/* Bad contrast (avoid) */
.bad-button {
  background: #FFCC00; /* Yellow */
  color: #FFFFFF; /* White - 1.4:1 ratio ❌ */
}
フォーカスインジケーター
/* Visible focus states for keyboard navigation */
button:focus-visible {
  outline: 3px solid #007AFF;
  outline-offset: 2px;
}

input:focus-visible {
  border-color: #007AFF;
  box-shadow: 0 0 0 4px rgba(0, 122, 255, 0.2);
}

/* Remove default focus ring, add custom */
*:focus {
  outline: none;
}

*:focus-visible {
  outline: 3px solid #007AFF;
  outline-offset: 2px;
}

パフォーマンス

画像の最適化

レスポンシブ画像:


<img
  src="image-800w.jpg"
  srcset="
    image-400w.jpg 400w,
    image-800w.jpg 800w,
    image-1200w.jpg 1200w
  "
  sizes="
    (max-width: 480px) 100vw,
    (max-width: 768px) 50vw,
    33vw
  "
  alt="Product image"
  loading="lazy"
>


<picture>
  <source srcset="image.webp" type="image/webp">
  <source srcset="image.jpg" type="image/jpeg">
  <img src="image.jpg" alt="Fallback image">
</picture>

遅延読み込み:

// React: Intersection Observer for lazy loading
function LazyImage({ src, alt }) {
  const [isLoaded, setIsLoaded] = useState(false);
  const [isInView, setIsInView] = useState(false);
  const imgRef = useRef();

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsInView(true);
          observer.disconnect();
        }
      },
      { rootMargin: '50px' }
    );

    if (imgRef.current) {
      observer.observe(imgRef.current);
    }

    return () => observer.disconnect();
  }, []);

  return (
    <div ref={imgRef} className="lazy-image-container">
      {!isLoaded && <div className="skeleton-loader" />}
      {isInView && (
        <img
          src={src}
          alt={alt}
          onLoad={() => setIsLoaded(true)}
          style={{ opacity: isLoaded ? 1 : 0 }}
        />
      )}
    </div>
  );
}
読み込み戦略

スケルトンスクリーン:

function SkeletonCard() {
  return (
    <div className="skeleton-card">
      <div className="skeleton skeleton-image" />
      <div className="skeleton skeleton-title" />
      <div className="skeleton skeleton-text" />
      <div className="skeleton skeleton-text short" />
    </div>
  );
}

// CSS
.skeleton {
  background: linear-gradient(
    90deg,
    #f0f0f0 25%,
    #e0e0e0 50%,
    #f0f0f0 75%
  );
  background-size: 200% 100%;
  animation: loading 1.5s infinite;
}

@keyframes loading {
  0% {
    background-position: 200% 0;
  }
  100% {
    background-position: -200% 0;
  }
}

.skeleton-image {
  height: 200px;
  border-radius: 8px 8px 0 0;
}

.skeleton-title {
  height: 24px;
  margin: 16px;
  border-radius: 4px;
}

.skeleton-text {
  height: 16px;
  margin: 8px 16px;
  border-radius: 4px;
}

.skeleton-text.short {
  width: 60%;
}

プログレッシブウェブアプリ(PWA):

// service-worker.js
const CACHE_NAME = 'mobile-app-v1';
const urlsToCache = [
  '/',
  '/styles/main.css',
  '/scripts/main.js',
  '/images/logo.png',
];

self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => cache.addAll(urlsToCache))
  );
});

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((response) => {
        // Return cached version or fetch new
        return response || fetch(event.request);
      })
  );
});
パフォーマンス指標

モバイル向け Core Web Vitals:

  • LCP(最大コンテンツ描画):< 2.5s
  • FID(初回入力遅延):< 100ms
  • CLS(累積レイアウトシフト):< 0.1
// Measure performance
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(entry.name, entry.startTime);
  }
});

observer.observe({ entryTypes: ['navigation', 'paint', 'largest-contentful-paint'] });

レスポンシブブレークポイント

一般的なデバイスの幅
/* iPhone SE (2022) */
@media (min-width: 375px) and (max-width: 667px) {
  /* Small phone styles */
}

/* iPhone 12/13/14 Pro */
@media (min-width: 390px) and (max-width: 844px) {
  /* Standard phone styles */
}

/* iPhone 14 Pro Max */
@media (min-width: 428px) and (max-width: 926px) {
  /* Large phone styles */
}

/* iPad Mini */
@media (min-width: 768px) and (max-width: 1024px) {
  /* Tablet styles */
}

/* iPad Pro */
@media (min-width: 1024px) and (max-width: 1366px) {
  /* Large tablet styles */
}
画面の向きに応じたスタイル
/* Portrait mode */
@media (orientation: portrait) {
  .container {
    flex-direction: column;
  }
}

/* Landscape mode */
@media (orientation: landscape) {
  .container {
    flex-direction: row;
  }

  .sidebar {
    width: 300px;
  }
}

/* Prevent layout shift on keyboard open */
@media (max-height: 500px) {
  .bottom-nav {
    display: none;
  }
}
コンテナクエリ(最新のアプローチ)
/* Component adapts to container size, not viewport */
.card-container {
  container-type: inline-size;
}

@container (min-width: 400px) {
  .card {
    display: grid;
    grid-template-columns: 1fr 2fr;
  }
}

@container (min-width: 600px) {
  .card {
    grid-template-columns: 1fr 1fr;
  }
}

例

1. モバイル向け E コマースの商品リスト
function ProductList({ products }) {
  return (
    <div className="product-list">
      {products.map((product) => (
        <div key={product.id} className="product-card">
          <div className="product-image-container">
            <img
              src={product.image}
              alt={product.name}
              loading="lazy"
            />
            {product.badge && (
              <span className="product-badge">{product.badge}</span>
            )}
          </div>

          <div className="product-info">
            <h3 className="product-name">{product.name}</h3>
            <p className="product-price">${product.price}</p>

            {product.rating && (
              <div className="product-rating">
                {'★'.repeat(product.rating)}
                {'☆'.repeat(5 - product.rating)}
                <span className="review-count">
                  ({product.reviewCount})
                </span>
              </div>
            )}
          </div>

          <button className="add-to-cart-btn">
            Add to Cart
          </button>
        </div>
      ))}
    </div>
  );
}

// CSS
.product-list {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 16px;
  padding: 16px;
}

@media (min-width: 768px) {
  .product-list {
    grid-template-columns: repeat(3, 1fr);
  }
}

@media (min-width: 1024px) {
  .product-list {
    grid-template-columns: repeat(4, 1fr);
  }
}

.product-card {
  background: white;
  border-radius: 12px;
  overflow: hidden;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.product-image-container {
  position: relative;
  aspect-ratio: 1;
  background: #f5f5f5;
}

.product-image-container img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.product-badge {
  position: absolute;
  top: 8px;
  right: 8px;
  background: #FF3B30;
  color: white;
  padding: 4px 8px;
  border-radius: 4px;
  font-size: 12px;
  font-weight: 600;
}

.product-info {
  padding: 12px;
}

.product-name {
  font-size: 14px;
  font-weight: 600;
  margin: 0 0 4px 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.product-price {
  font-size: 16px;
  font-weight: 700;
  color: #007AFF;
  margin: 0 0 8px 0;
}

.product-rating {
  font-size: 14px;
  color: #FFB800;
}

.review-count {
  color: #666;
  font-size: 12px;
  margin-left: 4px;
}

.add-to-cart-btn {
  width: 100%;
  height: 44px;
  background: #007AFF;
  color: white;
  border: none;
  font-size: 14px;
  font-weight: 600;
  -webkit-tap-highlight-color: transparent;
}

.add-to-cart-btn:active {
  background: #0051D5;
}
2. 無限スクロールのフィード
function InfiniteFeed() {
  const [posts, setPosts] = useState([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const observerTarget = useRef(null);

  const loadMore = async () => {
    if (loading || !hasMore) return;

    setLoading(true);
    const newPosts = await fetchPosts(page);

    if (newPosts.length === 0) {
      setHasMore(false);
    } else {
      setPosts(prev => [...prev, ...newPosts]);
      setPage(prev => prev + 1);
    }

    setLoading(false);
  };

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          loadMore();
        }
      },
      { threshold: 0.5 }
    );

    if (observerTarget.current) {
      observer.observe(observerTarget.current);
    }

    return () => observer.disconnect();
  }, [loading, hasMore]);

  return (
    <div className="feed">
      {posts.map(post => (
        <FeedCard key={post.id} post={post} />
      ))}

      {loading && <LoadingSpinner />}

      <div ref={observerTarget} style={{ height: '20px' }} />

      {!hasMore && (
        <div className="feed-end">No more posts</div>
      )}
    </div>
  );
}
3. オートコンプリート付きモバイル検索
function MobileSearch() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isFocused, setIsFocused] = useState(false);
  const [recentSearches, setRecentSearches] = useState([]);

  const handleSearch = async (value) => {
    setQuery(value);

    if (value.length >= 2) {
      const searchResults = await fetchSearchResults(value);
      setResults(searchResults);
    } else {
      setResults([]);
    }
  };

  const handleSubmit = (searchQuery) => {
    // Save to recent searches
    const updated = [searchQuery, ...recentSearches.slice(0, 4)];
    setRecentSearches(updated);
    localStorage.setItem('recentSearches', JSON.stringify(updated));

    // Navigate to results
    window.location.href = `/search?q=${encodeURIComponent(searchQuery)}`;
  };

  return (
    <div className="mobile-search">
      <div className="search-bar">
        <input
          type="search"
          inputMode="search"
          placeholder="Search products..."
          value={query}
          onChange={(e) => handleSearch(e.target.value)}
          onFocus={() => setIsFocused(true)}
          onBlur={() => setTimeout(() => setIsFocused(false), 200)}
        />

        {query && (
          <button
            className="clear-button"
            onClick={() => {
              setQuery('');
              setResults([]);
            }}
          >
            ✕
          </button>
        )}
      </div>

      {isFocused && (
        <div className="search-dropdown">
          {query.length === 0 && recentSearches.length > 0 && (
            <div className="recent-searches">
              <h4>Recent Searches</h4>
              {recentSearches.map((search, index) => (
                <button
                  key={index}
                  className="search-suggestion"
                  onClick={() => handleSubmit(search)}
                >
                  <span className="icon">🕐</span>
                  {search}
                </button>
              ))}
            </div>
          )}

          {results.length > 0 && (
            <div className="search-results">
              {results.map((result) => (
                <button
                  key={result.id}
                  className="search-result-item"
                  onClick={() => handleSubmit(result.name)}
                >
                  <img src={result.thumbnail} alt="" />
                  <div>
                    <div className="result-name">{result.name}</div>
                    <div className="result-category">{result.category}</div>
                  </div>
                </button>
              ))}
            </div>
          )}
        </div>
      )}
    </div>
  );
}
4. フィルタードロワー
function FilterDrawer({ isOpen, onClose, onApply }) {
  const [filters, setFilters] = useState({
    priceRange: [0, 1000],
    category: [],
    rating: 0,
    inStock: false,
  });

  return (
    <>
      {isOpen && (
        <div className="filter-drawer-overlay" onClick={onClose} />
      )}

      <div className={`filter-drawer ${isOpen ? 'open' : ''}`}>
        <div className="filter-header">
          <h2>Filters</h2>
          <button onClick={onClose}>✕</button>
        </div>

        <div className="filter-content">
          <div className="filter-section">
            <h3>Price Range</h3>
            <input
              type="range"
              min="0"
              max="1000"
              value={filters.priceRange[1]}
              onChange={(e) => setFilters({
                ...filters,
                priceRange: [0, parseInt(e.target.value)]
              })}
            />
            <div className="price-display">
              ${filters.priceRange[0]} - ${filters.priceRange[1]}
            </div>
          </div>

          <div className="filter-section">
            <h3>Category</h3>
            {['Electronics', 'Clothing', 'Books', 'Home'].map(cat => (
              <label key={cat} className="checkbox-label">
                <input
                  type="checkbox"
                  checked={filters.category.includes(cat)}
                  onChange={(e) => {
                    if (e.target.checked) {
                      setFilters({
                        ...filters,
                        category: [...filters.category, cat]
                      });
                    } else {
                      setFilters({
                        ...filters,
                        category: filters.category.filter(c => c !== cat)
                      });
                    }
                  }}
                />
                {cat}
              </label>
            ))}
          </div>

          <div className="filter-section">
            <label className="checkbox-label">
              <input
                type="checkbox"
                checked={filters.inStock}
                onChange={(e) => setFilters({
                  ...filters,
                  inStock: e.target.checked
                })}
              />
              In Stock Only
            </label>
          </div>
        </div>

        <div className="filter-actions">
          <button
            className="clear-button"
            onClick={() => setFilters({
              priceRange: [0, 1000],
              category: [],
              rating: 0,
              inStock: false,
            })}
          >
            Clear All
          </button>

          <button
            className="apply-button"
            onClick={() => {
              onApply(filters);
              onClose();
            }}
          >
            Apply Filters
          </button>
        </div>
      </div>
    </>
  );
}

// CSS
.filter-drawer {
  position: fixed;
  right: -100%;
  top: 0;
  bottom: 0;
  width: 85%;
  max-width: 400px;
  background: white;
  z-index: 1001;
  transition: right 0.3s;
  display: flex;
  flex-direction: column;
}

.filter-drawer.open {
  right: 0;
}

.filter-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px;
  border-bottom: 1px solid #E5E5EA;
}

.filter-content {
  flex: 1;
  overflow-y: auto;
  padding: 16px;
}

.filter-section {
  margin-bottom: 24px;
}

.filter-section h3 {
  font-size: 16px;
  font-weight: 600;
  margin-bottom: 12px;
}

.checkbox-label {
  display: flex;
  align-items: center;
  padding: 12px 0;
  font-size: 15px;
}

.checkbox-label input {
  margin-right: 12px;
  width: 20px;
  height: 20px;
}

.filter-actions {
  display: flex;
  gap: 12px;
  padding: 16px;
  border-top: 1px solid #E5E5EA;
}

.clear-button,
.apply-button {
  flex: 1;
  height: 48px;
  border-radius: 8px;
  font-size: 16px;
  font-weight: 600;
}

.clear-button {
  background: white;
  border: 2px solid #007AFF;
  color: #007AFF;
}

.apply-button {
  background: #007AFF;
  border: none;
  color: white;
}
5. モバイル決済フォーム
function MobilePaymentForm() {
  const [cardNumber, setCardNumber] = useState('');
  const [expiry, setExpiry] = useState('');
  const [cvv, setCvv] = useState('');

  const formatCardNumber = (value) => {
    return value
      .replace(/\s/g, '')
      .match(/.{1,4}/g)
      ?.join(' ') || '';
  };

  const formatExpiry = (value) => {
    const cleaned = value.replace(/\D/g, '');
    if (cleaned.length >= 2) {
      return `${cleaned.slice(0, 2)}/${cleaned.slice(2, 4)}`;
    }
    return cleaned;
  };

  return (
    <form className="payment-form">
      <div className="form-group">
        <label>Card Number</label>
        <input
          type="text"
          inputMode="numeric"
          maxLength="19"
          placeholder="1234 5678 9012 3456"
          value={formatCardNumber(cardNumber)}
          onChange={(e) => setCardNumber(e.target.value.replace(/\s/g, ''))}
        />
      </div>

      <div className="form-row">
        <div className="form-group">
          <label>Expiry</label>
          <input
            type="text"
            inputMode="numeric"
            maxLength="5"
            placeholder="MM/YY"
            value={expiry}
            onChange={(e) => setExpiry(formatExpiry(e.target.value))}
          />
        </div>

        <div className="form-group">
          <label>CVV</label>
          <input
            type="text"
            inputMode="numeric"
            maxLength="4"
            placeholder="123"
            value={cvv}
            onChange={(e) => setCvv(e.target.value.replace(/\D/g, ''))}
          />
        </div>
      </div>

      <button type="submit" className="pay-button">
        Pay $99.99
      </button>
    </form>
  );
}
6-20. その他の例

簡潔にするため、ここではさらに14個の重要なモバイルデザインパターンを要約します。

6. スクロールに応じて変化する固定ヘッダー

  • スクロールするとヘッダーが縮小します
  • プログレスバーで読み進めた位置を示します
  • スクロール後にトップへ戻るボタンが表示されます

7. ピンチズーム対応の画像ギャラリー

  • 全画面表示の画像ビューアー
  • スワイプして画像を切り替えます
  • ピンチ操作でズームできます

8. モバイル向けに最適化されたデータテーブル

  • 最初の列を固定した横スクロール
  • 小さな画面ではカード形式で表示します
  • 詳細を表示する展開可能な行

9. ボトムシートメニュー

  • 上方向にスワイプして展開します
  • ドラッグして閉じます
  • 複数のスナップ位置(折りたたみ、半分、全体)

10. モバイルカレンダーピッカー

  • タッチ操作に最適化された月表示
  • 日付範囲の選択
  • クイックアクション(今日、明日、来週)

11. スピードダイヤル付きフローティングアクションボタン(FAB)

  • メインアクションを常に表示します
  • 展開すると関連するアクションが表示されます
  • 滑らかなアニメーション

12. 引っ張って更新

  • カスタムの読み込みアニメーション
  • 触覚フィードバック
  • 成功/エラー状態

13. スワイプ可能なタブ

  • 横スクロール可能なタブ
  • アクティブなタブを示すインジケーター
  • スクロール時にタブにスナップします

14. モバイル動画プレーヤー

  • タッチ操作に最適化されたカスタムコントロール
  • ピクチャーインピクチャーモード
  • ジェスチャー操作(タップで一時停止、ダブルタップでスキップ)

15. モバイルトースト通知

  • 邪魔にならないメッセージ表示
  • 手動で閉じることもできる自動消去
  • アクションボタン

16. 折りたたみ可能なアコーディオン

  • タッチ操作しやすい展開/折りたたみ
  • 滑らかなアニメーション
  • 複数のセクション

17. モバイル向けステッパーフォーム

  • 複数のステップからなるプロセス
  • 進行状況インジケーター
  • 戻る/次へナビゲーション

18. 音声入力インターフェース

  • マイクボタン
  • リアルタイムの文字起こし
  • 音声フィードバック

19. オンボーディングカルーセル

  • スワイプ可能な紹介画面
  • スキップオプション
  • 進行状況を示すドット

20. モバイル共有シート

  • ネイティブアプリのような共有インターフェース
  • よく使われる共有先
  • リンクのコピー機能

結論

モバイルデザインには、タッチ操作、プラットフォームの慣習、パフォーマンス最適化への深い理解が必要です。モバイルファーストの原則に従い、親指で操作する範囲に配慮し、プラットフォームに適したパターンを実装することで、モバイルデバイスで自然に使えて快適に動作する体験を作れます。

覚えておきましょう。モバイルユーザーは外出先で利用することが多く、注意を向けられる時間が限られており、即座に反応することを期待しています。何よりも、速度、分かりやすさ、使いやすさを優先してください。

コメント