primitives icon indicating copy to clipboard operation
primitives copied to clipboard

[Docs: Toast] Zero guidance on how to have a app-wide portable toast system which I can call from anywhere to toast any message

Open ADTC opened this issue 1 year ago • 6 comments

Documentation

Radix Toast documentation shows very weirdly how to build a single toast surrounding a button which opens it.

That's it. Nothing more. How I'm supposed to build an application-wide toasting system from this, I don't know.

The docs look extremely lacking in clarity, and I'm surprised no one has raised this concern yet.

Relevant Radix Component(s)

The Radix doc shows how to build a Toaster from its parts, that envelopes a button which changes a state variable, which then does the toasting. It seems like its purpose is to just generate that toast on that button. So, it's not portable. What if I want to open a toast from an error state after sending a request to an API? I don't know how to derive that from this documentation.

You could say, have a button that calls the API, and wrap this button with <Toast.Provider> but how does that make sense? I have to wrap every button that needs a toast with it? How about toasting for events that are not triggered by buttons? Like a WebRTC push mesage?

What's even weirder is that in order to create multiple toasts, I have to create an array of Toast components? 🤯

  • https://www.radix-ui.com/primitives/docs/components/toast

Examples from other doc sites

Please compare to these where it shows how to toast from anywhere, in any JavaScript. You plugin a Toaster then you just toast. The example is a button click, but I can very easily derive how to toast from an error state after sending an API request. Multiple toasts? Just call toast again and again. Avoid duplicates? Just add ID to your toasts. Want duplicates? Don't add ID to your toasts.

  • https://ui.shadcn.com/docs/components/toast (This is a Tailwind implementation of Radix UI, and a very complex one at that. I want a simple basic implementation that doesn't use Tailwind.)
  • https://react-hot-toast.com/docs (Familiar and easy to use.)
  • https://sonner.emilkowal.ski/getting-started (Pitched as the "best of both worlds" combining ShadCN and React Hot Toast.)

Is this simplicity even possible to replicate in Radix Toast? Or am I just too dumb? 😂

ADTC avatar Mar 25 '24 19:03 ADTC

What's even weirder is that in order to create multiple toasts, I have to create an array of Toast components? 🤯

It's funny that the only thing useful about this toaster is its styling.

Anyhow, here is how I solved it with jotai and tailwind. This ain't optimized but at least has more functionality than theirs.

Toast.tsx

import { Icon } from "@iconify-icon/react";
import * as Toast from "@radix-ui/react-toast";
import { useAtom } from "jotai";
import { useEffect, useMemo, useRef } from "react";

const Toaster = () => {
  const [{ list }] = useAtom(toastAtom);

  return (
    <Toast.Provider swipeDirection="right">
      {list.map((props) => (
        <SingleToast {...props} key={props.id} />
      ))}
      <Toast.Viewport className="fixed bottom-0 right-0 flex flex-col p-8 gap-3 w-96 max-w-screen m-0 list-none !z-50 outline-none " />
    </Toast.Provider>
  );
};

const modes: { [type: string]: { textColor: string; borderColor: string } } = {
  positive: { textColor: "text-positive", borderColor: "border-positive" },
  negative: { textColor: "text-negative", borderColor: "border-negative" },
  info: { textColor: "text-info", borderColor: "border-info" },
};

const SingleToast = ({
  id,
  open = true,
  title = "",
  subtitle = "",
  mode = "info",
  timer = 3000,
  infinite = false,
}: {
  id?: number;
  open?: boolean;
  title: string;
  subtitle: string;
  mode?: string;
  timer?: number;
  infinite?: boolean;
}) => {
  const [_, setToast] = useAtom(toastAtom);
  const timerRef = useRef<NodeJS.Timeout | undefined>();

  const removeToast = () => {
    setToast((state) => {
      state.list = state.list.filter((toast) => toast.id != id);
    });
    timerRef.current && clearTimeout(timerRef.current);
  };

  const setTimer = () => {
    timerRef.current = setTimeout(() => {
      removeToast();
      clearTimeout(timerRef.current);
    }, timer + 1000);
  };

  useEffect(() => {
    !infinite && setTimer();
  }, []);

  return (
    <Toast.Root
      className={`${modes[mode].borderColor} border border-accent/50 bg-light dark:bg-dark rounded-md shadow-[hsl(206_22%_7%_/_35%)_0px_10px_38px_-10px,_hsl(206_22%_7%_/_20%)_0px_10px_20px_-15px] p-[15px] grid [grid-template-areas:_'title_action'_'description_action'] grid-cols-[auto_max-content] gap-x-[15px] items-center `}
      duration={Infinity}
    >
      <Toast.Title className={`font-bold ${modes[mode].textColor}`}>{title}</Toast.Title>
      <Toast.Description className="text-base">{subtitle}</Toast.Description>
      <Toast.Action className="[grid-area:_action]" asChild altText="Dismiss">
        <Toast.Close
          aria-label="Close"
          className={`border rounded-full h-8 w-8 flex justify-center items-center ${modes[mode].borderColor}`}
          onClick={removeToast}
        >
          <Icon icon="ph:x-thin" className={`text-xl ${modes[mode].textColor}`} />
        </Toast.Close>
      </Toast.Action>
    </Toast.Root>
  );
};

