Components

Getting started

Components144

More

A message that sits in the page: its tint, line and icon ease when the tone changes, and dismissing folds it away so the content below slides up.

Used forBillingSync and importsFormsAnnouncements

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/alert.json

Templates

Need the whole page?

43 Next.js landing pages, each designed from scratch. Try any of them live; one is $39, all of them $99, and 5 are free.

All 43 templates

Where it’s used

  • Billing

    A card is about to expire, with Update.

  • Sync and imports

    "Syncing" turning into "Synced" in place.

  • Forms

    A summary of what needs fixing.

  • Announcements

    A dismissible note at the top of a settings page.

What it handles for you

An alert that pops in and out drags the page with it; this one folds.

Instead of wiring upcalloutbannerinline notification
  1. Tone transitions

    Tint, line and icon ease (300ms).

  2. Folding dismissal

    Height closes (380ms, 80ms after the fade) so content slides up.

  3. Right role

    role="alert" for warning and danger, "status" otherwise.

  4. Responsive action

    Beside the text on wide cards, under it on narrow ones.

Overview

Inline alerts usually change tone by swapping a whole new box in, and vanish on dismiss so everything below jumps up. Here the tint, hairline and icon ease from one tone to the next (the icon swapping through a blur), and the ✕ folds the alert away: it lifts and fades while its height closes.

States

tone change
Background, line and icon colour ease; the icon swaps through a 3px blur.
dismiss
Lifts 6px and fades (220ms) while the height folds (380ms).

Usage

usage.tsx
import { Alert } from "@/components/ui/alert";<Alert tone="warning" title="Your card expires on 30 November" action={<button>Update</button>} onDismiss={() => {}}>  Update it to keep All access.</Alert>

Props

PropTypeDescription
tone"info" | "success" | "warning" | "danger"default "info"Colour and icon.
titleReactNodeFirst line.
childrenReactNodeDetails.
actionReactNodeA button or link.
onDismiss() => voidShows ✕; called after it has folded.
hiddenbooleanFold it away without unmounting.
iconReactNodeReplace the tone icon.

Accessibility and motion

  • Reduced motion: changes and dismissal happen in place.
  • Folded alerts are inert, so focus can't land in them.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs ui-interaction-motion.

Source

components/alert.tsx
"use client";import * as React from "react";import { EASE_OUT, INTERACTION_FOCUS, INTERACTION_PRESS, interactionIcon, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * ALERT: a message that sits in the page * *   tone     info, success, warning or danger: the tint, line and *            icon ease across when the tone changes (a sync that *            goes from "Syncing" to "Synced"), the icon swapping *            through a blur *   dismiss  the ✕ folds the alert away: it fades and lifts while *            its height closes, so the content below slides up *            instead of jumping *   action   an optional button on the right (or under it on *            narrow cards) * ───────────────────────────────────────────────────────── */export type AlertTone = "info" | "success" | "warning" | "danger";export interface AlertProps {  tone?: AlertTone;  title: React.ReactNode;  children?: React.ReactNode;  /** A button or link at the end. */  action?: React.ReactNode;  /** Show a ✕; called after the alert has folded away. */  onDismiss?: () => void;  /** Hide it (folds away) without unmounting. */  hidden?: boolean;  /** Replace the tone's icon. */  icon?: React.ReactNode;  className?: string;}const TONE: Record<AlertTone, string> = {  info: "bg-card shadow-[inset_0_0_0_1px_var(--border)] text-foreground",  success: "bg-emerald-500/[0.06] shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-emerald-500)_30%,transparent)] text-foreground",  warning: "bg-amber-500/[0.07] shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-amber-500)_35%,transparent)] text-foreground",  danger: "bg-red-500/[0.06] shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_30%,transparent)] text-foreground",};const ICON_TONE: Record<AlertTone, string> = {  info: "text-muted-foreground",  success: "text-emerald-600 dark:text-emerald-400",  warning: "text-amber-600 dark:text-amber-400",  danger: "text-red-600 dark:text-red-400",};const ICONS: Record<AlertTone, React.ReactNode> = {  info: (    <svg viewBox="0 0 16 16" fill="none" className="size-4">      <circle cx="8" cy="8" r="6.25" stroke="currentColor" strokeWidth="1.5" />      <path d="M8 7.25v3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />      <circle cx="8" cy="5" r="0.9" fill="currentColor" />    </svg>  ),  success: (    <svg viewBox="0 0 16 16" fill="none" className="size-4">      <circle cx="8" cy="8" r="6.25" stroke="currentColor" strokeWidth="1.5" />      <path d="M5.4 8.2 7.2 10l3.4-3.8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />    </svg>  ),  warning: (    <svg viewBox="0 0 16 16" fill="none" className="size-4">      <path d="M8 2.2 14.3 13H1.7L8 2.2Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />      <path d="M8 6.3v3.2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />      <circle cx="8" cy="11.2" r="0.85" fill="currentColor" />    </svg>  ),  danger: (    <svg viewBox="0 0 16 16" fill="none" className="size-4">      <circle cx="8" cy="8" r="6.25" stroke="currentColor" strokeWidth="1.5" />      <path d="M8 4.75v3.75" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />      <circle cx="8" cy="11" r="0.9" fill="currentColor" />    </svg>  ),};export function Alert({ tone = "info", title, children, action, onDismiss, hidden: hiddenProp, icon, className = "" }: AlertProps) {  const reduced = useInteractionReducedMotion();  const [dismissed, setDismissed] = React.useState(false);  const hidden = hiddenProp || dismissed;  React.useEffect(() => {    if (!dismissed) return;    const timer = window.setTimeout(() => onDismiss?.(), reduced ? 0 : 380);    return () => window.clearTimeout(timer);  }, [dismissed, onDismiss, reduced]);  return (    <div      className="grid"      aria-hidden={hidden || undefined}      inert={hidden || undefined}      style={{ gridTemplateRows: hidden ? "0fr" : "1fr", transition: reduced ? "none" : `grid-template-rows 380ms ${EASE_OUT} ${hidden ? 80 : 0}ms` }}    >      <div className="min-h-0 overflow-hidden [clip-path:inset(-2px)]">        <div          role={tone === "danger" || tone === "warning" ? "alert" : "status"}          className={`@container flex items-start gap-3 rounded-[14px] p-3.5 transition-[background-color,box-shadow] duration-300 ${TONE[tone]} ${className}`}          style={{            opacity: hidden ? 0 : 1,            translate: hidden && !reduced ? "0 -6px" : "0 0",            scale: hidden && !reduced ? "0.98" : "1",            transition: reduced ? "none" : `opacity 220ms ${EASE_OUT}, translate 320ms ${EASE_OUT}, scale 320ms ${EASE_OUT}, background-color 300ms, box-shadow 300ms`,          }}        >          <span className={`relative mt-px size-4 shrink-0 transition-colors duration-300 ${ICON_TONE[tone]}`} aria-hidden="true">            {icon ? (              icon            ) : (              (Object.keys(ICONS) as AlertTone[]).map((t) => (                <span key={t} className="absolute inset-0" style={interactionIcon(t === tone, reduced)}>                  {ICONS[t]}                </span>              ))            )}          </span>          <div className="flex min-w-0 flex-1 flex-col gap-2.5 @md:flex-row @md:items-start @md:justify-between">            <div className="flex min-w-0 flex-col gap-0.5">              <p className="text-[13.5px] font-medium leading-5">{title}</p>              {children && <div className="text-[13px] leading-relaxed text-muted-foreground">{children}</div>}            </div>            {action && <div className="flex shrink-0 items-center gap-2">{action}</div>}          </div>          {onDismiss && (            <button              type="button"              aria-label="Dismiss"              onClick={() => setDismissed(true)}              className={`-mr-1 -mt-1 flex size-7 shrink-0 items-center justify-center rounded-full text-muted-foreground cursor-pointer hover:bg-accent hover:text-foreground ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}            >              <svg viewBox="0 0 16 16" fill="none" className="size-3.5">                <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />              </svg>            </button>          )}        </div>      </div>    </div>  );}export default Alert;

Registry item: /r/alert.json