Skip to main content
UX Design Engineering
Theme
Text size
100%
Typeface

Chapter 9

Overlays and feedback

This chapter builds Dialog, Tooltip, Alert, Badge, and Card.

Your changes were saved. Beta Delete this project? This can’t be undone. Cancel Delete Removes every file
Dialog, Tooltip, Alert, and Badge: what sits above the page, and what reports back.

Overlays and feedback components complete the interactive layer. This chapter builds Dialog, Tooltip, Alert, Badge, and Card: modal interactions, informational overlays, status feedback, and content containers.

Dialog and Tooltip lean on React Aria. Alert, Badge, and Card do not, and the shift is worth watching for. None of them manages focus or interactive state, so a React Aria hook would add a dependency and buy nothing. Reaching for the accessibility layer by reflex is a habit worth breaking.

Dialog

Dialog: focus cycles inside the panel, and everything behind it goes inert.

The Dialog (modal) is the most complex overlay component. It requires focus trapping (Tab cycles within the dialog, not outside it), scroll locking (the page behind the dialog doesn’t scroll), Escape to close, focus restoration (focus returns to the trigger when the dialog closes), and aria-modal="true" to tell screen readers that content outside the dialog is inert.

// src/components/Dialog/Dialog.tsx
import { forwardRef, useRef } from 'react'
import {
  useDialog,
  useModalOverlay,
  Overlay,
  FocusScope,
} from 'react-aria'
import { useObjectRef } from '@react-aria/utils'
import { useOverlayTriggerState } from 'react-stately'
import { cn } from '@/utils/cn'
import './dialog.css'

export interface RudiDialogProps {
  isOpen: boolean
  onClose: () => void
  title: string
  isDismissable?: boolean
  size?: 'sm' | 'md' | 'lg'
  children: React.ReactNode
  className?: string
}

export const RudiDialog = forwardRef<HTMLDivElement, RudiDialogProps>(function RudiDialog(
  {
    isOpen,
    onClose,
    title,
    isDismissable = true,
    size = 'md',
    children,
    className,
  }: RudiDialogProps,
  ref,
) {
  const underlayRef = useRef<HTMLDivElement>(null)
  const dialogRef = useObjectRef(ref)

  // useModalOverlay wants a real OverlayTriggerState. Build it with the hook
  // rather than by hand: an object literal is a new identity every render, and
  // stub open/toggle/setOpen methods make any React Aria path that calls them a
  // silent no-op. Driving it from the `isOpen` prop keeps the component
  // controlled. useModalOverlay also locks scroll and hides outside content
  // itself, so there is no separate usePreventScroll call.
  const state = useOverlayTriggerState({
    isOpen,
    onOpenChange: (open) => {
      if (!open) onClose()
    },
  })

  // dialogRef must point to the modal element (not the underlay) so that
  // useModalOverlay can correctly detect clicks outside the dialog panel.
  const { modalProps, underlayProps } = useModalOverlay(
    { isDismissable },
    state,
    dialogRef,
  )

  const { dialogProps, titleProps } = useDialog({}, dialogRef)

  if (!isOpen) return null

  return (
    <Overlay>
      <div
        {...underlayProps}
        ref={underlayRef}
        className="rudi-dialog__overlay"
      >
        {/* FocusScope provides tab trapping and restores focus on close */}
        <FocusScope contain restoreFocus autoFocus>
          <div
            {...modalProps}
            {...dialogProps}
            ref={dialogRef}
            className={cn(
              'rudi-dialog',
              `rudi-dialog--${size}`,
              className,
            )}
          >
            <h2 {...titleProps} className="rudi-dialog__title">
              {title}
            </h2>
            <div className="rudi-dialog__body">{children}</div>
          </div>
        </FocusScope>
      </div>
    </Overlay>
  )
})

