Skip to content

Early previewhamur is not on npm yet. The components are being built in the open, follow along on GitHub.

FeedbackReact

Toast

Generates toast notifications.

Rendered by React 19.3.1

import * as React from 'react'
import { Toast } from '@hamur/react/toast'

export default function ToastExample() {
  return (
    <Toast.Provider>
      <ToastButton />
      <Toast.Portal>
        <Toast.Viewport className="toast-viewport">
          <ToastList />
        </Toast.Viewport>
      </Toast.Portal>
    </Toast.Provider>
  )
}

function ToastButton() {
  const toastManager = Toast.useToastManager()
  const count = React.useRef(0)
  return (
    <button
      type="button"
      className="button"
      onClick={() => {
        count.current += 1
        toastManager.add({
          title: `Draft ${count.current} saved`,
          description: 'You can find it in your drafts.',
        })
      }}
    >
      Save draft
    </button>
  )
}

function ToastList() {
  const { toasts } = Toast.useToastManager()
  return toasts.map((toast) => (
    <Toast.Root key={toast.id} toast={toast} className="toast">
      <Toast.Content className="toast-content">
        <Toast.Title className="toast-title" />
        <Toast.Description className="muted" />
        <Toast.Close className="toast-close" aria-label="Dismiss">
          Dismiss
        </Toast.Close>
      </Toast.Content>
    </Toast.Root>
  ))
}
672Tests passing in React · 0 failing
32.2 KiBSize
Not measured yetMemory
Not measured yetSpeed
productdevbook and 21 upstream authors8 commits · last

Installation

npm i @hamur/react

Not on npm yet. The package goes out under the chosen name.

Anatomy

Import the component and assemble its parts:

<Toast.Provider>
  <Toast.Portal>
    <Toast.Viewport />
  </Toast.Portal>
</Toast.Provider>
<Toast.Root>
  <Toast.Content>
    <Toast.Title />
    <Toast.Description />
    <Toast.Close />
  </Toast.Content>
</Toast.Root>

// Not in this example: Toast.Action, Toast.Arrow, Toast.Positioner

Examples

Anchored

Positioner and Arrow place a toast next to the button that made it, in its own Provider.

ToastAnchored.tsx
import * as React from 'react'
import { Toast } from '@hamur/react/toast'
import { Button } from '@hamur/react/button'

const anchoredToastManager = Toast.createToastManager()

export default function ToastAnchoredExample() {
  return (
    <Toast.Provider toastManager={anchoredToastManager}>
      <AnchoredToasts />
      <CopyButton />
    </Toast.Provider>
  )
}

function CopyButton() {
  const [copied, setCopied] = React.useState(false)
  const buttonRef = React.useRef<HTMLButtonElement | null>(null)

  function handleCopy() {
    setCopied(true)

    anchoredToastManager.add({
      description: 'Copied',
      positionerProps: {
        anchor: buttonRef.current,
        sideOffset: 10,
      },
      timeout: 1500,
      onClose() {
        setCopied(false)
      },
    })
  }

  return (
    <Button
      ref={buttonRef}
      className="icon-square"
      onClick={handleCopy}
      aria-label="Copy to clipboard"
      disabled={copied}
      focusableWhenDisabled
    >
      {copied ? <CheckIcon /> : <ClipboardIcon />}
    </Button>
  )
}

function AnchoredToasts() {
  const { toasts } = Toast.useToastManager()
  return (
    <Toast.Portal>
      <Toast.Viewport className="toast-anchored-viewport">
        {toasts.map((toast) => (
          <Toast.Positioner key={toast.id} toast={toast} className="toast-anchored-positioner">
            <Toast.Root toast={toast} className="toast-anchored">
              <Toast.Content>
                <Toast.Description />
              </Toast.Content>
            </Toast.Root>
          </Toast.Positioner>
        ))}
      </Toast.Viewport>
    </Toast.Portal>
  )
}

function ClipboardIcon() {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.5"
      style={{ display: 'block' }}
    >
      <rect width="8" height="4" x="8" y="2" rx="1" ry="1" />
      <path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
    </svg>
  )
}

function CheckIcon() {
  return (
    <svg
      width="16"
      height="16"
      viewBox="0 0 16 16"
      fill="none"
      stroke="currentColor"
      style={{ display: 'block' }}
    >
      <path d="m2.5 8.5 4 4 7-9" />
    </svg>
  )
}