export default Toaster;

useToast.ts

export const useToast = () => {
  const [_, setToast] = useAtom(toastAtom);

  return ({ timer = 3000, ...toast }: Toast) => {
    const id = Date.now() + Math.random();

    setToast((state) => {
      state.list = [...state.list, { id, timer, ...toast }];
    });
  };
};

store.ts

import { atomWithImmer } from "jotai-immer";

export const toastAtom = atomWithImmer<{ list: Array<Toast> }>({ list: [] });

types.d.ts

interface Toast {
  id?: number;
  open?: boolean;
  title: string;
  subtitle: string;
  timer?: number;
  infinite?: boolean;
  mode?: "info" | "positive" | "negative";
}

Usage

App.tsx

function App({ children }: PropsWithChildren) {
  const toast = useToast();

  useEffect(() => {
    toast({ title: "Failed", subtitle: "Something went wrong!", mode: "negative" });
    toast({ title: "Success", subtitle: "Hurray!", mode: "positive", timer: 8000 });
    toast({ title: "Info", subtitle: "Cool info!", timer: 12000 });
    toast({ title: "Infinite", subtitle: "I'll always be here!", infinite: true });
  }, []);
  return (
    <RadixTheme>
      {children}
      <Toaster />
    </RadixTheme>
  );
}

export default App;

aryankarim avatar Jun 12 '24 00:06 aryankarim

Radix toasts are intentionally designed to be declarative so that you can compose them with JSX instead of recreating JSX in a toast({}) object. you can render the Toast.Root anywhere in your app and it will portal into the Toast.Viewport:

const App = () => (
  <div>
    <Toast.Provider>
      <header>
        <Toast.Viewport />
      </header>
      <main>
        <Page />
      </main>
    </Toast.Provider>
  </div>
);

const Page = () => { 
  const [hasSuccessToast, setHasSuccessToast] = React.useState(false);
  return (
    <div>
      <button onClick={() => hasSuccessToast(true)}>submit</button>
      {/* this will portal into the <header /> */}
      <Toast open={hasSuccessToast} onOpenChange={setHasSuccessToast}>
        Created successfully
      </Toast>
    </div>
  );
}

const Toast = ({ title, children, ...props }) => (
  <Toast.Root {...props}>
    {title && <Toast.Title>{title}</Toast.Title>}
    <Toast.Description>{children}</Toast.Description>
  </Toast.Root>
);

if you'd like to be able to reopen the toast when it is already open or show duplicates of it, the docs have a section on creating your own imperative API while keeping the declarative JSX approach. the general expectation is something like:

const Page1 = () => { 
  const successToastRef = React.useRef(null);
  return (
    <div>
      <button onClick={() => successToastRef.current?.open()}>submit</button>
      <Toast ref={successToastRef}>Created successfully</Toast>
    </div>
  );
}

const Page2 = () => { 
  const copiedToClipboardToastRef = React.useRef(null);
  return (
    <div>
      <button onClick={() => copiedToClipboardToastRef.current?.open()}>copy text</button>
      <Toast ref={copiedToClipboardToastRef}>Copied to clipboard</Toast>
    </div>
  );
}

const Toast = React.forwardRef(({ title, children, ...props }, forwardedRef) => {
  const [open, setOpen] = React.useState(false);

  React.useImperativeHandle(forwardedRef, () => ({
    open: () => {
      setOpen(true);
      // you can do other stuff here now like wiggle it if it is already open
      // or display a cloned version if you want multiple toasts with the same content
    }
  }));

  return (
    <Toast.Root {...props} open={open} onOpenChange={setOpen}>
      {title && <Toast.Title>{title}</Toast.Title>}
      <Toast.Description>{children}</Toast.Description>
    </Toast.Root>
  );
});