useModalOverlay handles focus trapping and Escape dismissal, and it locks body scrolling and hides the rest of the page from assistive technology for you. That is why there is no separate usePreventScroll call here: adding one registers the same lock twice. Overlay renders the dialog into a portal appended to the document body, which prevents z-index stacking context issues. Reach for Overlay rather than the older OverlayContainer, which belongs to the OverlayProvider API that useModalOverlay replaced.

Dialog uses forwardRef so consumers can hold a ref to the dialog element, which is useful for programmatic focus management or measuring position. The internal useDialog hook requires a RefObject<HTMLDivElement>, not the looser ForwardedRef type that forwardRef provides. useObjectRef from @react-aria/utils bridges the two: it normalizes a forwarded ref (which may be a callback ref, a ref object, or null) into a stable RefObject, and syncs them so both the consumer’s ref and useDialog’s ref point to the same element.

The dialog is controlled-only (isOpen and onClose are required props). This is a deliberate choice. Uncontrolled dialogs (that manage their own open state) prevent the consumer from coordinating dialog visibility with the rest of their application state.

Dialog in Storybook — open in Storybook

Tooltip

Tooltip: bound to one trigger, and opened by keyboard focus as well as hover.

mergeProps from react-aria merges multiple props objects, combining event handlers by chaining them rather than overwriting — so if both objects have an onClick, both fire. It’s used here to attach the trigger props to the trigger element without discarding any existing handlers.

React.cloneElement creates a new React element by copying an existing one and merging in additional props. The TooltipTrigger uses it to inject the trigger and tooltip props into the caller’s elements without requiring consumers to forward those props themselves.

// src/components/Tooltip/Tooltip.tsx
import React, { forwardRef, useRef } from 'react'
import { mergeProps, useTooltipTrigger, useTooltip as useTooltipAria } from 'react-aria'
import { useTooltipTriggerState } from 'react-stately'
import type { TooltipTriggerState } from 'react-stately'
import { cn } from '@/utils/cn'
import './tooltip.css'

export interface RudiTooltipTriggerProps {
  delay?: number
  closeDelay?: number
  children: [React.ReactElement, React.ReactElement]
}

export const RudiTooltipTrigger = forwardRef<HTMLSpanElement, RudiTooltipTriggerProps>(
  function RudiTooltipTrigger({ delay = 500, closeDelay = 0, children }, ref) {
    const state = useTooltipTriggerState({ delay, closeDelay })
    const triggerRef = useRef<HTMLElement>(null)
    const { triggerProps, tooltipProps: triggerTooltipProps } =
      useTooltipTrigger({ delay, closeDelay }, state, triggerRef)

    const [trigger, tooltip] = children
    const triggerElement = trigger as React.ReactElement<Record<string, unknown>>

    return (
      <span ref={ref} className="rudi-tooltip-trigger">
        {React.cloneElement(
          triggerElement,
          // triggerProps carries the focus and pointer handlers, and the
          // aria-describedby that binds trigger to tooltip. All of it belongs on
          // the focusable element itself, not the wrapper. mergeProps chains any
          // handler the caller already passed rather than overwriting it.
          mergeProps(triggerElement.props, triggerProps, { ref: triggerRef }),
        )}
        {state.isOpen &&
          React.cloneElement(tooltip, { ...triggerTooltipProps, state })}
      </span>
    )
  },
)

export interface RudiTooltipProps {
  children: React.ReactNode
  className?: string
  /** Injected by RudiTooltipTrigger. */
  state?: TooltipTriggerState
}

export const RudiTooltip = forwardRef<
  HTMLSpanElement,
  RudiTooltipProps & Record<string, unknown>
>(function RudiTooltip({ children, className, state, ...props }, ref) {
  // useTooltip needs the TooltipTriggerState as its second argument. Without it
  // the returned props omit the handlers that keep the tooltip open while the
  // pointer travels onto it, so it dismisses the moment you reach for it.
  const { tooltipProps } = useTooltipAria(props, state)

  return (
    <span
      {...tooltipProps}
      ref={ref}
      className={cn('rudi-tooltip', className)}
      role="tooltip"
    >
      {children}
    </span>
  )
})

