[!NOTE] Every variant renders on
bg-cardwithtext-card-foregroundtitle andtext-muted-foregrounddescription. 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-50and friends) in the August 2026 Figma spec.Six variant names resolve to four looks:
defaultandinfoare the same navy rail, anderroris the same berry rail asdestructive. 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@deprecatedonly 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:
| Variant | Use for | Rail + icon (light) | Rail + icon (dark) |
|---|---|---|---|
default | Informational — info banners, notices, tips, version updates, neutral system status | bg-primary / text-primary (navy-700) | navy-400, via --primary |
destructive | Errors and failure states | tc-berry-700 | tc-berry-400 |
warning | Caution — proceed carefully, approaching a limit | tc-orange-700 | tc-orange-400 |
success | Positive confirmation, completed actions | tc-sage-600 | tc-sage-400 |
Looking for an
infovariant? Usedefault.defaultis the informational style — the navy rail is the info treatment, so an "info alert" is<Alert>with no variant set.
Deprecated aliases
| Alias | Use instead | Notes |
|---|---|---|
info | default | Renders identically (same navy rail and icon) |
error | destructive | Renders 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.
| Element | Font | Size | Weight | Line Height | Color |
|---|---|---|---|---|---|
| Title | font-sans (Adelle Sans) | text-sm (14px) | font-medium | leading-5 (20px) | text-card-foreground |
| Description | font-sans (Adelle Sans) | text-sm (14px) | font-normal | leading-5 (20px) | text-muted-foreground |
Container
| Property | Value |
|---|---|
| Layout | grid — three tracks: icon · content · action |
| Columns | grid-cols-[auto_1fr_auto] |
| Rows | grid-rows-[auto_auto] — title, description |
| Gap | none in either axis — spacing rides on the items (mr-1.5 on the icon, ml-3 on the action, mt-1 on the description) |
| Background | bg-card (every variant) |
| Border | border (default border color, every variant) |
| Border radius | rounded-lg |
| Padding | py-3 pl-6 pr-4 — extra leading padding clears the rail |
| Text | text-sm |
| Width | w-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
| Property | Value |
|---|---|
| Element | ::before pseudo-element on the container |
| Position | absolute — required, or it would become a grid item and claim a cell |
| Inset | inset-y-[5px] left-[5px] |
| Width | w-1 (4px) |
| Radius | rounded-full |
| Color | per variant — see the Variants table |
Icon
| Property | Value |
|---|---|
| Size | size-4 (16px) |
| Column | track 1 |
| Trailing margin | mr-1.5 (6px) |
| Vertical offset | translate-y-0.5 (aligns with the title's cap height) |
| Color | matches the rail, set by the variant |
Title
font-medium,tracking-tightline-clamp-1— truncates to a single line to keep alerts compact (min-h-4reserves height)col-start-2text-card-foregroundinherited from the container in every variant
Description
text-muted-foreground text-sm,col-start-2text-balance md:text-prettyfor 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. Notself-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; passclassName="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>
| Prop | Default | Notes |
|---|---|---|
defaultOpen | false | Starts 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
| Icon | Use For | Variant |
|---|---|---|
CircleCheck | Success messages | success |
CircleX | Errors | destructive / error |
Info | Informational notices | default / info |
TriangleAlert | Warnings | warning |
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
| Meaning | Implementation |
|---|---|
| Informational / info banner | <Alert> or variant="default" |
| Error, failure | variant="destructive" |
| Caution | variant="warning" |
| Confirmation, success | variant="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:
| Component | Live region |
|---|---|
Alert | role="alert", always |
FormMessage | role="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:
| Call | Rendered | Use 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 role | A 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 color | On light card | On dark card (#191A1A) |
|---|---|---|
| navy-700 → navy-400 | 7.89:1 | 6.19:1 |
| berry-700 → berry-400 | 6.03:1 | 6.53:1 |
| orange-700 → orange-400 | 4.20:1 | 9.24:1 |
| sage-600 → sage-400 | 5.48:1 | 6.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
| Problem | Solution |
|---|---|
| Wanted a tinted background | Not supported — the rail carries the variant. Don't add bg-tc-*-50 via className. |
| Icon is the wrong color | Don't set a color class on the icon; the variant colors it via [&>svg] |
| Icon not aligned with the title | Handled automatically by translate-y-0.5 on the SVG |
| Need a dismissible alert | Add a close button inside AlertAction; Alert doesn't auto-dismiss |
| Long action label overlaps text | It can't — AlertAction is a real grid track, so content shrinks instead |
| Adding an action opened a gap under the title | Fixed — 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 missing | Check nothing in className overrides before: utilities or sets position: static |
error vs destructive | Identical; prefer destructive. Using error or info logs a dev-only console warning. |
No editor strikethrough on info / error | Expected. 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
- Related Components: Alert Dialog (blocking confirmation), Sonner (toast notifications)
- Patterns: Component Match
Figma Reference
Last updated: August 12, 2026