Skip to content
Overlays & Feedback primitive

Alert

An inline message that is not a live region by default — an alert role interrupts a screen reader, which is wrong for a notice that is simply on the page — with a `live` switch for the ones that appear in response to something.

134 lineslucide-react
  • alert
  • callout
  • banner
  • notice
  • feedback
  • error

What's included

  • components/ui/alert.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add alert

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Live

Interactive, in your current theme. Every state below is the real component — type in it, tab through it, switch the theme.

Scheduled maintenance

The dashboard will be read-only on Sunday between 02:00 and 03:00 UTC.

Payment received

Your invoice for September has been marked as paid.

Could not save changes

The connection dropped before your edits reached the server. Try again in a moment.

Source

One file. Create components/ui/alert.tsx and paste.

components/ui/alert.tsx
'use client'

/**
 * <Alert> — an inline message that belongs to the page: info, success,
 * warning or error, with an optional action and an optional dismiss.
 *
 * WHY IT IS NOT A LIVE REGION BY DEFAULT
 *
 * The reflex is `role="alert"`, and it is wrong for most alerts. An `alert`
 * role interrupts whatever the screen reader is saying the moment it appears —
 * which is right when a save fails, and wrong for a "Your trial ends in 3
 * days" notice that is simply part of the page on load. So the default is no
 * role at all: a static alert is just content, in reading order.
 *
 * Pass `live` when the message APPEARS as a result of something the user did
 * or that happened while they were here. Then errors and warnings become
 * `role="alert"` (assertive) and info and success become `role="status"`
 * (polite), which is the distinction the two roles exist to make. The live
 * region has to be in the DOM before its text arrives to be announced, so for
 * a message that mounts with the alert, render `<Alert live>` from the start.
 *
 * Colour is never the only signal: every tone has its own icon and the title
 * carries the meaning in words. Text stays `foreground` on the tint, which
 * clears 4.5:1 in both themes; only the icon and the edge take the tone.
 */

import * as React from 'react'
import { Info, CircleCheck, TriangleAlert, CircleAlert, X } from 'lucide-react'

export type AlertTone = 'info' | 'success' | 'warning' | 'error'

export interface AlertAction {
  label: string
  onClick?: () => void
  href?: string
}

export interface AlertProps {
  tone?: AlertTone
  title?: string
  children?: React.ReactNode
  action?: AlertAction
  /** Renders a dismiss button and calls this when it is pressed. */
  onDismiss?: () => void
  /** True when the alert appears in response to something, so it is announced. */
  live?: boolean
  className?: string
}

const TONES: Record<
  AlertTone,
  { icon: React.ComponentType<{ className?: string; 'aria-hidden'?: boolean }>; box: string; glyph: string }
> = {
  info: {
    icon: Info,
    box: 'border-sky-600/30 bg-sky-500/10',
    glyph: 'text-sky-700 dark:text-sky-400',
  },
  success: {
    icon: CircleCheck,
    box: 'border-emerald-600/30 bg-emerald-500/10',
    glyph: 'text-emerald-700 dark:text-emerald-400',
  },
  warning: {
    icon: TriangleAlert,
    box: 'border-amber-600/35 bg-amber-500/10',
    glyph: 'text-amber-700 dark:text-amber-400',
  },
  error: {
    icon: CircleAlert,
    box: 'border-destructive/35 bg-destructive/10',
    glyph: 'text-destructive',
  },
}

export function Alert({
  tone = 'info',
  title,
  children,
  action,
  onDismiss,
  live = false,
  className = '',
}: AlertProps) {
  const { icon: Icon, box, glyph } = TONES[tone]
  const role = live ? (tone === 'error' || tone === 'warning' ? 'alert' : 'status') : undefined

  return (
    <div
      role={role}
      className={`flex items-start gap-3 rounded-xl border p-4 text-sm text-foreground ${box} ${className}`}
    >
      <Icon aria-hidden className={`mt-0.5 h-4 w-4 shrink-0 ${glyph}`} />
      <div className="min-w-0 flex-1">
        {title ? <p className="font-semibold">{title}</p> : null}
        {children ? (
          <div className={`text-muted-foreground ${title ? 'mt-1' : ''}`}>{children}</div>
        ) : null}
        {action ? (
          <div className="mt-3">
            {action.href ? (
              <a
                href={action.href}
                onClick={action.onClick}
                className="rounded font-medium underline underline-offset-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
              >
                {action.label}
              </a>
            ) : (
              <button
                type="button"
                onClick={action.onClick}
                className="rounded font-medium underline underline-offset-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
              >
                {action.label}
              </button>
            )}
          </div>
        ) : null}
      </div>
      {onDismiss ? (
        <button
          type="button"
          onClick={onDismiss}
          aria-label="Dismiss"
          className="-m-1 shrink-0 rounded-lg p-1 text-muted-foreground transition-colors hover:bg-foreground/10 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
        >
          <X aria-hidden className="h-4 w-4" />
        </button>
      ) : null}
    </div>
  )
}

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
toneAlertTone'info'
titlestring—
childrenReact.ReactNode—
actionAlertAction—
onDismissRenders a dismiss button and calls this when it is pressed.() => void—
liveTrue when the alert appears in response to something, so it is announced.booleanfalse
classNamestring''

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

More overlays & feedback

View all
Open the full page for this primitive

Dropdown Menu

An action menu with the menu keyboard model in full: arrows, Home/End, typeahead, and Escape that returns focus to the trigger rather than dropping it on the page body. Its limits — no collision flip, no portal — are stated in the header.

Overlays & Feedback222 linesNo deps
Open the full page for this primitive

Dialog

A modal on the native dialog element, which supplies the focus trap, inert background and top layer — plus the parts it does not: scroll lock that survives two dialogs, backdrop click, and an alertdialog that ignores it.

Overlays & Feedback156 lines1 dep
Open the full page for this primitive

Tooltip

Dismissible with Escape, hoverable, and persistent — the three things WCAG 1.4.13 asks of content on hover — shown on keyboard focus rather than every click, and wired with aria-describedby so it is announced even when it is not drawn.

Overlays & Feedback128 linesNo deps