Position

The Viewport and Root styles move the stack to the top center.

ToastPosition.tsx
import * as React from 'react'
import { Toast } from '@hamur/react/toast'

export default function ToastPositionExample() {
  return (
    <Toast.Provider>
      <ToastButton />
      <Toast.Portal>
        <Toast.Viewport className="toast-stack-viewport top">
          <ToastList />
        </Toast.Viewport>
      </Toast.Portal>
    </Toast.Provider>
  )
}

function ToastButton() {
  const toastManager = Toast.useToastManager()
  const countRef = React.useRef(0)

  function createToast() {
    countRef.current += 1
    toastManager.add({
      title: `Toast ${countRef.current} created`,
      description: 'This is a toast notification.',
    })
  }

  return (
    <button type="button" className="button" onClick={createToast}>
      Create toast
    </button>
  )
}

function ToastList() {
  const { toasts } = Toast.useToastManager()
  return toasts.map((toast) => (
    <Toast.Root key={toast.id} toast={toast} swipeDirection="up" className="toast-stack top">
      <Toast.Content className="toast-stack-content">
        <div className="toast-stack-text">
          <Toast.Title className="toast-stack-title" />
          <Toast.Description className="toast-stack-description" />
        </div>
        <Toast.Close className="toast-stack-button">Dismiss</Toast.Close>
      </Toast.Content>
    </Toast.Root>
  ))
}

Undo

An Action button inside the toast undoes what was done.

ToastUndo.tsx
import { Toast } from '@hamur/react/toast'

export default function ToastUndoExample() {
  return (
    <Toast.Provider>
      <Form />
      <Toast.Portal>
        <Toast.Viewport className="toast-stack-viewport">
          <ToastList />
        </Toast.Viewport>
      </Toast.Portal>
    </Toast.Provider>
  )
}

function Form() {
  const toastManager = Toast.useToastManager()

  function action() {
    const id = toastManager.add({
      title: 'Action performed',
      description: 'You can undo this action.',
      type: 'success',
      timeout: 10000,
      actionProps: {
        children: 'Undo',
        onClick() {
          toastManager.close(id)
          toastManager.add({
            title: 'Action undone',
          })
        },
      },
    })
  }

  return (
    <button type="button" className="button" onClick={action}>
      Perform action
    </button>
  )
}

function ToastList() {
  const { toasts } = Toast.useToastManager()
  return toasts.map((toast) => (
    <Toast.Root key={toast.id} toast={toast} className="toast-stack">
      <Toast.Content className="toast-stack-content">
        <div className="toast-stack-text with-action">
          <div className="toast-stack-message">
            <Toast.Title className="toast-stack-title" />
            <Toast.Description className="toast-stack-description" />
          </div>
          <Toast.Action className="toast-stack-button" />
        </div>
      </Toast.Content>
    </Toast.Root>
  ))
}

Promise

One toast moves from loading to success or error, styled by data-type.

ToastPromise.tsx
import { Toast } from '@hamur/react/toast'

export default function ToastPromiseExample() {
  return (
    <Toast.Provider>
      <PromiseDemo />
      <Toast.Portal>
        <Toast.Viewport className="toast-stack-viewport">
          <ToastList />
        </Toast.Viewport>
      </Toast.Portal>
    </Toast.Provider>
  )
}

function PromiseDemo() {
  const toastManager = Toast.useToastManager()

  function runPromise() {
    toastManager
      .promise(
        // Simulate an API request with a promise that resolves after 2 seconds
        new Promise<string>((resolve, reject) => {
          const shouldSucceed = Math.random() > 0.3 // 70% success rate
          setTimeout(() => {
            if (shouldSucceed) {
              resolve('operation completed')
            } else {
              reject(new Error('operation failed'))
            }
          }, 2000)
        }),
        {
          loading: 'Loading data…',
          success: (data: string) => `Success: ${data}`,
          error: (err: Error) => `Error: ${err.message}`,
        },
      )
      // The returned promise rejects with the error, which the toast already shows.
      .catch(() => {})
  }

  return (
    <button type="button" className="button" onClick={runPromise}>
      Run promise
    </button>
  )
}

