React: Simple Yet Elegant Toast Notifications

Jay @ Designly - Sep 22 - - Dev Community

Bundlephobia is really starting to catch on these days as developers become more conscious of the packages they choose to install. Some devs, in the interest in fast turnaround, choose to willy-nilly install the first package they find that'll get the job done. However, this can very quickly lead to severe bloat and tons of unnecessary code being shipped to the browser.

That is why the main theme of my blog has taken a real do-it-yourself approach to solving many user interface problems.

In today's article, we are going to learn how to create simple (but also really cool!) toaster notifications in React, using zero dependencies!

Notification Examples

The examples in this article are taken directly from my current project react-poptart, an NPM package I published. These examples illustrate how you can architect your own notification system, but some parts of the code are missing (e.g. types, helper functions, etc.) For the full repo code, please visit the link at the bottom of the page. You are also more than welcome to simply install react-poptart in your app.

Using the React Context API

We want to turn our notification system into a global service for our app, so we're going to leverage the power of the React Context API. Doing so will allow us to make our notifications available to any child component.

// Provider.tsx
'use client';

import React, { createContext, useContext, useState } from 'react';
import Matrix from './Matrix';

import { getContrastColor, generateSecureString, deepMerge } from './helpers';

import { defaultConfig } from './constants';

import type {
    I_PoptartProviderProps,
    I_PoptartContext,
    I_PoptartItem,
    I_PoptartProps,
    I_PoptartConfig,
    I_PoptartUserConfig,
} from './types';

// Create the Poptart context
const PoptartContext = createContext<I_PoptartContext | undefined>(undefined);

export const PoptartProvider: React.FC<I_PoptartProviderProps> = ({ children, config: userConfig }) => {
    // Merge the default options with the user options
    const config = deepMerge<I_PoptartConfig, I_PoptartUserConfig>(defaultConfig, userConfig || {});

    // State
    const [poptarts, setPoptarts] = useState<I_PoptartItem[]>([]);

    // Methods
    const push = (props: I_PoptartProps): string => {
        const id = generateSecureString(64);

        const foregroundColor = getContrastColor({
            backgroundColor: config.colors[props.type || config.defaultType],
            lightColor: config.colors.textLight,
            darkColor: config.colors.textDark,
        });

        let duration = config.defaultDuration;
        if (props.duration !== undefined) {
            duration = props.duration;
        }

        setPoptarts(prev => [
            ...prev,
            {
                id,
                props,
                expires: new Date(Date.now() + duration),
                progress: duration > 0 ? 100 : 0,
                foregroundColor,
            },
        ]);

        if (duration > 0) {
            setTimeout(() => {
                dismiss(id);
            }, duration);
        }

        return id;
    };

    // Dismiss a poptart
    const dismiss = (id: string) => {
        setPoptarts(prev => prev.filter(poptart => poptart.id !== id));
    };

    return (
        <PoptartContext.Provider
            value={{
                poptarts,
                config,
                currentAlert,
                push,
                dismiss,
            }}
        >
            {children}
            <Matrix config={config} poptarts={poptarts} dismiss={dismiss} />
        </PoptartContext.Provider>
    );
};

export const usePoptart = (): I_PoptartContext => {
    const context = useContext(PoptartContext);
    if (!context) {
        throw new Error('usePoptart must be used within a PoptartProvider');
    }
    return context;
};
Enter fullscreen mode Exit fullscreen mode

Here's how it works:

  1. We establish a global state to store our notifications poptarts
  2. We create a push method that appends a new poptart object to the stack.
  3. We use a helper function to determine the correct contrasting color for the text based on the background color (this is makes contrast automatic!)
  4. We set a timer to dismiss the poptart if a duration is set.
  5. Each poptart is given a unique ID
  6. We create a dismiss method to be able to programmatically dismiss poptart by their ID.

Building the Components

We're going to need a component to display our poptarts. We're going to call it Matrix and it will be imported directly into the Provider context.

import React from 'react';
import Poptart from './Poptart';

import { useAnimations } from './animations';

import { I_PoptartConfig, I_PoptartItem } from './types';

interface Props {
    config: I_PoptartConfig;
    poptarts: I_PoptartItem[];
    dismiss: (id: string) => void;
}

