SSGOI LogoSSGOI

Sheet Transition

Mobile-optimized bottom sheet style transition

Sheet Transition

The Sheet transition is a mobile-optimized transition with a bottom sheet style. The background page recedes and shrinks, while the new screen slides up from the bottom. This is commonly used when transitioning from a FAB button to a compose screen.

Demo

Loading demo...

UX Principles

When to Use?

The Sheet transition is used for transitions from FAB or compose buttons to full-screen input mode.

Content Relationship Suitability

콘텐츠 관계적합성설명
Unrelated ContentNot suitable between independent sections
Sibling RelationshipUse slide for navigation between same-level pages
Hierarchical RelationshipOptimal for creating new context like list→compose, feed→post creation

Primary Use Cases

  • Social Media: Creating new posts from feed
  • Email Apps: Composing new mail from inbox
  • Note Apps: Creating new note from note list
  • Todo Apps: Adding new task from list
  • Chat Apps: Composing new message from conversation list

Why Does It Work This Way?

  1. Context Preservation: Background shrinks and remains, making it clear where you came from
  2. Space Expansion Metaphor: Natural feeling of new workspace rising from below
  3. Focus Direction: Background page blurs and shrinks to focus on compose screen
  4. Mobile Native Pattern: Similar to modal transitions in iOS and Android native apps
  5. Easy Return: Background naturally expands again on back navigation

Motion Design

Forward Process (List → Compose):
1. Background page starts shrinking (scale: 1 → 0.8)
2. Simultaneously opacity decreases (opacity: 1 → 0)
3. Compose page slides up from bottom (translateY: 100% → 0)
4. Background placed behind with z-index: -1
5. Compose page placed in front with z-index: 100

Backward Process (Compose → List):
1. Compose page slides down to bottom (translateY: 0 → 100%)
2. Simultaneously background page expands (scale: 0.8 → 1)
3. Background opacity restores (opacity: 0 → 1)
4. Center point fixed to viewport center

Basic Usage

1. Transition Configuration

The sheet transition explicitly sets forward/backward directions with the direction option:

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

const config = {
  transitions: [
    {
      from: '/feed',
      to: '/compose',
      transition: sheet({ direction: 'enter' })  // Forward: sheet slides up
    },
    {
      from: '/compose',
      to: '/feed',
      transition: sheet({ direction: 'exit' })   // Backward: sheet slides down
    }
  ]
};

export default function App() {
  return (
    <Ssgoi config={config}>
      {/* App content */}
    </Ssgoi>
  );
}

2. Page Structure

Feed page with FAB button and compose page:

// Feed page
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">Feed</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>
  );
}

// Compose page
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"
        >
          Cancel
        </button>
        <h1 className="text-xl font-bold flex-1">New Post</h1>
        <button className="text-blue-500 font-semibold">
          Publish
        </button>
      </header>

      <main className="p-4">
        <input
          type="text"
          placeholder="Title"
          className="w-full bg-gray-800 border border-gray-700
                     rounded-lg px-4 py-3 mb-4"
        />
        <textarea
          placeholder="Write your content..."
          rows={10}
          className="w-full bg-gray-800 border border-gray-700
                     rounded-lg px-4 py-3 resize-none"
        />
      </main>
    </div>
  );
}

Real-World Examples

1. Social Media Post Composition

Twitter/X style new post creation:

// Timeline page
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">Timeline</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>
  );
}

// Compose page
function ComposeTweet() {
  const [content, setContent] = useState('');
  const navigate = useNavigate();

  const handlePost = () => {
    // Post logic
    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"
        >
          Cancel
        </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"
        >
          Post
        </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="What's happening?"
            className="flex-1 bg-transparent text-xl
                       text-gray-100 placeholder-gray-500
                       resize-none outline-none min-h-[200px]"
          />
        </div>

        {/* Character counter */}
        <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. Email Composition

Composing new mail from inbox:

// Inbox
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">Inbox</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>
  );
}

// Compose email
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)}>Cancel</button>
        <h1 className="text-lg font-semibold">New Email</h1>
        <button className="text-blue-500 font-semibold">Send</button>
      </header>

      <main className="divide-y divide-gray-800">
        <input
          type="email"
          placeholder="To"
          className="w-full bg-transparent px-4 py-3
                     text-gray-100 placeholder-gray-500"
        />
        <input
          type="text"
          placeholder="Subject"
          className="w-full bg-transparent px-4 py-3
                     text-gray-100 placeholder-gray-500"
        />
        <textarea
          placeholder="Email content"
          rows={15}
          className="w-full bg-transparent px-4 py-3
                     text-gray-100 placeholder-gray-500
                     resize-none outline-none"
        />
      </main>
    </div>
  );
}

Customization

direction Option

Specifies the direction of the sheet transition:

// Forward direction (list → compose): sheet slides up from bottom
sheet({ direction: 'enter' })

// Backward direction (compose → list): sheet slides down to bottom
sheet({ direction: 'exit' })

Important: direction: 'enter' and direction: 'exit' have different z-index handling:

  • enter: Entering sheet naturally appears in front
  • exit: Exiting sheet stays in front with z-index: 100 while sliding down

Spring Configuration

Adjust animation elasticity and speed:

sheet({
  direction: 'enter',
  spring: {
    stiffness: 140,  // Lower = smoother (default: 140)
    damping: 18,     // Higher = faster settling (default: 18)
    doubleSpring: 0.5 // Spring strength multiplier for OUT animation (default: 0.5)
  }
})

Smooth Transition

More gentle and relaxed transition:

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

Fast Transition

Faster and more responsive transition:

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

Important Notes

Performance Optimization

  • GPU acceleration used (transform, opacity)
  • Automatic will-change setup and cleanup
  • backfaceVisibility: hidden prevents flickering
  • Prevents layout recalculation

z-index Management

  • Compose page: z-index: 100 (displayed in front)
  • Background page: z-index: -1 (placed behind)
  • Automatically managed, no manual setup needed

Mobile Optimization

  • Optimized timing for mobile devices
  • Natural integration with touch gestures
  • Guaranteed 60fps smooth animation

Accessibility

  • Transition disabled when prefers-reduced-motion is set
  • Full keyboard navigation support
  • ESC key support to close compose page
  • Focus trap improves keyboard accessibility

Best Practices

✅ DO

  • Use for transitions from FAB button to full-screen compose mode
  • Apply when creating new content in social media, mail, notes, etc.
  • Set both direction: 'enter' and direction: 'exit' for bidirectional consistency
  • Use full-screen layout for compose page
  • Provide clear cancel and complete buttons

❌ DON'T

  • Don't use for general page-to-page navigation
  • Not suitable for read-only content viewing (use Instagram or Hero instead)
  • Don't use for tab navigation (use Slide instead)
  • Not suitable for navigation between pages without hierarchy
  • Consider modals or other transitions for desktop environments
  • Drill: Suitable for hierarchical content navigation
  • Slide: Suitable for horizontal tab navigation
  • Instagram: Suitable for image zoom viewing
  • Fade: Suitable for general page transitions

Browser Compatibility

  • Chrome/Edge (latest)
  • Firefox (latest)
  • Safari (latest)
  • All modern mobile browsers