SSGOI LogoSSGOI

シート(Sheet)トランジション

ボトムシートスタイルのモバイル最適化トランジション

シート(Sheet)トランジション

シート(Sheet)トランジションは、ボトムシートスタイルのモバイル最適化トランジションです。バックグラウンドページは後ろに下がりながら縮小し、新しい画面が下部からスライドアップして表示されます。FABボタンから作成画面への遷移時によく使用されます。

デモ

Loading demo...

UX原則

いつ使用しますか?

シート(Sheet)トランジションは、FABまたは作成ボタンから全画面入力モードへの遷移時に使用されます。

コンテンツ関係別適合性

콘텐츠 관계적합성설명
関連のないコンテンツ独立したセクション間には不適合
兄弟関係同レベルのページ間の移動にはスライドの使用を推奨
階層関係リスト→作成、フィード→投稿作成など新しいコンテキスト生成時に最適

主な使用ケース

  • ソーシャルメディア: フィードから新しい投稿を作成
  • メールアプリ: 受信トレイから新しいメールを作成
  • メモアプリ: ノートリストから新しいノートを作成
  • ToDoアプリ: リストから新しいタスクを追加
  • チャットアプリ: 会話リストから新しいメッセージを作成

なぜこのように動作しますか?

  1. コンテキストの保存: バックグラウンドが縮小しながら維持され、ユーザーがどこから来たかが明確
  2. 空間拡張メタファー: 新しい作業スペースが下から上がってくる自然な感覚
  3. 集中の誘導: バックグラウンドページがぼやけて縮小し、作成画面に集中
  4. モバイルネイティブパターン: iOSとAndroidネイティブアプリのモーダル遷移と類似
  5. 簡単な復帰: 戻る時にバックグラウンドが再び拡大して自然に復帰

モーションデザイン

前進プロセス (リスト → 作成):
1. バックグラウンドページ縮小開始 (scale: 1 → 0.8)
2. 同時に透明度減少 (opacity: 1 → 0)
3. 作成ページが下部からスライドアップ (translateY: 100% → 0)
4. バックグラウンドはz-index: -1で後ろに配置
5. 作成ページはz-index: 100で前に配置

後退プロセス (作成 → リスト):
1. 作成ページ下部へスライドダウン (translateY: 0 → 100%)
2. 同時にバックグラウンドページ拡大 (scale: 0.8 → 1)
3. バックグラウンド透明度復元 (opacity: 0 → 1)
4. 中心点はビューポートセンターに固定

基本的な使い方

1. トランジション設定

シートトランジションはdirectionオプションで前進/後退方向を明示的に設定します:

import { Ssgoi } from '@ssgoi/react';
import { sheet } from '@ssgoi/react/view-transitions';

const config = {
  transitions: [
    {
      from: '/feed',
      to: '/compose',
      transition: sheet({ direction: 'enter' })  // 前進: シートが上がる
    },
    {
      from: '/compose',
      to: '/feed',
      transition: sheet({ direction: 'exit' })   // 後退: シートが下がる
    }
  ]
};

export default function App() {
  return (
    <Ssgoi config={config}>
      {/* アプリ内容 */}
    </Ssgoi>
  );
}

2. ページ構造

FABボタンのあるリストページと作成ページ:

// フィードページ
function Feed() {
  const navigate = useNavigate();

  return (
    <div className="min-h-screen bg-gray-900 text-gray-100">
      <header className="p-4 border-b border-gray-700">
        <h1 className="text-xl font-bold">フィード</h1>
      </header>

      <main className="p-4 space-y-4">
        {posts.map(post => (
          <article
            key={post.id}
            className="bg-gray-800 rounded-lg p-4"
          >
            <h2 className="font-semibold">{post.title}</h2>
            <p className="text-gray-400 text-sm mt-2">{post.excerpt}</p>
          </article>
        ))}
      </main>

      {/* FAB */}
      <button
        onClick={() => navigate('/compose')}
        className="fixed bottom-6 right-6 w-14 h-14
                   bg-blue-500 rounded-full shadow-lg
                   flex items-center justify-center
                   hover:bg-blue-600 transition-colors"
      >
        <span className="text-2xl text-white">+</span>
      </button>
    </div>
  );
}

// 作成ページ
function Compose() {
  const navigate = useNavigate();

  return (
    <div className="min-h-screen bg-gray-900 text-gray-100">
      <header className="p-4 border-b border-gray-700 flex items-center gap-4">
        <button
          onClick={() => navigate(-1)}
          className="text-gray-400 hover:text-gray-200"
        >
          キャンセル
        </button>
        <h1 className="text-xl font-bold flex-1">新しい投稿</h1>
        <button className="text-blue-500 font-semibold">
          投稿
        </button>
      </header>

      <main className="p-4">
        <input
          type="text"
          placeholder="タイトル"
          className="w-full bg-gray-800 border border-gray-700
                     rounded-lg px-4 py-3 mb-4"
        />
        <textarea
          placeholder="内容を入力してください..."
          rows={10}
          className="w-full bg-gray-800 border border-gray-700
                     rounded-lg px-4 py-3 resize-none"
        />
      </main>
    </div>
  );
}