function ToastList() {
  const { toasts } = Toast.useToastManager()
  return toasts.map((toast) => (
    <Toast.Root key={toast.id} toast={toast} className="toast-stack">
      <Toast.Content className="toast-stack-content">
        <div className="toast-stack-text">
          <Toast.Title className="toast-stack-title" />
          <Toast.Description className="toast-stack-description" />
        </div>
        <Toast.Close className="toast-stack-button">Dismiss</Toast.Close>
      </Toast.Content>
    </Toast.Root>
  ))
}

Custom

A toast carries custom data that its rendering reads.

ToastCustom.tsx
import { Toast } from '@hamur/react/toast'

interface CustomToastData {
  userId: string
}

function isCustomToast(
  toast: Toast.Root.ToastObject,
): toast is Toast.Root.ToastObject<CustomToastData> {
  return toast.data?.userId !== undefined
}

export default function ToastCustomExample() {
  return (
    <Toast.Provider>
      <CustomToast />
      <Toast.Portal>
        <Toast.Viewport className="toast-stack-viewport">
          <ToastList />
        </Toast.Viewport>
      </Toast.Portal>
    </Toast.Provider>
  )
}

function CustomToast() {
  const toastManager = Toast.useToastManager()

  function action() {
    const data: CustomToastData = {
      userId: '123',
    }

    toastManager.add({
      title: 'Toast with custom data',
      data,
    })
  }

  return (
    <button type="button" className="button" onClick={action}>
      Create custom toast
    </button>
  )
}

function ToastList() {
  const { toasts } = Toast.useToastManager()
  return toasts.map((toast) => (
    <Toast.Root key={toast.id} toast={toast} className="toast-stack">
      <Toast.Content className="toast-stack-content">
        <div className="toast-stack-text">
          <Toast.Title className="toast-stack-title">{toast.title}</Toast.Title>
          {isCustomToast(toast) && toast.data ? (
            <Toast.Description className="toast-stack-description">
              data.userId is {toast.data.userId}
            </Toast.Description>
          ) : (
            <Toast.Description className="toast-stack-description" />
          )}
        </div>
        <Toast.Close className="toast-stack-button">Dismiss</Toast.Close>
      </Toast.Content>
    </Toast.Root>
  ))
}

Deduplicate

Adding the same toast again updates it in place and replays a pulse.

ToastDeduplicate.tsx
import { Toast } from '@hamur/react/toast'

export default function ToastDeduplicateExample() {
  return (
    <Toast.Provider>
      <PulseToastButton />
      <Toast.Portal>
        <Toast.Viewport className="toast-stack-viewport">
          <ToastList />
        </Toast.Viewport>
      </Toast.Portal>
    </Toast.Provider>
  )
}

function PulseToastButton() {
  const toastManager = Toast.useToastManager()

  function createToast() {
    toastManager.add({
      id: 'save-status',
      title: 'Draft saved',
      description: 'Click again while it is visible to replay the pulse.',
    })
  }

  return (
    <button type="button" className="button" onClick={createToast}>
      Save draft
    </button>
  )
}

function ToastList() {
  const { toasts } = Toast.useToastManager()
  return toasts.map((toast) => <PulseToastItem key={toast.id} toast={toast} />)
}

function PulseToastItem({ toast }: { toast: Toast.Root.ToastObject }) {
  let pulseClassName: string | null = null

  // New toasts start with `updateKey: 0`, so the first add skips the replay pulse.
  if (toast.updateKey) {
    pulseClassName = toast.updateKey % 2 === 0 ? 'pulse-even' : 'pulse-odd'
  }

  const className = ['toast-stack', pulseClassName].filter(Boolean).join(' ')

  return (
    <Toast.Root toast={toast} className={className}>
      <Toast.Content className="toast-stack-content">
        <div className="toast-stack-text">
          <Toast.Title className="toast-stack-title" />
          <Toast.Description className="toast-stack-description" />
        </div>
        <Toast.Close className="toast-stack-button">Dismiss</Toast.Close>
      </Toast.Content>
    </Toast.Root>
  )
}

Varying Heights

--toast-frontmost-height clamps the stack to the front toast, and data-behind hides the content behind it.

ToastVaryingHeights.tsx
import * as React from 'react'
import { Toast } from '@hamur/react/toast'