const Matrix: React.FC<Props> = props => {
    const { config, poptarts, dismiss } = props;

    // Inject the animation keyframes into the DOM
    useAnimations();

    const styleAdditions: React.CSSProperties = {};

    // Align the matrix based on config
    switch (config.defaultAlign) {
        case 'tl':
            styleAdditions.justifyContent = 'flex-start';
            styleAdditions.alignItems = 'flex-start';
            break;
        case 'tc':
            styleAdditions.justifyContent = 'flex-start';
            styleAdditions.alignItems = 'center';
            break;
        case 'tr':
            styleAdditions.justifyContent = 'flex-start';
            styleAdditions.alignItems = 'flex-end';
            break;
        case 'bl':
            styleAdditions.justifyContent = 'flex-end';
            styleAdditions.alignItems = 'flex-start';
            break;
        case 'bc':
            styleAdditions.justifyContent = 'flex-end';
            styleAdditions.alignItems = 'center';
            break;
        case 'br':
            styleAdditions.justifyContent = 'flex-end';
            styleAdditions.alignItems = 'flex-end';
            break;
        default:
            styleAdditions.justifyContent = 'flex-end';
            styleAdditions.alignItems = 'flex-end';
            break;
    }

    // Main container styles
    const containerStyles: React.CSSProperties = {
        position: 'fixed',
        inset: 0,
        pointerEvents: 'none',
        display: 'flex',
        flexDirection: 'column',
        gap: '20px',
        padding: '20px',
        zIndex: config.zIndex + 2,
        ...styleAdditions,
        ...config.styleOverrides.container,
    };

    return (
        <div className="poptart-container" style={containerStyles}>
            {poptarts.map((poptart) => (
                <Poptart key={poptart.id} dismiss={dismiss} config={config} {...poptart} />
            ))}
        </div>
    );
};

export default Matrix;
Enter fullscreen mode Exit fullscreen mode

Here, we've created a <div> container that covers the whole screen. We allow the user to supply the base zIndex and then we add to it to create our layers. We use pointerEvents: 'none' to prevent the <div> from blocking click events to the rest of the app below.

The switch statement allows us to use a simple 2-letter code to set the spawn location of our notifications. This is all fully typed, too, so we'll get nice auto-completion.

The useAnimations hook simply injects our animation CSS keyframes directly into the DOM. For sake of brevity, that code is not included here, but feel free to peruse the repo (link at bottom).

Next, we import our <Poptart> component. This is the actual notification that will be displayed.

// Poptart.tsx

import React from 'react';
import Icon from './Icon';
import ProgressBar from './ProgressBar';

import type { I_PoptartItem, I_PoptartConfig } from './types';

interface Props extends I_PoptartItem {
    config: I_PoptartConfig;
    dismiss: (id: string) => void;
}