実践活用例

1. ソーシャルメディア投稿作成

Twitter/Xスタイルの新しい投稿作成:

// タイムラインページ
function Timeline() {
  return (
    <div className="min-h-screen bg-gray-900">
      <header className="sticky top-0 bg-gray-900/95 backdrop-blur p-4
                         border-b border-gray-700">
        <h1 className="text-xl font-bold text-gray-100">タイムライン</h1>
      </header>

      <main>
        {tweets.map(tweet => (
          <article
            key={tweet.id}
            className="p-4 border-b border-gray-800 hover:bg-gray-800/50"
          >
            <div className="flex gap-3">
              <img
                src={tweet.avatar}
                alt={tweet.author}
                className="w-12 h-12 rounded-full"
              />
              <div className="flex-1">
                <div className="flex items-center gap-2">
                  <span className="font-bold text-gray-100">
                    {tweet.author}
                  </span>
                  <span className="text-gray-500 text-sm">
                    @{tweet.handle}
                  </span>
                  <span className="text-gray-500 text-sm">
                    · {tweet.time}
                  </span>
                </div>
                <p className="text-gray-200 mt-1">{tweet.content}</p>
              </div>
            </div>
          </article>
        ))}
      </main>

      {/* Compose FAB */}
      <Link
        to="/compose"
        className="fixed bottom-6 right-6 w-14 h-14
                   bg-blue-500 rounded-full shadow-lg
                   flex items-center justify-center
                   hover:bg-blue-600 active:scale-95
                   transition-all"
      >
        <svg className="w-6 h-6 text-white" fill="currentColor">
          <path d="M8 2v12m-6-6h12" stroke="currentColor" strokeWidth="2"/>
        </svg>
      </Link>
    </div>
  );
}

// 作成ページ
function ComposeTweet() {
  const [content, setContent] = useState('');
  const navigate = useNavigate();

  const handlePost = () => {
    // 投稿ロジック
    navigate(-1);
  };

  return (
    <div className="min-h-screen bg-gray-900 text-gray-100">
      <header className="p-4 border-b border-gray-700
                         flex items-center justify-between">
        <button
          onClick={() => navigate(-1)}
          className="text-gray-400 hover:text-gray-200 font-medium"
        >
          キャンセル
        </button>
        <button
          onClick={handlePost}
          disabled={!content.trim()}
          className="bg-blue-500 text-white px-4 py-2 rounded-full
                     font-semibold disabled:opacity-50
                     disabled:cursor-not-allowed"
        >
          投稿
        </button>
      </header>

      <main className="p-4">
        <div className="flex gap-3">
          <img
            src="/current-user-avatar.jpg"
            alt="You"
            className="w-12 h-12 rounded-full"
          />
          <textarea
            autoFocus
            value={content}
            onChange={(e) => setContent(e.target.value)}
            placeholder="いまどうしてる?"
            className="flex-1 bg-transparent text-xl
                       text-gray-100 placeholder-gray-500
                       resize-none outline-none min-h-[200px]"
          />
        </div>

        {/* 文字数カウンター */}
        <div className="mt-4 flex items-center justify-between
                       px-4 py-2 border-t border-gray-700">
          <div className="flex gap-2">
            <button className="text-blue-500 p-2 hover:bg-blue-500/10
                               rounded-full">
              <span>📷</span>
            </button>
            <button className="text-blue-500 p-2 hover:bg-blue-500/10
                               rounded-full">
              <span>😊</span>
            </button>
          </div>
          <span className={`text-sm ${content.length > 280 ? 'text-red-500' : 'text-gray-500'}`}>
            {content.length} / 280
          </span>
        </div>
      </main>
    </div>
  );
}

2. メール作成

受信トレイから新しいメールを作成:

// 受信トレイ
function Inbox() {
  return (
    <div className="min-h-screen bg-gray-900">
      <header className="p-4 border-b border-gray-700">
        <h1 className="text-xl font-bold text-gray-100">受信トレイ</h1>
      </header>

      <main>
        {emails.map(email => (
          <article
            key={email.id}
            className="p-4 border-b border-gray-800
                       hover:bg-gray-800/50 cursor-pointer"
          >
            <div className="flex items-start gap-3">
              <div className="w-10 h-10 rounded-full bg-blue-500
                              flex items-center justify-center
                              text-white font-semibold">
                {email.sender[0]}
              </div>
              <div className="flex-1 min-w-0">
                <div className="flex items-center justify-between mb-1">
                  <span className="font-semibold text-gray-100">
                    {email.sender}
                  </span>
                  <span className="text-xs text-gray-500">
                    {email.time}
                  </span>
                </div>
                <h3 className="text-gray-100 font-medium mb-1 truncate">
                  {email.subject}
                </h3>
                <p className="text-gray-400 text-sm truncate">
                  {email.preview}
                </p>
              </div>
            </div>
          </article>
        ))}
      </main>

      {/* Compose FAB */}
      <Link
        to="/compose-email"
        className="fixed bottom-6 right-6 w-14 h-14
                   bg-blue-500 rounded-full shadow-lg
                   flex items-center justify-center"
      >
        <span className="text-white text-2xl">✉️</span>
      </Link>
    </div>
  );
}