export default function ToastVaryingHeightsExample() {
  return (
    <Toast.Provider>
      <ToastButton />
      <Toast.Portal>
        <Toast.Viewport className="toast-stack-viewport">
          <ToastList />
        </Toast.Viewport>
      </Toast.Portal>
    </Toast.Provider>
  )
}

function ToastButton() {
  const toastManager = Toast.useToastManager()
  const countRef = React.useRef(0)

  function createToast() {
    countRef.current += 1
    const description = TEXTS[Math.floor(Math.random() * TEXTS.length)]
    toastManager.add({
      title: `Toast ${countRef.current} created`,
      description,
    })
  }

  return (
    <button type="button" className="button" onClick={createToast}>
      Create varying height toast
    </button>
  )
}

function ToastList() {
  const { toasts } = Toast.useToastManager()
  return toasts.map((toast) => (
    <Toast.Root key={toast.id} toast={toast} className="toast-stack">
      <Toast.Content className="toast-stack-content">
        <div className="toast-stack-text">
          <Toast.Title className="toast-stack-title" />
          <Toast.Description className="toast-stack-description" />
        </div>
        <Toast.Close className="toast-stack-button">Dismiss</Toast.Close>
      </Toast.Content>
    </Toast.Root>
  ))
}

const TEXTS = [
  'Short message.',
  'A bit longer message that spans two lines.',
  'This is a longer description that intentionally takes more vertical space to demonstrate stacking with varying heights.',
  'An even longer description that should span multiple lines so we can verify the clamped collapsed height and smooth expansion animation when hovering or focusing the viewport.',
]

API reference

22 props across 11 parts, in React syntax. className, style, render are listed once, under Shared props.

ActionToast.Action

Performs an action when clicked. Renders a <button>.

Action props
PropTypeDefault
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
Action data attributes
Data attributeDescription
data-typeThe type of the toast.

ArrowToast.Arrow

Displays an element positioned against the toast anchor. Renders a <div>.

Arrow data attributes
Data attributeDescription
data-sideIndicates which side the toast is positioned relative to the anchor.
data-alignIndicates how the toast is aligned relative to specified side.
data-uncenteredPresent when the toast arrow is uncentered.

CloseToast.Close

Closes the toast when clicked. Renders a <button>.

Close props
PropTypeDefault
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
Close data attributes
Data attributeDescription
data-typeThe type of the toast.

ContentToast.Content

A container for the contents of a toast. Renders a <div>.

Content data attributes
Data attributeDescription
data-expandedPresent when the toast viewport is expanded.
data-behindPresent when the toast is behind the frontmost toast in the stack.

DescriptionToast.Description

A description that describes the toast. Can be used as the default message for the toast when no title is provided. Renders a <p>.

Description data attributes
Data attributeDescription
data-typeThe type of the toast.

PortalToast.Portal

A portal element that moves the viewport to a different part of the DOM. By default, the portal element is appended to <body>. Renders a <div>.

Portal props
PropTypeDefault
containerA parent element to render the portal element into.HTMLElement | ShadowRoot | React.RefObject<HTMLElement | ShadowRoot | null> | null—

PositionerToast.Positioner

Positions the toast against the anchor. Renders a <div>.

Positioner props
PropTypeDefault
disableAnchorTrackingWhether to disable the popup from tracking any layout shift of its positioning anchor.booleanfalse
toastrequiredThe toast object associated with the positioner.ToastObject<any>—
alignHow to align the popup relative to the specified side.Align'center'
alignOffsetAdditional offset along the alignment axis in pixels.number | OffsetFunction0
sideWhich side of the anchor element to align the toast against.Side'top'
sideOffsetDistance between the anchor and the popup in pixels.number | OffsetFunction0
arrowPaddingMinimum distance to maintain between the arrow and the edges of the popup.number5
anchorAn element to position the toast against.Element | null—
collisionAvoidanceDetermines how to handle collisions when positioning the popup.CollisionAvoidance—
collisionBoundaryAn element or a rectangle that delimits the area that the popup is confined to.Boundary'clipping-ancestors'
collisionPaddingAdditional space to maintain from the edge of the collision boundary.Padding5
stickyWhether to maintain the popup in the viewport afterbooleanfalse
positionMethodDetermines which CSS position property to use."absolute" | "fixed"'absolute'
Positioner data attributes
Data attributeDescription
data-anchor-hiddenPresent when the anchor is hidden.
data-sideIndicates which side the toast is positioned relative to the trigger.
data-alignIndicates how the toast is aligned relative to specified side.
Positioner CSS variables
CSS variableDescription
--available-widthThe available width between the anchor and the edge of the viewport.
--available-heightThe available height between the anchor and the edge of the viewport.
--anchor-widthThe anchor's width.
--anchor-heightThe anchor's height.
--transform-originThe coordinates that this element is anchored to. Used for animations and transitions.