The tooltip appears on hover (after the configured delay) and on focus. It hides on pointer leave, blur, Escape, or scroll. React Aria manages the timing and the aria-describedby relationship between the trigger and the tooltip content. Both TooltipTrigger and Tooltip use forwardRef: TooltipTrigger exposes the outer wrapper span (useful for positioning logic), and Tooltip exposes the tooltip element itself.

Tooltip in Storybook — open in Storybook

Alert

Alert: no hooks at all. The container is a live region, so the browser announces it.

Alert needs no React Aria at all. Its accessibility comes entirely from one attribute and a live region the browser already provides:

// src/components/Alert/Alert.tsx
import { useState } from 'react'
import { cn } from '@/utils/cn'
import { RudiIcon } from '@/components/Icon/Icon'
import './alert.css'

export interface RudiAlertProps {
  variant: 'info' | 'success' | 'warning' | 'error'
  title?: string
  /** Iconify icon name shown beside the content. */
  icon?: string
  isPolite?: boolean
  dismissible?: boolean
  onDismiss?: () => void
  children: React.ReactNode
  className?: string
}

export function RudiAlert({
  variant,
  title,
  icon,
  isPolite = false,
  dismissible = false,
  onDismiss,
  children,
  className,
}: RudiAlertProps) {
  const [dismissed, setDismissed] = useState(false)

  if (dismissed) return null

  return (
    <div
      role={isPolite ? 'status' : 'alert'}
      className={cn(
        'rudi-alert',
        `rudi-alert--${variant}`,
        icon && 'rudi-alert--has-icon',
        dismissible && 'rudi-alert--dismissible',
        className,
      )}
    >
      {icon && (
        <RudiIcon icon={icon} size="md" className="rudi-alert__icon" aria-hidden="true" />
      )}
      <div className="rudi-alert__body">
        {title && <p className="rudi-alert__title">{title}</p>}
        <div className="rudi-alert__content">{children}</div>
      </div>
      {dismissible && (
        <button
          type="button"
          className="rudi-alert__dismiss"
          aria-label="Dismiss alert"
          onClick={() => {
            setDismissed(true)
            onDismiss?.()
          }}
        >
          <RudiIcon icon="mdi:close" size="sm" aria-hidden="true" />
        </button>
      )}
    </div>
  )
}

role="alert" triggers an assertive announcement in screen readers: the content is read immediately, interrupting whatever the screen reader was doing. role="status", via isPolite, triggers a polite announcement read at the next natural pause. Use assertive for errors that need immediate attention, polite for success messages and informational updates. Getting this backwards is a common and disruptive mistake: an assertive announcement for “Settings saved” cuts off whatever the user was listening to.

Three structural details matter more than they look.

The rudi-alert__body wrapper is not decorative. With an icon on one side and a dismiss button on the other, the alert is a three-column grid, and title plus content have to occupy one cell together. Flattening them into direct children of .rudi-alert puts the title and the content in separate grid cells and the layout falls apart. The --has-icon and --dismissible modifiers are what switch grid-template-columns between the three arrangements.

Dismissal is local state. dismissible hides the alert from inside the component and calls onDismiss so the parent can react. Requiring the consumer to own visibility would mean four lines of state management at every call site for the common case. Consumers who do want to own it can ignore dismissible and unmount the alert themselves.

The dismiss button needs its own label. The icon inside it is aria-hidden, so without aria-label="Dismiss alert" the button has no accessible name at all. This is the same argument as IconButton’s required aria-label in chapter 7, and the reason that prop was made non-optional there.

Alerts in Storybook — open in Storybook

Badge

Badge: a plain span, except in dot mode, where it drops its children.

Badge is a presentational <span>. No hooks, no state, no ref forwarding:

