Component
Dismissible Alert
An alert the user can dismiss: a real close button with an accessible name, controlled (`open` + `onDismiss`) or uncontrolled (`defaultOpen`) state, and focus management that never strands keyboard users when the alert unmounts.
- Component
- React
- TSX
- Tailwind CSS
- MIT
Preview
Live component
9:41 100%
devsnips.dev/library/react/components/alerts/alert-dismissible/ fluid · no fixed width 100%
Install
Add to your project
npx devsnips add React/Components/Alerts/alert-dismissible React/Components/Alerts/alert-dismissible import {
createContext,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import type {
ButtonHTMLAttributes,
HTMLAttributes,
ReactNode,
} from "react";
/**
* DevSnips React Alert — Dismissible.
*
* The shared alert core; this variant demonstrates dismissal: the
* `dismissible` prop appends a real `<AlertClose>` button (accessible name,
* keyboard-operable), visibility is controlled (`open` + `onDismiss`) or
* uncontrolled (`defaultOpen`), and focus moves to the next operable
* element before the alert unmounts.
*/
function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
export type AlertVariant = "default" | "info" | "success" | "warning" | "destructive";
export type AlertSize = "md" | "sm";
export type AlertRole = "status" | "alert";
const ROOT_BASE_CLASSES =
"flex w-full min-w-0 items-start rounded-[var(--ds-radius-md)] border text-left text-[var(--ds-color-foreground)]";
const SIZE_CLASSES: Record<AlertSize, string> = {
md: "gap-3 px-4 py-3",
sm: "gap-2.5 px-3 py-2",
};
// Semantic variants derive their tint from the semantic token via color-mix
// (the same derivation recipe the buttons use for hover), so no component-
// specific color values are invented and dark mode stays in sync for free.
const VARIANT_CLASSES: Record<AlertVariant, string> = {
default:
"border-[var(--ds-color-border)] bg-[var(--ds-color-surface)]",
info: "border-[color-mix(in_srgb,var(--ds-color-info)_35%,var(--ds-color-border))] bg-[color-mix(in_srgb,var(--ds-color-info)_7%,var(--ds-color-surface))]",
success:
"border-[color-mix(in_srgb,var(--ds-color-success)_35%,var(--ds-color-border))] bg-[color-mix(in_srgb,var(--ds-color-success)_8%,var(--ds-color-surface))]",
warning:
"border-[color-mix(in_srgb,var(--ds-color-warning)_40%,var(--ds-color-border))] bg-[color-mix(in_srgb,var(--ds-color-warning)_10%,var(--ds-color-surface))]",
destructive:
"border-[color-mix(in_srgb,var(--ds-color-destructive)_35%,var(--ds-color-border))] bg-[color-mix(in_srgb,var(--ds-color-destructive)_7%,var(--ds-color-surface))]",
};
// Urgency, not decoration: informational feedback is polite, failures and
// cautions that need prompt attention are assertive. Matches the live-region
// conventions used across the DevSnips notification patterns.
const DEFAULT_ROLE: Record<AlertVariant, AlertRole> = {
default: "status",
info: "status",
success: "status",
warning: "alert",
destructive: "alert",
};
const ICON_TONE_CLASSES: Record<AlertVariant, string> = {
default: "text-[var(--ds-color-muted-foreground)]",
info: "text-[var(--ds-color-info)]",
success: "text-[var(--ds-color-success)]",
warning: "text-[var(--ds-color-warning)]",
destructive: "text-[var(--ds-color-destructive)]",
};
const GLYPH_PROPS = {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.75,
strokeLinecap: "round",
strokeLinejoin: "round",
className: "size-4",
} as const;
// Small, restrained status glyphs (lucide-style, 24px grid, currentColor).
// They are supplements to the text + role, never the sole carrier of meaning.
const VARIANT_GLYPHS: Record<AlertVariant, ReactNode> = {
default: null,
info: (
<svg {...GLYPH_PROPS}>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
),
success: (
<svg {...GLYPH_PROPS}>
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</svg>
),
warning: (
<svg {...GLYPH_PROPS}>
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>
),
destructive: (
<svg {...GLYPH_PROPS}>
<circle cx="12" cy="12" r="10" />
<path d="m15 9-6 6" />
<path d="m9 9 6 6" />
</svg>
),
};
const CLOSE_GLYPH = (
<svg {...GLYPH_PROPS} className="size-3.5" aria-hidden="true">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
);
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
// When dismissal removes the focused close button from the DOM, the browser
// would drop focus to <body>. Move focus to the next operable element in
// document order (or the previous one at the end of the page) BEFORE the
// alert unmounts, so keyboard users never lose their place.
function moveFocusOut(alertEl: HTMLElement): void {
const candidates = Array.from(
document.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
).filter((el) => !alertEl.contains(el) && el.getClientRects().length > 0);
const after = candidates.find(
(el) => (alertEl.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0,
);
const target = after ?? candidates[candidates.length - 1];
target?.focus();
}
/* ------------------------------------------------------------------------ */
/* Alert context */
/* ------------------------------------------------------------------------ */
interface AlertContextValue {
variant: AlertVariant;
size: AlertSize;
titleId: string;
descriptionId: string;
hasTitle: boolean;
hasDescription: boolean;
registerTitle(): () => void;
registerDescription(): () => void;
dismiss(): void;
closeLabel: string;
}
const AlertContext = createContext<AlertContextValue | null>(null);
function useAlert(component: string): AlertContextValue {
const context = useContext(AlertContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <Alert>.`);
}
return context;
}
/* ------------------------------------------------------------------------ */
/* Alert (root surface + dismissal state) */
/* ------------------------------------------------------------------------ */
// `role` and `size` are omitted from the forwarded div attributes because the
// Alert API narrows them (`role` is limited to live-region roles, `size` is
// the density scale, not the obsolete HTML attribute).
export interface AlertProps extends Omit<HTMLAttributes<HTMLDivElement>, "role" | "size"> {
/** Semantic intent: colors the surface, picks the default icon and role. */
variant?: AlertVariant;
/** Density: `md` default, `sm` for dense interfaces (reduced padding/gap). */
size?: AlertSize;
/**
* Live-region role. Defaults to `status` (polite) for default/info/success
* and `alert` (assertive) for warning/destructive. Pass `null` for static
* page content that must not announce itself.
*/
role?: AlertRole | null;
/** Render a trailing `AlertClose` button wired to the dismissal state. */
dismissible?: boolean;
/** Controlled visibility (with `onDismiss`). */
open?: boolean;
/** Initial visibility when uncontrolled (default `true`). */
defaultOpen?: boolean;
/** Called when the user dismisses the alert (close button activated). */
onDismiss?: () => void;
/**
* Leading icon: `undefined` renders the variant's semantic glyph (none for
* `default`), a ReactNode replaces it, and `null` hides the icon entirely.
*/
icon?: ReactNode;
/** Accessible name for the auto-rendered close button. */
closeLabel?: string;
className?: string;
children?: ReactNode;
}
export function Alert({
variant = "default",
size = "md",
role,
dismissible = false,
open,
defaultOpen = true,
onDismiss,
icon,
closeLabel = "Dismiss alert",
className,
children,
id,
...rest
}: AlertProps) {
const generatedId = useId();
const alertId = id ?? `alert-${generatedId}`;
const titleId = `${alertId}-title`;
const descriptionId = `${alertId}-description`;
const [hasTitle, setHasTitle] = useState(false);
const [hasDescription, setHasDescription] = useState(false);
const registerTitle = useCallback(() => {
setHasTitle(true);
return () => setHasTitle(false);
}, []);
const registerDescription = useCallback(() => {
setHasDescription(true);
return () => setHasDescription(false);
}, []);
const isControlled = open !== undefined;
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isOpen = isControlled ? open : internalOpen;
const rootRef = useRef<HTMLDivElement | null>(null);
const dismiss = useCallback(() => {
const el = rootRef.current;
if (el && el.contains(document.activeElement)) moveFocusOut(el);
if (!isControlled) setInternalOpen(false);
onDismiss?.();
}, [isControlled, onDismiss]);
const context = useMemo<AlertContextValue>(
() => ({
variant,
size,
titleId,
descriptionId,
hasTitle,
hasDescription,
registerTitle,
registerDescription,
dismiss,
closeLabel,
}),
[
variant,
size,
titleId,
descriptionId,
hasTitle,
hasDescription,
registerTitle,
registerDescription,
dismiss,
closeLabel,
],
);
if (!isOpen) return null;
const resolvedRole = role === undefined ? DEFAULT_ROLE[variant] : role ?? undefined;
const showIcon =
icon === undefined ? variant !== "default" : icon !== null && icon !== false;
return (
<AlertContext.Provider value={context}>
<div
ref={rootRef}
id={alertId}
role={resolvedRole}
aria-labelledby={hasTitle ? titleId : undefined}
aria-describedby={hasDescription ? descriptionId : undefined}
className={cx(ROOT_BASE_CLASSES, SIZE_CLASSES[size], VARIANT_CLASSES[variant], className)}
{...rest}
>
{showIcon ? <AlertIcon>{icon === undefined ? undefined : icon}</AlertIcon> : null}
<div className="flex min-w-0 flex-1 flex-col gap-1">{children}</div>
{dismissible ? <AlertClose /> : null}
</div>
</AlertContext.Provider>
);
}
/* ------------------------------------------------------------------------ */
/* AlertIcon */
/* ------------------------------------------------------------------------ */
export interface AlertIconProps extends HTMLAttributes<HTMLSpanElement> {
/** Custom glyph; defaults to the alert variant's semantic icon. */
children?: ReactNode;
}
/**
* The leading icon slot. Always `aria-hidden`: the icon supplements the
* variant's role + text, it never carries meaning on its own (so there is
* nothing to hide from — and nothing lost for — assistive technology).
*/
export function AlertIcon({ className, children, ...rest }: AlertIconProps) {
const { variant } = useAlert("AlertIcon");
return (
<span
aria-hidden="true"
className={cx(
"mt-0.5 inline-flex size-4 shrink-0 items-center justify-center",
ICON_TONE_CLASSES[variant],
className,
)}
{...rest}
>
{children ?? VARIANT_GLYPHS[variant]}
</span>
);
}
/* ------------------------------------------------------------------------ */
/* AlertTitle / AlertDescription */
/* ------------------------------------------------------------------------ */
export interface AlertTitleProps extends HTMLAttributes<HTMLParagraphElement> {
children?: ReactNode;
}
/**
* The alert's headline — a styled `<p>`, not a heading: alerts are feedback
* regions, not document structure, so they stay out of the page outline.
* Registers itself so the root wires `aria-labelledby` only when a title
* exists.
*/
export function AlertTitle({ className, children, ...rest }: AlertTitleProps) {
const context = useAlert("AlertTitle");
useEffect(() => context.registerTitle(), [context]);
return (
<p
id={context.titleId}
className={cx("m-0 break-words text-sm font-medium leading-5 text-[var(--ds-color-foreground)]", className)}
{...rest}
>
{children}
</p>
);
}
export interface AlertDescriptionProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
/**
* Supporting content (a `<div>`, so lists and links are valid children).
* Registers itself so the root wires `aria-describedby` only when a
* description exists.
*/
export function AlertDescription({ className, children, ...rest }: AlertDescriptionProps) {
const context = useAlert("AlertDescription");
useEffect(() => context.registerDescription(), [context]);
return (
<div
id={context.descriptionId}
className={cx("break-words text-sm leading-5 text-[var(--ds-color-muted-foreground)]", className)}
{...rest}
>
{children}
</div>
);
}
/* ------------------------------------------------------------------------ */
/* AlertAction */
/* ------------------------------------------------------------------------ */
export interface AlertActionProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
}
/**
* The actions row, rendered inside the text column below the description.
* `flex-wrap` keeps real `<button>` / `<a>` children usable at narrow
* widths — they wrap instead of overflowing. Compose only real controls
* here; never put a control inside another control.
*/
export function AlertAction({ className, children, ...rest }: AlertActionProps) {
return (
<div className={cx("mt-1.5 flex flex-wrap items-center gap-2", className)} {...rest}>
{children}
</div>
);
}
/* ------------------------------------------------------------------------ */
/* AlertClose */
/* ------------------------------------------------------------------------ */
export interface AlertCloseProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Accessible name (icon-only button); defaults to the root's `closeLabel`. */
label?: string;
}
/**
* A real `<button type="button">` that dismisses the nearest `<Alert>`.
* Icon-only, so it always carries an accessible name; keyboard users reach
* it with Tab and activate it with Enter/Space. Call `event.preventDefault()`
* from a custom `onClick` to veto the dismissal.
*/
export function AlertClose({
label,
className,
onClick,
type,
children,
...rest
}: AlertCloseProps) {
const { dismiss, closeLabel, size } = useAlert("AlertClose");
return (
<button
type={type ?? "button"}
aria-label={label ?? closeLabel}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) dismiss();
}}
className={cx(
"inline-flex shrink-0 items-center justify-center rounded-[var(--ds-radius-sm)] text-[var(--ds-color-muted-foreground)] transition-colors duration-150 ease-out hover:bg-[color-mix(in_srgb,currentColor_8%,transparent)] hover:text-[var(--ds-color-foreground)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none",
size === "sm" ? "-my-0.5 -mr-1.5 size-7" : "-my-1 -mr-2 size-8",
className,
)}
{...rest}
>
{children ?? CLOSE_GLYPH}
</button>
);
}
export default Alert; /* DevSnips React — JavaScript parity build.
* Same API, behavior, and classes as code.tsx; TypeScript types removed.
* Regenerated from code.tsx — edit code.tsx and re-run the generator.
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState
} from "react";
function cx(...parts) {
return parts.filter(Boolean).join(" ");
}
const ROOT_BASE_CLASSES = "flex w-full min-w-0 items-start rounded-[var(--ds-radius-md)] border text-left text-[var(--ds-color-foreground)]";
const SIZE_CLASSES = {
md: "gap-3 px-4 py-3",
sm: "gap-2.5 px-3 py-2"
};
const VARIANT_CLASSES = {
default: "border-[var(--ds-color-border)] bg-[var(--ds-color-surface)]",
info: "border-[color-mix(in_srgb,var(--ds-color-info)_35%,var(--ds-color-border))] bg-[color-mix(in_srgb,var(--ds-color-info)_7%,var(--ds-color-surface))]",
success: "border-[color-mix(in_srgb,var(--ds-color-success)_35%,var(--ds-color-border))] bg-[color-mix(in_srgb,var(--ds-color-success)_8%,var(--ds-color-surface))]",
warning: "border-[color-mix(in_srgb,var(--ds-color-warning)_40%,var(--ds-color-border))] bg-[color-mix(in_srgb,var(--ds-color-warning)_10%,var(--ds-color-surface))]",
destructive: "border-[color-mix(in_srgb,var(--ds-color-destructive)_35%,var(--ds-color-border))] bg-[color-mix(in_srgb,var(--ds-color-destructive)_7%,var(--ds-color-surface))]"
};
const DEFAULT_ROLE = {
default: "status",
info: "status",
success: "status",
warning: "alert",
destructive: "alert"
};
const ICON_TONE_CLASSES = {
default: "text-[var(--ds-color-muted-foreground)]",
info: "text-[var(--ds-color-info)]",
success: "text-[var(--ds-color-success)]",
warning: "text-[var(--ds-color-warning)]",
destructive: "text-[var(--ds-color-destructive)]"
};
const GLYPH_PROPS = {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.75,
strokeLinecap: "round",
strokeLinejoin: "round",
className: "size-4"
};
const VARIANT_GLYPHS = {
default: null,
info: <svg {...GLYPH_PROPS}>
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>,
success: <svg {...GLYPH_PROPS}>
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</svg>,
warning: <svg {...GLYPH_PROPS}>
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>,
destructive: <svg {...GLYPH_PROPS}>
<circle cx="12" cy="12" r="10" />
<path d="m15 9-6 6" />
<path d="m9 9 6 6" />
</svg>
};
const CLOSE_GLYPH = <svg {...GLYPH_PROPS} className="size-3.5" aria-hidden="true">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>;
const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
function moveFocusOut(alertEl) {
const candidates = Array.from(
document.querySelectorAll(FOCUSABLE_SELECTOR)
).filter((el) => !alertEl.contains(el) && el.getClientRects().length > 0);
const after = candidates.find(
(el) => (alertEl.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
);
const target = after ?? candidates[candidates.length - 1];
target?.focus();
}
const AlertContext = createContext(null);
function useAlert(component) {
const context = useContext(AlertContext);
if (!context) {
throw new Error(`<${component}> must be rendered inside <Alert>.`);
}
return context;
}
function Alert({
variant = "default",
size = "md",
role,
dismissible = false,
open,
defaultOpen = true,
onDismiss,
icon,
closeLabel = "Dismiss alert",
className,
children,
id,
...rest
}) {
const generatedId = useId();
const alertId = id ?? `alert-${generatedId}`;
const titleId = `${alertId}-title`;
const descriptionId = `${alertId}-description`;
const [hasTitle, setHasTitle] = useState(false);
const [hasDescription, setHasDescription] = useState(false);
const registerTitle = useCallback(() => {
setHasTitle(true);
return () => setHasTitle(false);
}, []);
const registerDescription = useCallback(() => {
setHasDescription(true);
return () => setHasDescription(false);
}, []);
const isControlled = open !== undefined;
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isOpen = isControlled ? open : internalOpen;
const rootRef = useRef(null);
const dismiss = useCallback(() => {
const el = rootRef.current;
if (el && el.contains(document.activeElement)) moveFocusOut(el);
if (!isControlled) setInternalOpen(false);
onDismiss?.();
}, [isControlled, onDismiss]);
const context = useMemo(
() => ({
variant,
size,
titleId,
descriptionId,
hasTitle,
hasDescription,
registerTitle,
registerDescription,
dismiss,
closeLabel
}),
[
variant,
size,
titleId,
descriptionId,
hasTitle,
hasDescription,
registerTitle,
registerDescription,
dismiss,
closeLabel
]
);
if (!isOpen) return null;
const resolvedRole = role === undefined ? DEFAULT_ROLE[variant] : role ?? undefined;
const showIcon = icon === undefined ? variant !== "default" : icon !== null && icon !== false;
return <AlertContext.Provider value={context}>
<div
ref={rootRef}
id={alertId}
role={resolvedRole}
aria-labelledby={hasTitle ? titleId : undefined}
aria-describedby={hasDescription ? descriptionId : undefined}
className={cx(ROOT_BASE_CLASSES, SIZE_CLASSES[size], VARIANT_CLASSES[variant], className)}
{...rest}
>
{showIcon ? <AlertIcon>{icon === undefined ? undefined : icon}</AlertIcon> : null}
<div className="flex min-w-0 flex-1 flex-col gap-1">{children}</div>
{dismissible ? <AlertClose /> : null}
</div>
</AlertContext.Provider>;
}
function AlertIcon({ className, children, ...rest }) {
const { variant } = useAlert("AlertIcon");
return <span
aria-hidden="true"
className={cx(
"mt-0.5 inline-flex size-4 shrink-0 items-center justify-center",
ICON_TONE_CLASSES[variant],
className
)}
{...rest}
>
{children ?? VARIANT_GLYPHS[variant]}
</span>;
}
function AlertTitle({ className, children, ...rest }) {
const context = useAlert("AlertTitle");
useEffect(() => context.registerTitle(), [context]);
return <p
id={context.titleId}
className={cx("m-0 break-words text-sm font-medium leading-5 text-[var(--ds-color-foreground)]", className)}
{...rest}
>
{children}
</p>;
}
function AlertDescription({ className, children, ...rest }) {
const context = useAlert("AlertDescription");
useEffect(() => context.registerDescription(), [context]);
return <div
id={context.descriptionId}
className={cx("break-words text-sm leading-5 text-[var(--ds-color-muted-foreground)]", className)}
{...rest}
>
{children}
</div>;
}
function AlertAction({ className, children, ...rest }) {
return <div className={cx("mt-1.5 flex flex-wrap items-center gap-2", className)} {...rest}>
{children}
</div>;
}
function AlertClose({
label,
className,
onClick,
type,
children,
...rest
}) {
const { dismiss, closeLabel, size } = useAlert("AlertClose");
return <button
type={type ?? "button"}
aria-label={label ?? closeLabel}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) dismiss();
}}
className={cx(
"inline-flex shrink-0 items-center justify-center rounded-[var(--ds-radius-sm)] text-[var(--ds-color-muted-foreground)] transition-colors duration-150 ease-out hover:bg-[color-mix(in_srgb,currentColor_8%,transparent)] hover:text-[var(--ds-color-foreground)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ds-color-focus-ring)] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none",
size === "sm" ? "-my-0.5 -mr-1.5 size-7" : "-my-1 -mr-2 size-8",
className
)}
{...rest}
>
{children ?? CLOSE_GLYPH}
</button>;
}
export { Alert, AlertIcon, AlertTitle, AlertDescription, AlertAction, AlertClose };
export default Alert; # Dismissible Alert
An alert the user can dismiss: a real close button with an accessible name, controlled (`open` + `onDismiss`) or uncontrolled (`defaultOpen`) state, and focus management that never strands keyboard users when the alert unmounts.
## Installation
This component requires **React** and **Tailwind CSS**. Drop `code.tsx` (or `code.jsx` for JavaScript projects) into your project. Tailwind utility classes are included directly in the component, so no separate CSS file is required.
The component consumes the DevSnips semantic design tokens through Tailwind arbitrary values (for example `bg-[var(--ds-color-surface)]`). Define the `--ds-*` tokens once in your theme — see [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) for the full token spec.
## Usage
```tsx
import Alert, {
AlertTitle,
AlertDescription,
} from "./alert-dismissible";
// Uncontrolled
<Alert variant="info" dismissible onDismiss={() => persistDismissal()}>
<AlertTitle>Usage resets on the 1st</AlertTitle>
<AlertDescription>Your quota renews each billing cycle.</AlertDescription>
</Alert>
// Controlled
const [open, setOpen] = useState(true);
<Alert
variant="info"
dismissible
open={open}
onDismiss={() => setOpen(false)}
>
…
</Alert>
```
## JavaScript
A `code.jsx` build is provided for projects that ship plain JSX. It exposes the same API and behavior as `code.tsx` — only the TypeScript types are removed.
```jsx
import Alert, {
AlertTitle,
AlertDescription,
} from "./alert-dismissible";
// Uncontrolled
<Alert variant="info" dismissible onDismiss={() => persistDismissal()}>
<AlertTitle>Usage resets on the 1st</AlertTitle>
<AlertDescription>Your quota renews each billing cycle.</AlertDescription>
</Alert>
// Controlled
const [open, setOpen] = useState(true);
<Alert
variant="info"
dismissible
open={open}
onDismiss={() => setOpen(false)}
>
…
</Alert>
```
## Props
### `<Alert>`
| Name | Type | Default | Description |
|---|---|---|---|
| `variant` | `"default" \| "info" \| "success" \| "warning" \| "destructive"` | `"default"` | Semantic intent: tints the surface, picks the default icon, and picks the default live-region role. |
| `size` | `"md" \| "sm"` | `"md"` | Density: `sm` reduces padding/gap for dense interfaces. |
| `role` | `"status" \| "alert" \| null` | derived from `variant` | Live-region role: `status` (polite) for default/info/success, `alert` (assertive) for warning/destructive. Pass `null` for static page content that must not announce itself. |
| `dismissible` | `boolean` | `false` | Render a trailing `AlertClose` wired to the dismissal state. |
| `open` | `boolean` | — | Controlled visibility (with `onDismiss`). |
| `defaultOpen` | `boolean` | `true` | Initial visibility when uncontrolled. |
| `onDismiss` | `() => void` | — | Called when the user dismisses the alert via the close button. |
| `icon` | `ReactNode` | variant glyph | `undefined` renders the variant's semantic icon (none for `default`), a ReactNode replaces it, `null` hides it. |
| `closeLabel` | `string` | `"Dismiss alert"` | Accessible name for the auto-rendered close button. |
| `id` | `string` | generated | Root element id; the title/description ids derive from it. |
| `className` | `string` | — | Extra classes on the surface. |
| `children` | `ReactNode` | — | `AlertTitle`, `AlertDescription`, `AlertAction`, `AlertClose` compositions. |
Every other attribute of a plain `<div>` (`aria-*`, `data-*`, …) is forwarded — including `aria-live`, for the rare case the role's implicit live behavior needs adjusting.
### `<AlertClose>`
| Name | Type | Default | Description |
|---|---|---|---|
| `label` | `string` | root's `closeLabel` | Accessible name for the icon-only button. |
| `onClick` | `(event) => void` | — | Runs before dismissal; call `event.preventDefault()` to veto the dismiss. |
| `children` | `ReactNode` | × glyph | Custom button content (kept `aria-hidden` — the name comes from `label`). |
| `className` | `string` | — | Extra classes on the button. |
A real `<button type="button">` that dismisses the nearest `<Alert>`: Tab reaches it, Enter/Space activates it, and a `focus-visible` ring marks keyboard focus. If the alert was unmounting the focused button, focus moves to the next operable element in document order before removal.
### `<AlertTitle>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the title. |
| `children` | `ReactNode` | — | Title text. |
A styled `<p>`, not a heading — alerts are feedback regions, so they stay out of the page outline. Registers itself with the root, which then wires `aria-labelledby`.
### `<AlertDescription>`
| Name | Type | Default | Description |
|---|---|---|---|
| `className` | `string` | — | Extra classes on the description. |
| `children` | `ReactNode` | — | Supporting content — a `<div>`, so paragraphs, lists, and links are all valid. |
Registers itself with the root, which then wires `aria-describedby`.
## Composition
- `Alert` — the root surface (radius-md, 1px border, token-tinted per variant) and the dismissal state owner (controlled `open` + `onDismiss`, or uncontrolled `defaultOpen`). When `dismissible` it appends a trailing `AlertClose`.
- `AlertIcon` — the leading icon slot; renders the variant's semantic glyph by default, a custom `ReactNode` when given, and is always `aria-hidden` (the role + text carry the meaning).
- `AlertTitle` — the alert headline (a styled `<p>` — alerts are feedback regions, not document headings). Registers itself so the root wires `aria-labelledby` only when a title exists.
- `AlertDescription` — supporting content (a `<div>`, so lists and links are valid children). Registers itself for `aria-describedby`.
- `AlertAction` — the actions row inside the text column; `flex-wrap` keeps real `<button>` / `<a>` children usable at narrow widths.
- `AlertClose` — a real `<button type="button">` with an accessible name that dismisses the nearest `Alert` (auto-rendered when `dismissible`, or composed manually for custom placement).
Compose only the primitives an alert actually needs — a bare `Alert` with an `AlertDescription` is valid; so is the full icon + title + description + action + close composition.
Set `dismissible` and the root appends a trailing `AlertClose` wired to the alert's dismissal state — no manual wiring. For custom placement, compose `<AlertClose />` yourself (it reads the nearest `Alert` context) and leave `dismissible` off.
## Behavior
Dismissal works in both state modes:
- **Uncontrolled** (demo 1): the alert owns its visibility. Clicking the close button unmounts it and `onDismiss` still fires (persist the dismissal there). The "Reset demo" button remounts it.
- **Controlled** (demo 2): the parent owns `open`. Clicking close fires `onDismiss` and the alert hides only when the parent sets `open={false}` — the event log shows the callback firing.
In both modes the close button is a real `<button type="button">` with an accessible name (`closeLabel` on the root, or `label` on `AlertClose`). Because the focused button unmounts on dismissal, focus first moves to the next operable element in document order — try it: Tab to the close button, press Enter, and focus lands on the control after the alert instead of dropping to `<body>`.
## Keyboard Interaction
The alert surface itself is not focusable and carries no keyboard behavior — it is feedback, not a control. Any interactive element composed inside it (an action button, a link) is a real native control: Tab reaches it, Enter/Space activates it, and a `focus-visible` ring (2px, `color.focus-ring` token) marks keyboard focus.
The close button is a real `<button type="button">`: Tab reaches it and Enter/Space activates it. When dismissal removes the focused button from the DOM, focus moves to the next operable element in document order (or the previous one at the end of the page) — it never drops to `<body>`.
## Accessibility
- The root carries a live-region role matched to urgency: `role="status"` (polite) for default/info/success, `role="alert"` (assertive) for warning/destructive — informational messages are never blanket-promoted to `role="alert"`.
- `AlertTitle` / `AlertDescription` register themselves with the root, so `aria-labelledby` / `aria-describedby` always reference real rendered content and are omitted entirely when the region is absent.
- The semantic icon is `aria-hidden="true"`: meaning is carried by the role and text, so state is never communicated by color alone.
- Pass `role={null}` for static page content that should not announce itself (for example an always-visible note rendered at page load).
- The icon-only close button always carries an accessible name (`"Dismiss alert"` by default; customize with `closeLabel` per message).
- Dismissal moves focus to the next operable element before unmounting — keyboard users never lose their place.
## States
- **Surface** — `color.surface` for `default`, or a semantic tint derived via `color-mix` from `color.info` / `color.success` / `color.warning` / `color.destructive`; 1px border (part token, part tint), `radius-md`, no elevation — inline alerts are not floating.
- **Title / description** — body-sm: a medium title on `color.foreground`, muted body on `color.muted-foreground`.
- **Icon** — 16px, colored by the variant's semantic token, decorative to assistive technology.
- **Close button** — muted glyph with a translucent `currentColor` hover wash, a `focus-visible` ring, and native `disabled` styling (50% opacity, no pointer events).
- **Dismissed** — unmounts from the DOM (uncontrolled) or when the parent sets `open={false}` (controlled); `onDismiss` fires in both modes.
## Responsive Behavior
The alert is fluid-width (`w-full min-w-0`) and fills its container at every viewport: at 375px the title and description wrap (long words break), while the icon and close button shrink-wrap instead of pushing text out — the text column is `flex-1` with `min-w-0`. `AlertAction` is `flex-wrap`, so multiple actions wrap to another row instead of overflowing, and the close button stays reachable. No horizontal overflow at 375 / 768 / 1280px.
## Styling
Built with React, Tailwind CSS, and DevSnips design tokens. The component consumes the `--ds-*` semantic tokens via arbitrary values (for example `bg-[var(--ds-color-surface)]`); semantic tints are derived from the semantic tokens with `color-mix`, so no component-specific values are invented. Define the tokens once in your theme — no component-specific CSS file is required.
## Design Tokens
See [React/DESIGN_TOKENS.md](../../../DESIGN_TOKENS.md) for the authoritative token specification. This alert variant follows the token system rules: `radius-md` surfaces, 1px borders, body-sm text, semantic status colors (`color.info` / `color.success` / `color.warning` / `color.destructive`) for tints and icons, and the `color.focus-ring` token for keyboard focus.
## Notes
`AlertClose` supports a veto: call `event.preventDefault()` in a custom `onClick` to stop the dismissal (for example while a save is in flight). 450 lines UTF-8 · LF · Spaces: 2
Continue browsing