const Poptart: React.FC<Props> = superProps => {
    const { props, id, foregroundColor, config, dismiss } = superProps;
    const { message, onClick } = props;

    const type = props.type || config.defaultType;
    const backgroundColor = config.colors[type];
    const width = props.width || config.defaultWidth;
    const fontSize = config.fontSize;
    const paddingX = config.paddingX;
    const paddingY = config.paddingY;
    const progressBarHeight = config.progressBar.height;
    const duration = props.duration !== undefined ? props.duration : config.defaultDuration;
    const hasDuration = duration > 0;
    const animation = props.animation || config.defaultAnimation;
    const animationDuration = props.animationDuration || config.defaultAnimationDuration;

    const defaultPoptartStyle = config.defaultPoptartStyle === 'default' ? 'filled' : config.defaultPoptartStyle;
    let poptartStyle = defaultPoptartStyle;
    if (props.poptartStyle) {
        poptartStyle = props.poptartStyle === 'default' ? defaultPoptartStyle : props.poptartStyle;
    }
    const isInverted = poptartStyle === 'inverted';

    // Dynamic styles
    const dynamicStyles: React.CSSProperties = {
        backgroundColor: isInverted ? config.colors.invertedBackground : backgroundColor,
        color: isInverted ? backgroundColor : foregroundColor,
        width,
        maxWidth: '100%',
        paddingTop: `${paddingY}px`,
        paddingLeft: `${paddingX}px`,
        paddingRight: `${paddingX}px`,
        paddingBottom: `${paddingY + (hasDuration ? progressBarHeight : 0)}px`,
        borderRadius: '10px',
        overflow: 'hidden',
        cursor: 'pointer',
        position: 'relative',
        pointerEvents: 'auto',
        fontSize,
        boxShadow: '0 0 10px rgba(0, 0, 0, 0.2)',
        border: '1px solid rgba(0, 0, 0, 0.1)',
        animation: `${animation} ${animationDuration}s ease-out`,
        ...config.styleOverrides.poptart,
    };

    const innerStyle: React.CSSProperties = {
        display: 'flex',
        alignItems: 'center',
        gap: '10px',
    };

    const textStyle: React.CSSProperties = {
        wordBreak: 'break-word',
        hyphens: 'auto',
        overflowWrap: 'break-word',
        textShadow: isInverted ? `2px 2px 2px rgba(0, 0, 0, 0.1)` : `2px 2px 2px rgba(0, 0, 0, 0.3)`,
    };

    const handleClick = () => {
        dismiss(id);
        onClick && onClick();
    };

    const iconSize = fontSize * config.iconSizeFactor;

    return (
        <div className="poptart" style={dynamicStyles} onClick={handleClick}>
            <div className="poptart-inner" style={innerStyle}>
                <div className="poptart-icon" style={{ width: iconSize }}>
                    <Icon type={type} color={isInverted ? backgroundColor : foregroundColor} size={iconSize} />
                </div>
                <span className="poptart-message" style={textStyle}>
                    {message}
                </span>
            </div>
            {hasDuration ? (
                <ProgressBar
                    poptart={props}
                    height={config.progressBar.height}
                    backgroundColor={backgroundColor}
                    colorOverride={isInverted ? backgroundColor : undefined}
                    config={config}
                />
            ) : null}
        </div>
    );
};

export default Poptart;
Enter fullscreen mode Exit fullscreen mode

Here, we create our base styles and then calculate user defined vs default config styles. We also allow all styles to be overridden.

Next, we create a simple <ProgressBar> component to display a cool countdown timer at the bottom:

import React, { useState, useEffect } from 'react';

import { getContrastColor } from './helpers';

import { I_PoptartConfig, I_PoptartProps } from './types';

interface ProgressBarProps {
    poptart: I_PoptartProps;
    height: number;
    backgroundColor: string;
    config: I_PoptartConfig;
    colorOverride?: string;
}

const ProgressBar: React.FC<ProgressBarProps> = ({ poptart, height, backgroundColor, config, colorOverride }) => {
    const color =
        colorOverride ??
        getContrastColor({
            backgroundColor,
            lightColor: config.progressBar.lightColor,
            darkColor: config.progressBar.darkColor,
        });

    const [width, setWidth] = useState(100);

    const duration = poptart.duration || config.defaultDuration;

    useEffect(() => {
        setTimeout(() => {
            setWidth(0);
        }, 100);
    }, []);

    const dynamicStyles: React.CSSProperties = {
        position: 'absolute',
        bottom: 0,
        left: 0,
        height: `${height}px`,
        backgroundColor: color,
        width: `${width}%`,
        transition: `width ${duration}ms linear`,
        ...config.styleOverrides.progressBar,
    };

    return <div className="poptart-progress" style={dynamicStyles} />;
};

export default ProgressBar;
Enter fullscreen mode Exit fullscreen mode

The trick here is we set the CSS transition duration value via our duration variable. Easy huh?

There are a few more pieces to the puzzle to build your own notification library, but this is the basics of how to get started. Please feel free to browse through the entire repo.

  1. GitHub Repo
  2. React-poptart Website

Thank you for taking the time to read my article and I hope you found it useful (or at the very least, mildly entertaining). For more great information about web dev, systems administration and cloud computing, please read the Designly Blog. Also, please leave your comments! I love to hear thoughts from my readers.

If you want to support me, please follow me on Spotify!

Also, be sure to check out my new app called Snoozle! It's an app that generates bedtime stories for kids using AI and it's completely free to use!

Looking for a web developer? I'm available for hire! To inquire, please fill out a contact form.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player