Back to Tamagui

Toast v2 Implementation Plan

code/kitchen-sink/plans/toast-2.md

1.144.414.3 KB
Original Source

Toast v2 Implementation Plan

Overview

Revamp @tamagui/toast to v2, inspired by Sonner's excellent UX. Must be fully cross-platform using Tamagui's animation system - NO CSS cheats.

Reference Implementations

  • ~/github/sonner - primary inspiration
  • ~/github/base-ui - additional patterns
  • @tamagui/sheet - gesture/drag patterns, resistive pull

Current Session Issues Found & Fixes

BUGS FOUND (need fixing)

  1. Top position toasts outside frame ✅ FIXED

    • Issue: Toasts with top position were rendering outside the container
    • Fix: Changed from hardcoded bottom: 0 to dynamic {...(isTop ? { top: 0 } : { bottom: 0 })}
  2. Action buttons overlapping ✅ FIXED

    • Issue: Cancel/Confirm buttons used ToastCloseButton (20x20px fixed) causing overlap
    • Fix: Created new ToastActionButton component with proper padding for text buttons
  3. Hover stack → leave → rest fly away, one stays ✅ FIXED

    • Issue: When hovering stacked toasts then leaving, most toasts dismiss but one stays longer
    • Cause: pauseTimer was being called multiple times, double-counting elapsed time
    • Fix: Added lastPauseTimeRef guard (Sonner pattern) - only calculate elapsed time if timer was started after last pause
  4. Exit animations fly too far ✅ FIXED

    • Issue: Toasts flew 100px on exit, too dramatic
    • Fix: Reduced to 30px for swipe, 10px for normal exit
  5. Focus outline shows on click ✅ FIXED

    • Issue: Focus ring appeared on mouse click, should only show on keyboard
    • Fix: Changed focusStyle to focusVisibleStyle
  6. Expanded state showed ALL toasts ✅ FIXED

    • Issue: When expanded, ALL toasts showed instead of respecting visibleToasts limit
    • Sonner behavior: visibleToasts limit applies in BOTH collapsed and expanded states
    • Fix: Changed opacity logic to hide toasts beyond visibleToasts in both states
    • Also added pointerEvents: 'none' for hidden toasts

CHANGES MADE THIS SESSION

  1. visibleToasts default: 3 → 4
  2. Fade out last visible toast - opacity 0.5 for toast at visibleToasts-1 index
  3. Top position anchor - dynamic top/bottom based on position
  4. ToastActionButton component - proper text button for action/cancel
  5. Subtle exit animations - 10px/30px instead of 100px
  6. focusVisibleStyle - outline only on keyboard navigation

DESIGN DECISIONS (user feedback)

  1. Keep styles minimal in core - demo can add more styles
  2. No sonnerStyle close button - keep it simple, inline close button is fine
  3. Use animateOnly={['transform', 'opacity']} in examples for performance
  4. Export .Close component - like Dialog, just wraps events without styling

Style Checklist

Container (Toaster)

  • position: fixed (web) / absolute (native)
  • z-index: 100000+ (very high)
  • width: 356px (TOAST_WIDTH constant)
  • Viewport offsets: 24px desktop
  • pointerEvents: 'box-none' for pass-through

Position Variants

  • bottom-right: bottom + right offsets
  • bottom-left: bottom + left offsets
  • bottom-center: bottom + left 50% + translateX(-50%)
  • top-right: top + right offsets ✅ FIXED anchor
  • top-left: top + left offsets ✅ FIXED anchor
  • top-center: top + left 50% + translateX(-50%) ✅ FIXED anchor

Toast Item Frame

  • position: absolute (within container)
  • left: 0, right: 0 (full width of container)
  • Dynamic anchor: top:0 for top positions, bottom:0 for bottom positions ✅ FIXED
  • background: $background
  • border-radius: $4 (8px)
  • paddingHorizontal: $4, paddingVertical: $3
  • border: 1px solid $borderColor
  • elevation: $4 + shadow for depth
  • focusable: true
  • focusVisibleStyle instead of focusStyle ✅ FIXED

Toast Content Layout

  • flex row with icon, content, close button
  • icon: 16x16px, flex-shrink: 0
  • content: flex column, gap $1
  • title: fontWeight 600, $color, size $4
  • description: $color11, size $2
  • gap: $3 between elements

Close Button

  • positioned inline (user decided against absolute overlap)
  • 20x20px circle
  • borderRadius: $10 (circular)
  • backgroundColor: $color5
  • hoverStyle: $color6
  • pressStyle: $color7

Action Buttons ✅ FIXED

  • New ToastActionButton component with proper sizing
  • borderRadius: $2
  • paddingHorizontal: $2
  • height: 24px
  • primary variant for action button (dark bg, light text)
  • marginTop: $3 spacing
  • gap: $2 between buttons
  • justifyContent: flex-end

Rich Colors (Type Variants)

  • success: $green2 background, $green6 border
  • error: $red2 background, $red6 border
  • warning: $yellow2 background, $yellow6 border
  • info: $blue2 background, $blue6 border
  • loading: default (neutral)