// メール作成
function ComposeEmail() {
  const navigate = useNavigate();

  return (
    <div className="min-h-screen bg-gray-900 text-gray-100">
      <header className="p-4 border-b border-gray-700
                         flex items-center justify-between">
        <button onClick={() => navigate(-1)}>キャンセル</button>
        <h1 className="text-lg font-semibold">新しいメール</h1>
        <button className="text-blue-500 font-semibold">送信</button>
      </header>

      <main className="divide-y divide-gray-800">
        <input
          type="email"
          placeholder="宛先"
          className="w-full bg-transparent px-4 py-3
                     text-gray-100 placeholder-gray-500"
        />
        <input
          type="text"
          placeholder="件名"
          className="w-full bg-transparent px-4 py-3
                     text-gray-100 placeholder-gray-500"
        />
        <textarea
          placeholder="メール本文"
          rows={15}
          className="w-full bg-transparent px-4 py-3
                     text-gray-100 placeholder-gray-500
                     resize-none outline-none"
        />
      </main>
    </div>
  );
}

カスタマイズ

directionオプション

シートトランジションの方向を指定します:

// 前進方向 (リスト → 作成): シートが下から上がる
sheet({ direction: 'enter' })

// 後退方向 (作成 → リスト): シートが下に下がる
sheet({ direction: 'exit' })

重要: direction: 'enter'direction: 'exit'はz-index処理が異なります:

  • enter: 進入するシートが自然に前面に表示
  • exit: 退出するシートがz-index: 100で前面を維持しながら下がる

スプリング設定

アニメーションの弾性と速度の調整:

sheet({
  direction: 'enter',
  physics: { spring: {
    stiffness: 140,  // 低いほど滑らか (デフォルト: 140)
    damping: 18,     // 高いほど速く安定 (デフォルト: 18)
    doubleSpring: 0.5 // OUTアニメーションのスプリング強度倍率 (デフォルト: 0.5)
  }
})

滑らかなトランジション

より滑らかで余裕のあるトランジション:

sheet({
  physics: { spring: {
    stiffness: 100,
    damping: 20,
    doubleSpring: 0.6
  }
})

速いトランジション

より速くレスポンシブなトランジション:

sheet({
  physics: { spring: {
    stiffness: 180,
    damping: 16,
    doubleSpring: 0.4
  }
})

注意事項

パフォーマンス最適化

  • GPU加速を使用 (transformopacityを使用)
  • will-changeの自動設定と解除
  • backfaceVisibility: hiddenでちらつき防止
  • レイアウト再計算の防止

z-index管理

  • 作成ページ: z-index: 100 (前面に表示)
  • バックグラウンドページ: z-index: -1 (背面に配置)
  • 自動的に管理されるため手動設定不要

モバイル最適化

  • モバイルデバイス向けに最適化されたタイミング
  • タッチジェスチャーと自然に連動
  • 60fps滑らかなアニメーション保証

アクセシビリティ

  • prefers-reduced-motion設定時にトランジション無効化
  • キーボードナビゲーション完全対応
  • ESCキーで作成ページを閉じる対応
  • フォーカストラップでキーボードアクセシビリティ向上

ベストプラクティス

✅ DO

  • FABボタンから全画面作成モードへの遷移時に使用
  • ソーシャルメディア、メール、メモなど新しいコンテンツ作成時に適用
  • direction: 'enter'direction: 'exit'を一緒に設定して双方向の一貫性を維持
  • 作成ページは全画面レイアウトを使用
  • キャンセルおよび完了ボタンを明確に提供

❌ DON'T

  • 一般的なページ間ナビゲーションには使用しないでください
  • 読み取り専用コンテンツの表示には不適合 (InstagramまたはHeroを使用)
  • タブナビゲーションには使用しないでください (スライドを使用)
  • 階層構造のないページ間の移動には不適合
  • デスクトップ環境ではモーダルまたは他のトランジションを検討

関連トランジション

  • ドリル: 階層的なコンテンツナビゲーションに適合
  • スライド: 水平タブナビゲーションに適合
  • Instagram: 画像拡大表示に適合
  • フェード: 一般的なページトランジションに適合

ブラウザ互換性

  • Chrome/Edge (最新)
  • Firefox (最新)
  • Safari (最新)
  • すべてのモダンモバイルブラウザ