jjenzz avatar Aug 19 '24 12:08 jjenzz

I don't think that's how regular devs use toast. Many of them just want a way to quickly post a quick notification (that can automatically dismiss itself) to the user if something happens (mainly after a fetch or some promise). To maintain a state for each of the possible toast is rather cumbersome.

JokerQyou avatar Dec 14 '24 09:12 JokerQyou

Just landed here with the same question. It's is quite the mind bender to figure out how to make a (simple) reusable API, though the imperativeRef approach works well enough (while also feeling quite unconventional).

damassi avatar Dec 16 '24 04:12 damassi

In general, I really like the Radix concept, but for this specific case it is actually a terrible solution.

hugo-cardoso avatar Feb 27 '25 23:02 hugo-cardoso

Documentation

Radix Toast documentation shows very weirdly how to build a single toast surrounding a button which opens it.

That's it. Nothing more. How I'm supposed to build an application-wide toasting system from this, I don't know.

The docs look extremely lacking in clarity, and I'm surprised no one has raised this concern yet.

Relevant Radix Component(s)

The Radix doc shows how to build a Toaster from its parts, that envelopes a button which changes a state variable, which then does the toasting. It seems like its purpose is to just generate that toast on that button. So, it's not portable. What if I want to open a toast from an error state after sending a request to an API? I don't know how to derive that from this documentation.

You could say, have a button that calls the API, and wrap this button with <Toast.Provider> but how does that make sense? I have to wrap every button that needs a toast with it? How about toasting for events that are not triggered by buttons? Like a WebRTC push mesage?

What's even weirder is that in order to create multiple toasts, I have to create an array of Toast components? 🤯

  • https://www.radix-ui.com/primitives/docs/components/toast

Examples from other doc sites

Please compare to these where it shows how to toast from anywhere, in any JavaScript. You plugin a Toaster then you just toast. The example is a button click, but I can very easily derive how to toast from an error state after sending an API request. Multiple toasts? Just call toast again and again. Avoid duplicates? Just add ID to your toasts. Want duplicates? Don't add ID to your toasts.

  • https://ui.shadcn.com/docs/components/toast (This is a Tailwind implementation of Radix UI, and a very complex one at that. I want a simple basic implementation that doesn't use Tailwind.)
  • https://react-hot-toast.com/docs (Familiar and easy to use.)
  • https://sonner.emilkowal.ski/getting-started (Pitched as the "best of both worlds" combining ShadCN and React Hot Toast.)

Is this simplicity even possible to replicate in Radix Toast? Or am I just too dumb? 😂

This is my implementation for a Snackbar, which I think is what you are looking for. i.e. a function that you can call from anywhere to show a toast (snackbar in my case). That being said, this viewport is driving me a little crazy, so maybe someone can help me out with that... first the code... (two notes - my implementation uses framer... I haven't finished styling the thing yet so you'll have to add that).

// provider.tsx
'use client';
import { AnimatePresence, motion } from 'framer-motion';
import { Toast as RadixToast } from 'radix-ui';
import { ComponentRef, ReactNode, createContext, forwardRef, useContext, useState } from 'react';
import { PiX } from 'react-icons/pi';
import { cn } from '~/utils/ui';

type ToastMessage = {
  id: string;
  text: string;
  type?: 'success' | 'error' | 'info' | 'warning';
};

const ToastContext = createContext<{
  showToast: (text: string, type?: ToastMessage['type']) => void;
}>({
  showToast: () => {
    throw new Error('showToast() cannot be called outside of a <ToastProvider />.');
  },
});

/**
 * Hook to trigger a toast
 * @example
 *
 * const { showToast } = useToast()
 *
 * <Button onClick={() => showToast('some toast message', 'success')} />
 */
export function useToast() {
  return useContext(ToastContext);
}

/**
 * This is a custom viewport for a 'snackbar' style message. When placed anywhere on a given page, the alert will animate into where the viewport is positioned.
 */
export function Snackbar() {
  return <RadixToast.Viewport name="toast" hotkey={['Tab']} label="Notifications Tab" />;
}