export interface RudiBadgeProps {
  variant?: 'default' | 'success' | 'warning' | 'error' | 'info'
  size?: 'sm' | 'md'
  /** Renders as a bare dot, suppressing children. */
  dot?: boolean
  children?: React.ReactNode
  className?: string
}

Defaults are variant="default", size="md", dot={false}.

The dot mode is the one surprising piece: it suppresses children entirely rather than shrinking them, rendering {!dot && children}. A dot badge is a status indicator with no text, so passing children alongside dot is a mistake, and dropping them is more honest than rendering a badge too small to read. Note that children is optional precisely because of this mode.

There is no aria-label prop. A badge is decorative or it duplicates adjacent text, and a bare dot conveys nothing on its own, so a consumer who needs it announced should label the surrounding element. Adding a label prop would invite the pattern where every badge gets a redundant announcement.

Card

Card: the first compound component, assembled from three named slots.

Card is a compound component, and it is the first one in the guide:

export interface RudiCardProps {
  variant?: 'default' | 'outlined' | 'elevated'
  padding?: 'none' | 'sm' | 'md' | 'lg'
  children: React.ReactNode
  className?: string
}

// …CardRoot, CardHeader, CardBody, and CardFooter defined above…

export const RudiCard = Object.assign(CardRoot, {
  Header: CardHeader,
  Body: CardBody,
  Footer: CardFooter,
})

Which gives consumers this:

<RudiCard variant="outlined">
  <RudiCard.Header>
    <RudiHeading level={3}>Monthly report</RudiHeading>
  </RudiCard.Header>
  <RudiCard.Body>
    <RudiText>Revenue is up 12% over the previous period.</RudiText>
  </RudiCard.Body>
  <RudiCard.Footer>
    <RudiButton variant="secondary">Export</RudiButton>
  </RudiCard.Footer>
</RudiCard>

Object.assign attaches the subcomponents as properties of the root. The alternative, exporting RudiCard, RudiCardHeader, RudiCardBody, and RudiCardFooter separately, works identically and puts four names into the consumer’s import statement where one would do. The dotted form also makes the relationship obvious at the call site: RudiCard.Footer outside a RudiCard reads as wrong.

The padding prop is plumbed through a local custom property rather than applied directly. .rudi-card--padding-md sets --card-padding, and the header, body, and footer rules each read it. That way one prop on the root controls the inset on all three regions without any of them knowing what the others do.

Card takes no React Aria and has no as prop. It is a container, so there is no press behavior to normalize and no focus to manage. If you need a clickable card, put a real link or button inside it rather than making the container itself interactive: a clickable <div> needs a role, a tab index, and key handlers to reach parity with an element the browser already ships.

What you have now

The overlay and feedback layer is complete: Dialog, Tooltip, Alert, Badge, and Card, on top of the form components from chapters 7 and 8 and the layout and typography layers before them.

Two claims worth stating precisely, because both are easy to overstate.

Not every component uses React Aria. Dialog, Tooltip, Button, Input, IconButton, Select, Checkbox, CheckboxGroup, RadioGroup, and Switch do, because each manages focus, keyboard interaction, or ARIA state that is hard to get right. Alert, Badge, and Card do not, because they manage none of it.

Not every component uses forwardRef. Button, IconButton, Input, Checkbox, Switch, Dialog, Tooltip, and Card forward refs because a consumer has a plausible reason to reach the underlying element. Select, CheckboxGroup, RadioGroup, Alert, and Badge are plain functions. A group component’s ref target is ambiguous, and a badge’s is useless. Adding forwardRef everywhere by reflex costs a wrapper and communicates a capability nobody needs.

This is roughly a third of what the companion repository ships. Chapters 14 through 16 cover the rest: the presentational and navigation set, the charts, and the drag-and-drop board.

The next chapter configures Storybook to document and demonstrate the system.

This chapter has been corrected since it was first published: see what changed. Found an error? Report it.