SSGOI LogoSSGOI

Sheet 过渡

底部弹出式移动端优化过渡

Sheet 过渡

Sheet 过渡是一种底部弹出式的移动端优化过渡。背景页面会向后退并缩小,新屏幕从底部向上滑动出现。常用于从 FAB 按钮切换到编辑屏幕时。

演示

Loading demo...

UX 原则

何时使用?

Sheet 过渡用于从 FAB 或编辑按钮切换到全屏输入模式

按内容关系的适用性

콘텐츠 관계적합성설명
无关内容不适用于独立部分之间
同级关系同级页面间移动建议使用滑动
层级关系最适合创建新内容场景,如列表→编辑、动态→发帖等

主要用例

  • 社交媒体: 从动态发布新帖子
  • 邮件应用: 从收件箱撰写新邮件
  • 笔记应用: 从笔记列表创建新笔记
  • 待办事项应用: 从列表添加新任务
  • 聊天应用: 从对话列表撰写新消息

为什么这样运作?

  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. 过渡配置

Sheet 过渡使用 direction 选项明确设置前进/后退方向:

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

const config = {
  transitions: [
    {
      from: '/feed',
      to: '/compose',
      transition: sheet({ direction: 'enter' })  // 前进: sheet 上升
    },
    {
      from: '/compose',
      to: '/feed',
      transition: sheet({ direction: 'exit' })   // 后退: sheet 下降
    }
  ]
};

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 过渡的方向:

// 前进方向(列表 → 编辑): sheet 从下方升起
sheet({ direction: 'enter' })

// 后退方向(编辑 → 列表): sheet 向下降落
sheet({ direction: 'exit' })

重要: direction: 'enter'direction: 'exit' 的 z-index 处理不同:

  • enter: 进入的 sheet 自然显示在前面
  • exit: 退出的 sheet 保持在 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 键关闭编辑页面
  • 焦点陷阱改善键盘可访问性

最佳实践

✅ 应该

  • 从 FAB 按钮切换到全屏编辑模式时使用
  • 在社交媒体、邮件、笔记等新内容创建时应用
  • 同时设置 direction: 'enter'direction: 'exit' 保持双向一致性
  • 编辑页面使用全屏布局
  • 清晰提供取消和完成按钮

❌ 不应该

  • 不要用于常规页面间导航
  • 不适合只读内容查看(使用 instagram 或 hero)
  • 不要用于标签导航(使用 slide)
  • 不适合没有层级结构的页面间移动
  • 桌面环境考虑使用模态框或其他过渡

相关过渡

  • Drill: 适合层级内容导航
  • Slide: 适合水平标签导航
  • Instagram: 适合图片放大查看
  • Fade: 适合一般页面过渡

浏览器兼容性

  • Chrome/Edge(最新版)
  • Firefox(最新版)
  • Safari(最新版)
  • 所有现代移动浏览器