export function ToastProvider({ children }: { children: ReactNode }) {
  const [currentToast, setCurrentToast] = useState<ToastMessage | null>(null);

  function showToast(text: string, type: ToastMessage['type'] = 'info') {
    const newToast = {
      id: window.crypto.randomUUID(),
      text,
      type,
    };

    setCurrentToast(newToast);
  }

  return (
    <RadixToast.Provider swipeDirection="right">
      <ToastContext.Provider value={{ showToast }}>{children}</ToastContext.Provider>

      <AnimatePresence mode="wait">
        {currentToast && (
          <Toast
            key={currentToast.id}
            text={currentToast.text}
            type={currentToast.type}
            onClose={() => setCurrentToast(null)}
          />
        )}
      </AnimatePresence>
    </RadixToast.Provider>
  );
}

const Toast = forwardRef<
  ComponentRef<typeof RadixToast.Root>,
  {
    onClose: () => void;
    text: string;
    type?: ToastMessage['type'];
  }
>(function Toast({ onClose, text, type = 'info' }, forwardedRef) {
  const typeStyles = {
    success: 'border-green-500 bg-green-900/50',
    error: 'border-red-500 bg-red-900/50',
    warning: 'border-yellow-500 bg-yellow-900/50',
    info: 'border-blue-500 bg-blue-900/50',
  };

  return (
    <RadixToast.Root
      ref={forwardedRef}
      asChild
      forceMount
      onOpenChange={onClose}
      duration={type === 'error' ? 5000 : 2500}
      className="w-full"
    >
      <motion.div
        layout
        initial={{
          opacity: 1,
        }}
        animate={{ x: 0 }}
        exit={{
          opacity: 0,
          zIndex: -1,
          transition: {
            opacity: {
              duration: 0.2,
            },
          },
        }}
        transition={{
          type: 'spring',
          mass: 1,
          damping: 30,
          stiffness: 200,
        }}
      >
        <div
          className={cn(
            'flex w-full justify-between overflow-hidden whitespace-nowrap rounded-lg border text-sm text-white shadow-sm backdrop-blur',
            typeStyles[type]
          )}
        >
          <RadixToast.Description className="truncate p-4">{text}</RadixToast.Description>
          <RadixToast.Close className="border-l border-gray-600/50 p-4 text-gray-500 transition hover:bg-gray-600/30 hover:text-gray-300 active:text-white">
            <PiX className="h-5 w-5" />
          </RadixToast.Close>
        </div>
      </motion.div>
    </RadixToast.Root>
  );
});

Now, my issue... @jjenzz maybe you can help?

My Issue

This implementation is very nearly there for me, but I'm struggling to find a way to target the Viewport component in order to set it as full width (w-full) of it's parent, i.e. where I am mounting my snackbar.

Within the docs for the viewport I see that we are able to assign asChild, however the behaviour here is somewhat unexpected. Where I expect the viewport to become a child, that isn't what happens... instead, some strange merging of props is happening where the result is an <ol/>. The impact of this is that I am never able to target that viewport element itself.

What did I try

  • Adding className="w-full ..." to the viewport element - merges classes to the child
  • Adding style={{ width: '100%' }} to the viewport element - same outcome - merges style to the child
  • Adding itemID="snackbar-viewport" to the viewport element - same outcome 😫
  • Adding name="toast" to the viewport element, then attempting to target that element in the global.css - again, merges style to the child

Consider this code (also in blob above);

export function Snackbar() {
  return <RadixToast.Viewport style={{ width: '100%' }} name="toast" hotkey={['Tab']} label="Notifications Tab" />;
}

This is how it appears in the UI - notice how the label stays with the viewport (expected) but the width and name go to the child. This behaviour makes it very difficult to make the viewport full width of its parent.

Image{width=70%}

This is how I want it - notice that I've manually added width full within the browser.

Image{width=70%}

This worked but it's an anti-pattern

In keeping with my above attempts to make this go, I added the below to my global.css. This indeed stays with the viewport in the expected way, allowing me to make it appear how I need, however I hate that I have to do this and expect issues as aria-label's value serves a purpose and will in many cases be translated, meaning the value is almost certainly going to be different at runtime. Please help.

[aria-label="Notifications Tab"] {
  @apply w-full;
}

Edit

I decided to have a look at the source for this and think I can see the crux of the problem. It should be sufficient to add a viewportID prop to ToastViewport, passing that id on to DismissableLayer.Branch, i.e. viewport-id={viewportID}... The viewport-id can then be used to target the viewports div element and apply some styles.

If this sounds like a reasonable solution, I can open a PR 🙂

akin-fagbohun avatar May 03 '25 19:05 akin-fagbohun