Icons

  • success: ✓ in $green10
  • error: ✕ in $red10
  • warning: ⚠ in $yellow10
  • info: ℹ in $blue10
  • loading: ⟳ in $color11
  • close: ✕ in $color11

Feature Checklist

Core Toast API

  • toast() - basic toast
  • toast.success() - success type
  • toast.error() - error type
  • toast.warning() - warning type
  • toast.info() - info type
  • toast.loading() - loading type (no auto-dismiss)
  • toast.promise() - promise with loading/success/error states
  • toast.custom() - custom JSX
  • toast.dismiss(id) - dismiss specific toast
  • toast.dismiss() - dismiss all

Toast Options

  • id - custom id for updating
  • title - main text (string or function)
  • description - secondary text (string or function)
  • duration - auto-dismiss time (default 4000ms)
  • icon - custom icon
  • action - action button with label/onClick
  • cancel - cancel button
  • dismissible - can be dismissed (default true)
  • onDismiss - callback when dismissed
  • onAutoClose - callback when auto-closed
  • closeButton - per-toast close button override

Toaster Props

  • position - 6 positions
  • expand - always show expanded
  • visibleToasts - max visible (default 4) ✅ CHANGED from 3
  • gap - space between toasts (default 14px)
  • duration - default duration (4000ms)
  • offset - viewport padding (24px)
  • hotkey - keyboard shortcut (alt+T)
  • swipeDirection - dismiss direction ('right')
  • swipeThreshold - swipe distance (50px)
  • closeButton - show close buttons
  • richColors - colored backgrounds
  • icons - custom icons per type
  • theme - light/dark/system

Stacking Behavior

  • Only show visibleToasts (default 4)
  • Scale down non-front toasts: 1 - (index * 0.05)
  • Y-offset for collapsed stack: 10px per toast
  • Expanded state shows full offset with gaps
  • Front toast height applied to hidden toasts
  • z-index: visibleToasts - index (front highest)
  • transformOrigin: 'bottom center' for bottom, 'top center' for top
  • Fade out last visible toast (opacity 0.5) ✅ NEW

Hover Expand ✅ COMPLETE

  • Expand on hover (onMouseEnter + onMouseMove)
  • Pause timers when expanded/hovered
  • Resume timers when mouse leaves
  • Gap filler View to prevent flicker when mouse moves between toasts
  • Collapse when only 1 toast remains
  • interacting state tracks pointer down/up
  • Timer sync issue when leaving hover ✅ FIXED with lastPauseTimeRef guard

Swipe to Dismiss ✅ COMPLETE

  • Pointer event tracking (setPointerCapture)
  • Direction lock on first significant movement
  • Threshold-based dismiss (50px default)
  • Velocity-based dismiss (0.11 px/ms)
  • Resistive pull in wrong direction (sqrt curve from Sheet)
  • Exit animation in swipe direction
  • Snap back on cancel

Enter/Exit Animations ✅ REFINED

  • enterStyle: opacity 0, y: ±10, scale: 0.95 (subtle)
  • exitStyle: opacity 0, x/y: ±30 for swipe, ±10 for normal, scale: 0.95
  • AnimatePresence for mount/unmount
  • transition: 'quick' (Tamagui animation)
  • Disable animation while dragging

Keyboard Support

  • Escape to dismiss focused toast
  • Hotkey to expand toaster (alt+T)
  • Focus management (tabIndex, lastFocusedElementRef)

Accessibility

  • role="status"
  • aria-live="polite"
  • aria-atomic
  • aria-label on container
  • tabIndex for focusable
  • aria-label="Close toast" on close button

Test Checklist

Basic Toast Tests ✅

  • shows a default toast
  • shows typed toasts (success, error, warning, info)
  • shows loading toast
  • shows multiple stacked toasts

Interaction Tests ✅

  • dismisses all toasts
  • closes toast when clicking close button
  • keyboard escape dismisses focused toast
  • shows action button

Promise Tests ✅

  • promise toast transitions loading -> success
  • promise toast shows error on rejection

Position Tests ✅

  • changes position when clicking position buttons
  • all 6 positions verified with screenshots

Hover Tests ✅

  • hover expands stacked toasts
  • hover pauses auto-dismiss timer
  • mouse leave collapses stack
  • no flicker when mouse moves between toasts

Swipe Tests ✅

  • swipe right dismisses (default)
  • swipe threshold works
  • velocity-based dismiss
  • resistive pull in wrong direction
  • swipe cancel snaps back

Interrupt Tests ✅ NEW

  • toast closing does not interrupt on mouse re-entry
  • drag gesture moves toast visually
  • fast swipe dismisses via velocity

TODO - Remaining Work (DEADLINE: 9:00 AM DEMO VIDEO)

TIMELINE

  • NOW - 8:20: Fix top position bug, verify tests pass
  • 8:20 - 8:35: Write clean ToastDemo, verify on tamagui.dev /ui/toast (mobile + safe areas)
  • 8:35 - 8:45: Update docs, update version-two blog post
  • 8:45 - 8:55: Full yarn test, lint, typecheck, sub-agent review
  • 8:55 - 9:00: Clean commit, push to CI, /alert user