ProviderToast.Provider

Provides a context for creating and managing toasts.

Provider props
PropTypeDefault
limitThe maximum number of toasts that can be displayed at once.number3
toastManagerA global manager for toasts to use outside of a React component.ToastManager<any>—
timeoutThe default amount of time (in ms) before a toast is auto dismissed.number5000
childrenReact.ReactNode—

RootToast.Root

Groups all parts of an individual toast. Renders a <div>.

Root props
PropTypeDefault
swipeDirectionDirection(s) in which the toast can be swiped to dismiss."left" | "right" | "up" | "down" | ("left" | "right" | "up" | "down")[]['down', 'right']
toastrequiredThe toast to render.ToastRootToastObject<any>—
Root data attributes
Data attributeDescription
data-expandedPresent when the toast is expanded in the viewport.
data-limitedPresent when the toast was limited because the toast limit was exceeded.
data-typeThe type of the toast.
data-swipingPresent when the toast is being swiped.
data-swipe-directionThe direction the toast was swiped.
data-starting-stylePresent when the toast begins animating in.
data-ending-stylePresent when the toast is animating out.
Root CSS variables
CSS variableDescription
--toast-indexIndicates the index of the toast in the list.
--toast-offset-yIndicates the vertical pixels offset of the toast in the list when expanded.
--toast-heightIndicates the measured natural height of the toast in pixels.
--toast-swipe-movement-xIndicates the horizontal swipe movement of the toast.
--toast-swipe-movement-yIndicates the vertical swipe movement of the toast.

TitleToast.Title

A title that labels the toast. Renders a <h2>.

Title data attributes
Data attributeDescription
data-typeThe type of the toast.

ViewportToast.Viewport

A container viewport for toasts. Renders a <div>.

Viewport data attributes
Data attributeDescription
data-expandedIndicates toasts are expanded in the viewport.
Viewport CSS variables
CSS variableDescription
--toast-frontmost-heightIndicates the height of the frontmost toast.

Shared props

Every part that renders an element takes these.

Shared props
PropType
classNameCSS class applied to the element, or a function that returns a class based on the component's state.string | ((state: State) => string | undefined)
styleStyle applied to the element, or a function that returns a style object based on the component's state.React.CSSProperties | ((state: State) => React.CSSProperties | undefined)
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, State>

Keyboard and accessibility

Keyboard

Keyboard
KeysDoesPart
F6Moves focus to the viewportViewport
TabFrom the focused viewport, moves to the first toastViewport
ShiftTabFrom the first toast, returns focus to where it was beforeViewport
F6From inside the viewport, returns focus to where it was beforeViewport
EscapeCloses the focused toastRoot

Accessibility

What the parts set
PartSetsSo that
Viewportrole="region", aria-label="Notifications"screen reader users can find the toasts as a landmark
Viewportaria-live="polite", aria-relevant="additions text", aria-atomic="false"a new toast is read when the reader is idle, without rereading the others
Viewporta visually hidden role="alert" copy of each high-priority toast while focus is outsideurgent toasts are announced at once
Rootrole="dialog" (role="alertdialog" for a high-priority toast), aria-modal="false"a focused toast is read as a small dialog that does not block the page
Rootaria-labelledby to Title, aria-describedby to Descriptionthe toast is announced with its heading and message
Closearia-hidden while the stack is collapsed and not focusedhidden close buttons are not read
Arrowaria-hidden="true"the decorative arrow is not read

Focus order

  1. Toasts never take focus on their own; the page keeps it.
  2. F6 moves focus to Viewport, and Tab moves on to the first toast and through its buttons.
  3. Shift+Tab from the first toast, or F6 again, returns focus to where it was.