How Astryx Toast System Handles Collision Detection and Positioning for Multiple Toasts

Astryx's toast system uses a single ToastViewport component with uniqueID-based deduplication and flexbox-based stacking to manage collision detection and positioning for multiple concurrent notifications.

Toast notifications often overlap or duplicate when users trigger rapid actions. The facebook/astryx repository solves this through a centralized viewport pattern. The ToastViewport component in packages/core/src/Toast/ToastViewport.tsx orchestrates collision detection via configurable behaviors and positions toasts using CSS flexbox with directional stacking logic.

Collision Detection via Unique IDs and Behaviors

Every toast can carry an optional uniqueID in its ToastOptions. When addToast is called, the system checks this ID against existing toasts and applies a collisionBehavior to determine the outcome.

Available Collision Behaviors

The ToastCollisionBehavior type (defined in packages/core/src/Toast/types.ts, lines 15–17) supports two modes:

  • overwrite — The new toast replaces the existing one with the same uniqueID, preserving position in the stack
  • ignore — The new toast is suppressed entirely, preventing any render or screen-reader announcement

Implementation in ToastViewport.tsx

Lines 205–231 of ToastViewport.tsx contain the core detection logic:

// Simplified excerpt from ToastViewport.tsx lines 205-231
function addToast(options: ToastOptions) {
  const existingIndex = findToastIndexByUniqueID(options.uniqueID);
  
  if (existingIndex !== -1) {
    if (options.collisionBehavior === 'ignore') {
      return; // Early exit — toast suppressed
    }
    if (options.collisionBehavior === 'overwrite') {
      // Replace in-place rather than appending
      updateToastAtIndex(existingIndex, options);
      return;
    }
  }
  
  // No collision — append new toast
  appendToast(options);
}

This deduplication happens before any render cycle, keeping the DOM and accessibility tree clean.

Positioning and Stacking Strategy

Viewport Position Prop

ToastViewport accepts a position prop with four ToastPosition values:

  • topEnd
  • topStart
  • bottomEnd
  • bottomStart

CSS Flexbox Mapping

Lines 43–56 of ToastViewport.tsx define static style objects for each position:

// From ToastViewport.tsx lines 43-56
const bottomEnd = {
  alignItems: 'flex-end',
  bottom: 16,
  right: 16,
};

const topEnd = {
  alignItems: 'flex-end',
  top: 16,
  right: 16,
  flexDirection: 'column-reverse', // Newest at top
};

// bottomStart and topStart follow same pattern with left alignment

The component selects styles via posStyle and applies them through stylex.props.

Stack Order Control

Position Flex Direction Newest Toast Appears At
topEnd, topStart column-reverse Top of stack
bottomEnd, bottomStart column (default) Bottom of stack

This ensures visual consistency: top positions grow downward, bottom positions grow upward.

Visible Toast Limiting

The viewport respects maxVisible to prevent screen overflow:

// From ToastViewport.tsx — visible slice logic
const visibleToasts = toasts.slice(-maxVisible);

Only the last maxVisible toasts render; older entries remain in state but hidden.

Complete Usage Examples

Overwriting Duplicate Status Messages

import { useToast } from '@astryxdesign/core';

function SaveButton() {
  const showToast = useToast();

  const handleSave = async () => {
    // Show pending state
    showToast({
      body: 'Saving changes...',
      uniqueID: 'save-status',
      type: 'loading',
    });

    await saveToServer();

    // Replace with success — same uniqueID triggers overwrite
    showToast({
      body: 'Saved successfully',
      uniqueID: 'save-status',
      type: 'success',
    });
  };

  return <button onClick={handleSave}>Save</button>;
}

Ignoring Duplicate Warnings

function WarnUnsavedChanges() {
  const showToast = useToast();

  // First call shows toast; subsequent calls suppressed
  const warn = () => {
    showToast({
      body: 'You have unsaved changes',
      uniqueID: 'unsaved-changes',
      collisionBehavior: 'ignore',
      type: 'warning',
    });
  };

  return <button onClick={warn}>Check Status</button>;
}

Configuring Viewport Position and Limit

import { ToastViewport } from '@astryxdesign/core';

function App() {
  return (
    <ToastViewport 
      position="topEnd" 
      maxVisible={5}
    >
      <Router />
    </ToastViewport>
  );
}

Key Implementation Files

File Responsibility
packages/core/src/Toast/ToastViewport.tsx Collision detection (lines 205–231), position styles (lines 43–56), stacking logic
packages/core/src/Toast/types.ts ToastCollisionBehavior, ToastPosition, and option interfaces (lines 15–17)
packages/core/src/Toast/ToastContext.ts Context provider with addToast, removeToast, findByUniqueID
packages/core/src/Toast/Toast.tsx Individual toast rendering, auto-hide timers, dismiss UI

Summary

  • Collision detection relies on uniqueID matching with configurable collisionBehavior of overwrite or ignore
  • Positioning uses four viewport corners with flexbox alignment and inset placement
  • Stack order inverts via column-reverse for top positions so newest toasts appear highest
  • Overflow protection slices toasts to maxVisible, keeping only the most recent entries rendered
  • All logic centralizes in ToastViewport.tsx according to the Astryx source code architecture

Frequently Asked Questions

How does Astryx prevent duplicate toast spam?

Astryx prevents spam through the ignore collision behavior. When a toast with collisionBehavior: 'ignore' shares a uniqueID with an existing toast, the addToast function returns early at line 205–231 of ToastViewport.tsx without adding or announcing anything.

Can I change toast position dynamically?

The position prop accepts topEnd, topStart, bottomEnd, or bottomStart as static values. Dynamic position changes would require remounting ToastViewport or maintaining multiple viewports, as the position styles are computed at render time from the static objects defined at lines 43–56.

What happens when maxVisible is exceeded?

Older toasts are sliced from the visible array via toasts.slice(-maxVisible) while remaining in internal state. They reappear if newer toasts are dismissed, maintaining FIFO order within the visibility window.

Why does column-reverse matter for top positions?

Without column-reverse, flexbox would append new toasts below existing ones for top positions, causing the stack to grow away from the viewport edge. The reversal ensures visual anchoring: top positions grow downward from the corner, bottom positions grow upward.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →