Alert

Displays a callout for user attention.

Spec · from metadata

When to use

  • Displaying success, warning, or error messages inline on a page
  • An info banner or informational notice — tips, version updates, neutral system status. Use variant="default"; there is no separate "info" variant.
  • Informational notices that persist (not dismissible)
  • Form-level error summaries above or below a form — pass role="status" or role={undefined} so the summary does not compete with the role=alert on each FormMessage
  • Status messages after an action completes

When not to use

  • Temporary notifications that auto-dismiss — use Toast instead
  • Confirmation before a destructive action — use AlertDialog
  • Inline field-level errors — use FormMessage within Form
  • Modal alerts — use AlertDialog

Variants

PropValuesDefaultDescription
variantdefaultdestructivewarningsuccessdefaultSets the left rail and icon color only; the surface and text are identical across all four. default = navy rail (--primary) — this is the INFORMATIONAL style: use it for info banners, notices, tips, version updates and neutral system status. destructive = berry rail (tc-berry-700) for errors and failure states. warning = orange rail (tc-orange-700) for caution. success = sage rail (tc-sage-600) for positive confirmation. There is no separate 'info' variant to choose — asking for an info alert means variant="default".
  • info deprecated — use default. Renders identically to default (same navy rail and icon). default is the informational style, so a separate name added nothing but ambiguity.
  • error deprecated — use destructive. Renders identically to destructive (same berry rail and icon). Two names for one look with no rule for choosing between them.

Anti-patterns

Avoid<Alert variant="destructive">
  <p>Are you sure you want to delete?</p>
  <Button variant="destructive">Delete</Button>
</Alert>
Prefer<AlertDialog>...</AlertDialog>

Alert is for displaying messages, not for confirmations. Use AlertDialog for destructive action confirmations.

Avoid<Alert>
  <AlertTitle>Error</AlertTitle>
</Alert>
Prefer<Alert variant="destructive">
  <CircleXIcon />
  <AlertTitle>Error</AlertTitle>
  <AlertDescription>Something went wrong.</AlertDescription>
</Alert>

Use the destructive variant for errors, include an icon, and always provide a description.

Avoid<AlertDescription>
  Coverage ends on <span className="font-semibold">31 August</span> unless the plan is renewed.
</AlertDescription>
Prefer<AlertDescription>
  <p>Coverage ends on <span className="font-semibold">31 August</span> unless the plan is renewed.</p>
</AlertDescription>

AlertDescription is a grid: the bare version renders as three stacked lines (text, span, text) instead of one sentence. Wrapping the flowing sentence in a single <p> makes it one grid row.

Accessibility

  • Screen readerrole='alert' is set by default, which creates an assertive live region: screen readers interrupt to announce the content when it appears. Use sparingly to avoid alert fatigue. Because props spread after the role, a caller can override it — role='status' announces politely at the next pause, role={undefined} removes the live region entirely. Do that whenever another live region already owns the announcement: FormMessage takes role=alert while a field has an error, so a form-level Alert error summary rendered alongside it would mean N+1 assertive regions firing on one submit. Field-level FormMessage owns the announcement; the summary should be role='status' or role={undefined}.
  • ContrastText is card-foreground (title) and muted-foreground (description) in every variant, so text contrast does not vary by variant. The rail and icon are the only variant color and are non-text, so they are held to WCAG 1.4.11 (3:1): on the light card navy-700 is 7.89:1, berry-700 6.03:1, orange-700 4.20:1, sage-600 5.48:1; on the dark card each ramp's -400 step is used instead, at 6.19:1, 6.53:1, 9.24:1 and 6.76:1. Because color is the only variant signal, always pair it with an icon and with text that names the state.

Token bindings

TokenCategoryUsage
bg-cardcolorAlert background — every variant
text-card-foregroundcolorAlertTitle text color
text-muted-foregroundcolorAlertDescription text color
bg-primarycolorRail and icon color for the default and info variants
bg-tc-berry-700colorRail and icon color for the destructive and error variants (dark: tc-berry-400)
bg-tc-orange-700colorRail and icon color for the warning variant (dark: tc-orange-400)
bg-tc-sage-600colorRail and icon color for the success variant (dark: tc-sage-400)
rounded-lgradiusAlert border radius
rounded-fullradiusRail cap radius
py-3 pl-6 pr-4spacingInternal padding
Recent changes
  • breaking

    AlertAction anchors to the title row instead of centring

    2026-08-19

  • new

    AlertDisclosure — Alert can collapse its body

    2026-08-19

  • fixed

    Deprecated variant values are no longer offered as choices

    2026-08-12

[!NOTE] Every variant renders on bg-card with text-card-foreground title and text-muted-foreground description. The variant is signalled by a 4px rounded rail on the leading edge plus the icon color — never by a tinted background. This replaced the earlier soft-tint palettes (bg-tc-sage-50 and friends) in the August 2026 Figma spec.

Six variant names resolve to four looks: default and info are the same navy rail, and error is the same berry rail as destructive. The aliases are kept so existing code keeps type-checking, but they are deprecated — passing one logs a dev-only console warning pointing at the canonical name. There is deliberately no editor strikethrough: TypeScript honours @deprecated only on declarations, so a single member of a string-literal union cannot be marked.

Import

import { Alert, AlertTitle, AlertDescription, AlertAction } from "@timelycare/helix-ui"

Props

interface AlertProps {
  // Choose one of the four. `info` and `error` are accepted deprecated
  // aliases of `default` and `destructive` — see Deprecated aliases below.
  variant?: "default" | "destructive" | "warning" | "success" | "info" | "error"
  className?: string
  children: React.ReactNode
}

interface AlertTitleProps {
  className?: string
  children: React.ReactNode
}

interface AlertDescriptionProps {
  className?: string
  children: React.ReactNode
}

interface AlertActionProps {
  className?: string
  children: React.ReactNode
}

Variants

The background, border, title color, and description color are identical across all six. Only the rail and icon change.

There are four variants. Pick by meaning:

VariantUse forRail + icon (light)Rail + icon (dark)
defaultInformational — info banners, notices, tips, version updates, neutral system statusbg-primary / text-primary (navy-700)navy-400, via --primary
destructiveErrors and failure statestc-berry-700tc-berry-400
warningCaution — proceed carefully, approaching a limittc-orange-700tc-orange-400
successPositive confirmation, completed actionstc-sage-600tc-sage-400

Looking for an info variant? Use default. default is the informational style — the navy rail is the info treatment, so an "info alert" is <Alert> with no variant set.

Deprecated aliases

AliasUse insteadNotes
infodefaultRenders identically (same navy rail and icon)
errordestructiveRenders identically (same berry rail and icon)

Both still work and are not scheduled for removal before the next major, but passing either logs a dev-only console warning naming the replacement. They are deliberately absent from variants[].values in the metadata, so the MCP server and the Spec tab offer only the four canonical names — an agent asked for an "info banner" is pointed at default rather than handed a deprecated alias. There is no editor strikethrough: TypeScript honours @deprecated only on declarations, so a single member of a string-literal union cannot be marked.

Because the rail and icon are the only variant color, always pass an icon and make sure the text itself names the state. A user who cannot distinguish the rail colors should still get the message from the words.


Styling

Typography

Title and description colors do not vary by variant.

ElementFontSizeWeightLine HeightColor
Titlefont-sans (Adelle Sans)text-sm (14px)font-mediumleading-5 (20px)text-card-foreground
Descriptionfont-sans (Adelle Sans)text-sm (14px)font-normalleading-5 (20px)text-muted-foreground

Container

PropertyValue
Layoutgrid — three tracks: icon · content · action
Columnsgrid-cols-[auto_1fr_auto]
Rowsgrid-rows-[auto_auto] — title, description
Gapnone in either axis — spacing rides on the items (mr-1.5 on the icon, ml-3 on the action, mt-1 on the description)
Backgroundbg-card (every variant)
Borderborder (default border color, every variant)
Border radiusrounded-lg
Paddingpy-3 pl-6 pr-4 — extra leading padding clears the rail
Texttext-sm
Widthw-full relative

There are no grid gaps by design: CSS Grid places a gap between tracks whether or not they hold anything, so a column gap would indent an alert with no icon, and a row gap would add phantom height to one with no description. Putting the spacing on the items means it exists only when the element does — which is also why adding an AlertAction cannot widen the gap between the title and the description.

The rows must be explicit. AlertAction spans them with row-span-full (grid-row: 1 / -1), and -1 counts back from the end of the explicit grid — with implicit rows it silently resolves to row 1 alone, letting a tall action stretch the title's row.

Rail

PropertyValue
Element::before pseudo-element on the container
Positionabsolute — required, or it would become a grid item and claim a cell
Insetinset-y-[5px] left-[5px]
Widthw-1 (4px)
Radiusrounded-full
Colorper variant — see the Variants table

Icon

PropertyValue
Sizesize-4 (16px)
Columntrack 1
Trailing marginmr-1.5 (6px)
Vertical offsettranslate-y-0.5 (aligns with the title's cap height)
Colormatches the rail, set by the variant

Title

  • font-medium, tracking-tight
  • line-clamp-1 — truncates to a single line to keep alerts compact (min-h-4 reserves height)
  • col-start-2
  • text-card-foreground inherited from the container in every variant

Description

  • text-muted-foreground text-sm, col-start-2
  • text-balance md:text-pretty for nicer wrapping
  • [[data-slot=alert-title]+&]:mt-1 — the 4px below the title lives here, not as a container row gap, so a description-only alert gets no stray offset

AlertAction

  • col-start-3 row-span-full ml-3 self-start — its own track, spanning the title and description rows but top-aligned with the title. Not self-center: the slot spans both rows, so centring placed the control at the vertical middle of the whole box — a disclosure control sat beside the title while collapsed and slid down by half the description's height when expanded. A control you click must not move when you click it. Title-only alerts render identically either way; pass className="self-center" where centring is genuinely wanted.
  • Action buttons should use size xs (h-7 px-3 text-xs font-medium)
  • Content shrinks to make room for it, rather than being overlapped

AlertDisclosure

A long notice that should lead with its summary. AlertDisclosure supplies both halves of the disclosure — the trigger and the revealed region — already wired together, so you do not hand-roll one out of Alert plus Collapsible.

<Alert variant="warning" role="status">
  <TriangleAlert />
  <AlertTitle>3 claims need review before payroll runs</AlertTitle>
  <AlertDisclosure>
    <AlertDescription>
      <p>INV-1043 and INV-1044 are missing a group number…</p>
    </AlertDescription>
  </AlertDisclosure>
</Alert>
PropDefaultNotes
defaultOpenfalseStarts collapsed — the reason to reach for a disclosure is that the title should carry the screen on its own
label"Read more"The house verb for expanding truncated body text in place. "See more" and "Expand" are anti-patterns — see copy patterns
collapseLabel"Read less"

Pass role="status" on the Alert. Alert defaults to role="alert", an assertive live region, and revealing content inside one makes a screen reader interrupt itself to read text the user just asked to see. Use role={undefined} instead when the notice was already on screen before the user arrived and needs no announcement at all. Alert spreads props after its own role, so the caller's value wins.

The collapsed body is hidden, not visually hidden — it leaves the accessibility tree entirely. A disclosure whose content is still read out is a control that lies about what it does.

The trigger does not move when clicked. AlertDisclosure reuses alertActionClasses, so it inherits the self-start fix: measured on the Alert preview, expanding grows the alert by 87px while the trigger moves 0px. Under the old self-center it slid by half the revealed height.

It is a client component — the one part of the Alert family that is. Disclosure needs state, and "use client" is per-module, so it lives in alert-disclosure.tsx to keep Alert itself server-safe. Importing it does not move Alert across the client boundary; a server component can render an Alert containing an AlertDisclosure island, which is what the docs preview does.


Icons

Size: size-4 (16×16px) · Color: set by the variant, matching the rail

IconUse ForVariant
CircleCheckSuccess messagessuccess
CircleXErrorsdestructive / error
InfoInformational noticesdefault / info
TriangleAlertWarningswarning

Icons are placed as direct children of the Alert container. The grid handles positioning and the variant sets the color — no wrapper div and no color class needed.

import { CircleCheck } from "lucide-react"

<Alert variant="success">
  <CircleCheck />
  <AlertTitle>Title</AlertTitle>
</Alert>

States

MeaningImplementation
Informational / info banner<Alert> or variant="default"
Error, failurevariant="destructive"
Cautionvariant="warning"
Confirmation, successvariant="success"

Alert is a static display component. Any action buttons within it have their own states.

Alert is a static display component. Any action buttons within it have their own states.


Common Patterns

Success Alert

import { CircleCheck } from "lucide-react"

<Alert variant="success">
  <CircleCheck />
  <AlertTitle>Changes saved</AlertTitle>
  <AlertDescription>
    Your changes have been saved successfully.
  </AlertDescription>
</Alert>

Destructive Alert

import { CircleX } from "lucide-react"

<Alert variant="destructive">
  <CircleX />
  <AlertTitle>Something went wrong</AlertTitle>
  <AlertDescription>
    We couldn't process your request. Please try again.
  </AlertDescription>
</Alert>

Warning Alert

import { TriangleAlert } from "lucide-react"

<Alert variant="warning">
  <TriangleAlert />
  <AlertTitle>Storage almost full</AlertTitle>
  <AlertDescription>
    You have used 90% of your available storage. Consider upgrading your plan.
  </AlertDescription>
</Alert>

Info Alert

import { Info } from "lucide-react"

<Alert variant="info">
  <Info />
  <AlertTitle>New version available</AlertTitle>
  <AlertDescription>
    Version 2.4.0 includes performance improvements and bug fixes.
  </AlertDescription>
</Alert>

With Action Button

<Alert variant="info">
  <Info />
  <AlertTitle>Dark mode is now available</AlertTitle>
  <AlertDescription>Enable it under your profile settings to get started.</AlertDescription>
  <AlertAction>
    <Button variant="outline" size="xs">Undo</Button>
  </AlertAction>
</Alert>

Description Only

<Alert>
  <AlertDescription>
    This one has a description only. No title. No icon.
  </AlertDescription>
</Alert>

Component Structure

// Alert container — CSS Grid, three tracks, rail as a ::before pseudo-element
<div
  role="alert"
  className="relative grid w-full grid-cols-[auto_1fr_auto] grid-rows-[auto_auto] items-start
    rounded-lg border bg-card py-3 pl-6 pr-4 text-sm text-card-foreground
    before:absolute before:inset-y-[5px] before:left-[5px] before:w-1
    before:rounded-full before:content-['']
    [&>svg]:mr-1.5 [&>svg]:size-4 [&>svg]:translate-y-0.5
    before:bg-tc-sage-600 [&>svg]:text-tc-sage-600"
>
  {/* Icon — direct child, lands in track 1 */}
  <CircleCheck />

  {/* AlertTitle */}
  <div data-slot="alert-title" className="col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight">
    {title}
  </div>

  {/* AlertDescription */}
  <div
    data-slot="alert-description"
    className="col-start-2 text-muted-foreground text-sm [[data-slot=alert-title]+&]:mt-1"
  >
    {description}
  </div>

  {/* AlertAction (optional) — track 3, top-aligned with the title */}
  <div data-slot="alert-action" className="col-start-3 row-span-full ml-3 self-start">
    <Button variant="outline" size="xs">Undo</Button>
  </div>
</div>

Accessibility

  • Static container component — not built on a Radix interactive primitive
  • Uses role="alert" for important messages (announced immediately by screen readers)
  • No keyboard interaction needed (static content)
  • Ensure decorative icons have aria-hidden="true"

Live regions — who owns the announcement

role="alert" implies aria-live="assertive", which interrupts the screen reader. Two Helix components create live regions and can collide:

ComponentLive region
Alertrole="alert", always
FormMessagerole="alert", but only while error is set

A form with three invalid fields plus a form-level Alert summary is four assertive regions firing on one submit; several at once are queued or dropped depending on the screen reader. And because react-hook-form's shouldFocusError defaults to true, a failed submit focuses the first invalid field — its message is read via aria-describedby on focus and fires as an alert, so the same text is announced twice.

Field-level FormMessage owns the announcement. An Alert used as a form error summary is a visual affordance and must not compete. Props spread after the component's own role, so a caller can override it:

CallRenderedUse for
<Alert>role="alert"Standalone notices — the default
<Alert role="status">role="status"A summary that adds information the field messages don't
<Alert role={undefined}>no roleA summary that only repeats the field messages
// Form error summary — polite, so the field messages own the interruption
<Alert variant="destructive" role="status">
  <CircleX />
  <AlertTitle>Could not save</AlertTitle>
  <AlertDescription>Fix the 3 highlighted fields and try again.</AlertDescription>
</Alert>

The same principle applies outside forms: if an Alert already announces a result, don't also fire a Toast for it.

Contrast

Title and description use card-foreground and muted-foreground in every variant, so text contrast does not vary by variant. The rail and icon are non-text and are held to WCAG 2.1 SC 1.4.11 (3:1):

Variant colorOn light cardOn dark card (#191A1A)
navy-700 → navy-4007.89:16.19:1
berry-700 → berry-4006.03:16.53:1
orange-700 → orange-4004.20:19.24:1
sage-600 → sage-4005.48:16.76:1

The dark-mode steps are a Helix addition — the Figma spec defines light only, and the light steps fail on the dark card (berry-700 at 2.89:1, navy-700 at 2.21:1). Navy reaches navy-400 for free because --primary already resolves there in dark mode. See the drift section in helix-tokens.md.

Color is the only variant signal, so it must never be the only cue: pair every alert with an icon and with text that names the state.


Gotchas

ProblemSolution
Wanted a tinted backgroundNot supported — the rail carries the variant. Don't add bg-tc-*-50 via className.
Icon is the wrong colorDon't set a color class on the icon; the variant colors it via [&>svg]
Icon not aligned with the titleHandled automatically by translate-y-0.5 on the SVG
Need a dismissible alertAdd a close button inside AlertAction; Alert doesn't auto-dismiss
Long action label overlaps textIt can't — AlertAction is a real grid track, so content shrinks instead
Adding an action opened a gap under the titleFixed — the action spans both rows and the 4px is a margin on the description, so an alert is 70px tall with or without one. If it regresses, check that grid-rows-[auto_auto] is still on the container.
Rail is missingCheck nothing in className overrides before: utilities or sets position: static
error vs destructiveIdentical; prefer destructive. Using error or info logs a dev-only console warning.
No editor strikethrough on info / errorExpected. TypeScript honours @deprecated only on declarations, so it cannot mark one member of a string-literal union — the dev console warning is the signal instead.

See Also


Figma Reference

View in Figma


Last updated: August 12, 2026