CRITICAL - Before 8:20

  1. Fix timer sync bug ✅ DONE - added lastPauseTimeRef guard
  2. FIX: Top position toast outside viewport ✅ FIXED - explicitly set top/bottom to avoid conflicts
  3. Test action buttons after ToastActionButton fix ✅ VERIFIED - buttons render correctly

Before 8:35 (Demo Ready)

  1. Write clean ToastDemo for tamagui.dev ✅ DONE - code/demos/src/ToastDemo.tsx
  2. Verify on yarn dev site at /ui/toast ✅ VERIFIED - page renders, toast works
  3. Mobile web + safe areas check (SSR safe!) ✅ FIXED - use-window-dimensions isClient guard
  4. Web-only code but use conditionals for future native support

Before 8:45 (Docs + Blog)

  1. Update Toast docs - clean up (2.0.0.mdx exists but has old API docs)
  2. Update version-two blog post with Toast feature/demo hero

Before 8:55 (Testing + Review)

  1. yarn test - 31 toast tests passing ✅
  2. yarn lint, yarn typecheck ✅ - fixed biome issues, types clean
  3. Sub-agent code review

Before 9:00 (Ship It)

  1. Clean commit and push to CI
  2. /alert user every 30s until acknowledged

Low Priority (Post-Demo)

  • Export .Close component (Dialog pattern)
  • Add animateOnly={['transform', 'opacity']} to examples
  • All animation drivers tested
  • SVG icons instead of text characters
  • RTL support

Key Formulas (from Sonner)

Stack Scale

scale = 1 - (index * 0.05)
// index 0: 1.0, index 1: 0.95, index 2: 0.90

Collapsed Y Offset

// For bottom position
stackY = isFront ? 0 : -peekAmount * index
// For top position
stackY = isFront ? 0 : peekAmount * index

Expanded Y Offset

offset = (heightIndex * gap) + toastsHeightBefore
// For bottom: -offset
// For top: +offset

Resistive Pull (from Sheet)

typescript
function resisted(delta: number, maxResist = 25): number {
  if (delta >= 0) return delta
  const pastBoundary = Math.abs(delta)
  const resistedDistance = Math.sqrt(pastBoundary) * 2
  return -Math.min(resistedDistance, maxResist)
}

Velocity Threshold

VELOCITY_THRESHOLD = 0.11 // px/ms
shouldDismiss = passedThreshold || velocity > VELOCITY_THRESHOLD

Gap Filler (prevents hover flicker)

typescript
// Invisible hit area above/below toast when expanded
const gapFillerHeight = expanded ? gap + 1 : 0
<View
  position="absolute"
  left={0}
  right={0}
  height={gapFillerHeight}
  pointerEvents="auto"
  {...(isTop ? { top: '100%' } : { bottom: '100%' })}
/>

Opacity Fade for Limit

typescript
// Fade out toasts as they approach visibility limit
let computedOpacity = 1
if (!expanded) {
  if (index >= visibleToasts) {
    computedOpacity = 0 // completely hidden beyond limit
  } else if (index === visibleToasts - 1) {
    computedOpacity = 0.5 // last visible toast fades
  }
}

Files Modified

  1. /code/ui/toast/src/Toaster.tsx

    • Container positioning with minHeight: 1
    • onMouseEnter + onMouseMove for hover expand
    • Auto-collapse when 1 toast remains
    • visibleToasts default: 4
  2. /code/ui/toast/src/ToastItem.tsx

    • Stacking logic (scale, y, z-index, height, transformOrigin)
    • Gap filler View for hover flicker prevention
    • dataSet for RN Web data attribute compatibility
    • Enter/exit styles with AnimatePresence
    • Dynamic top/bottom anchor based on position
    • ToastActionButton component for action/cancel
    • focusVisibleStyle for keyboard-only focus
    • Subtle exit animations (10px/30px)
    • Opacity fade for limit
  3. /code/ui/toast/src/useDragGesture.ts

    • Resistive pull with sqrt curve (Sheet pattern)
    • Direction lock on first movement
    • Velocity-based dismiss
  4. /code/kitchen-sink/tests/ToastMultiple.test.tsx

    • Loading toast test fix (text selector vs data-type)
  5. /code/kitchen-sink/tests/ToastHover.test.tsx - NEW

    • Hover expand tests
    • Flicker prevention tests
    • Timer pause tests
  6. /code/kitchen-sink/tests/ToastVisual.test.tsx - NEW

    • Visual verification tests
    • Swipe tests
  7. /code/kitchen-sink/tests/ToastInterrupt.test.tsx - NEW

    • Interrupt behavior tests
    • Drag gesture tests
    • Velocity dismiss tests

Test Results Summary

Total: 25 tests passing

  • Toast.test.tsx: 3 tests (focus management)
  • ToastMultiple.test.tsx: 12 tests (core API)
  • ToastHover.test.tsx: 3 tests (hover behavior)
  • ToastVisual.test.tsx: 4 tests (swipe, visual)
  • ToastInterrupt.test.tsx: 3 tests (interrupt, drag, velocity)

All tests pass with CSS animation driver.