Action alerts
Five alerts that carry a next step: a working dismiss control, a quiet text link, a button pair, a trailing action on the title row and full-width stacked buttons. The surface stays calm on purpose so the action leads. Every control keeps a visible focus ring. Each is sized, driven by a tone prop (info, success, warning, danger) and fully token-based.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/alert-actionDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/alert-action.tsx"use client"
import * as React from "react"
import { ArrowRight, CircleAlert, CircleCheck, Info, TriangleAlert, X } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Action alert family: 5 decorative alert surfaces. The shared idea is that the
alert carries a NEXT STEP - a dismiss control and/or action buttons.
Dismissing REALLY works: internal state plus an AnimatePresence exit
animation (only a fade under reduced-motion). The surface stays deliberately
calm (card plus a thin border) so attention goes to the action. Colour comes
ONLY from semantic tokens; alpha via color-mix or the tailwind opacity
modifier. Anatomy (Berkay's decision): the icon marks the TITLE ROW ONLY -
icon and title share line 1, the description starts BELOW the icon and takes
the full width; actions sit on their own row below the description. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
export type StyledTone = "info" | "success" | "warning" | "danger"
const pad: Record<StyledSize, string> = {
sm: "p-3 text-sm",
md: "p-4 text-sm",
lg: "p-4 text-base",
xl: "p-5 text-base",
}
/* Grid columns change per variant, so there is NO grid-cols on the base. The
icon is a direct child, so the [&>svg] / [&>i] pair (lucide <svg> and
remixicon <i> compatibility) lives here. */
const base =
"relative grid w-full items-start gap-x-2 gap-y-0.5 rounded-lg [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:translate-y-0.5 [&>i]:block [&>i]:size-4 [&>i]:shrink-0 [&>i]:translate-y-0.5 [&>i]:text-base [&>i]:leading-none"
const cols2 = "grid-cols-[calc(var(--spacing)*4)_1fr]"
const cols3 = "grid-cols-[calc(var(--spacing)*4)_1fr_auto]"
/* Tone -> varsayilan lucide ikon. */
const toneIcon: Record<StyledTone, React.ReactNode> = {
info: <Info />,
success: <CircleCheck />,
warning: <TriangleAlert />,
danger: <CircleAlert />,
}
/* Icon colour comes from the root; the [&>i] pair is required for remixicon
compatibility. */
const iconTone: Record<StyledTone, string> = {
info: "[&>svg]:text-info [&>i]:text-info",
success: "[&>svg]:text-success [&>i]:text-success",
warning: "[&>svg]:text-warning-soft-foreground [&>i]:text-warning-soft-foreground",
danger: "[&>svg]:text-danger [&>i]:text-danger",
}
/* A calm card surface plus a thin border tinted by tone. Identical across all
variants. */
const surfaceTone: Record<StyledTone, string> = {
info: "border border-info/30 bg-card text-card-foreground shadow-xs",
success: "border border-success/30 bg-card text-card-foreground shadow-xs",
warning: "border border-warning/40 bg-card text-card-foreground shadow-xs",
danger: "border border-danger/30 bg-card text-card-foreground shadow-xs",
}
/* Eylem butonu temeli: focus-visible ring zorunlu, ikon-uyum cifti dahil. */
const actionBtn =
"inline-flex h-8 shrink-0 select-none items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-sm font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
const primaryBtn = cn(
actionBtn,
"bg-primary text-primary-foreground hover:bg-[color-mix(in_oklab,var(--color-primary)_88%,var(--color-background))]"
)
const ghostBtn = cn(
actionBtn,
"text-foreground hover:bg-[color-mix(in_oklab,var(--color-foreground)_7%,transparent)]"
)
/* Metin baglantisi gibi okunan eylem. */
const linkBtn =
"inline-flex items-center gap-1 rounded-sm text-sm font-medium underline underline-offset-4 outline-none transition-colors hover:no-underline focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
/* The close button is 24px (size-6), so after:-inset-2 enlarges its touch
target. The button is absolutely positioned, so a containing block for after
already exists. */
const closeBtn =
"absolute right-2 top-2 inline-flex size-6 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors after:absolute after:-inset-2 hover:bg-[color-mix(in_oklab,var(--color-foreground)_7%,transparent)] hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
type AlertProps = Omit<React.ComponentProps<"div">, "title"> & {
size?: StyledSize
tone?: StyledTone
title?: React.ReactNode
icon?: React.ReactNode
}
/* Shared shell: the column count, the description span, the action row and the
trailing node that goes into the title row all come from outside. */
function Frame({
size,
tone,
title,
icon,
children,
className,
cols = cols2,
span = "col-span-2",
actions,
trailing,
...props
}: Omit<AlertProps, "size" | "tone"> & {
size: StyledSize
tone: StyledTone
cols?: string
span?: string
actions?: React.ReactNode
trailing?: React.ReactNode
}) {
return (
<div
data-slot="styled-alert"
data-tone={tone}
role="alert"
className={cn(base, cols, pad[size], iconTone[tone], surfaceTone[tone], className)}
{...props}
>
{icon ?? toneIcon[tone]}
<div
data-slot="styled-alert-title"
className="col-start-2 min-h-4 font-medium tracking-tight"
>
{title}
</div>
{trailing}
<div
data-slot="styled-alert-description"
className={cn("col-start-1 text-sm opacity-90 [&_p]:leading-relaxed", span)}
>
{children}
</div>
{actions ? (
<div
data-slot="styled-alert-actions"
className={cn("col-start-1 mt-3 flex items-center gap-2", span)}
>
{actions}
</div>
) : null}
</div>
)
}
/* Dismiss: a real close control in the top right. Internal state; once closed
the component renders nothing. The AnimatePresence exit falls back to a fade
only under reduced-motion (no height or transform). */
export function DismissAlert({
className,
size = "md",
tone = "info",
title = "Heads up",
children = "A real dismiss control backed by internal state.",
icon,
...props
}: AlertProps) {
const reduce = useReducedMotion()
const [open, setOpen] = React.useState(true)
return (
<AnimatePresence initial={false}>
{open ? (
<motion.div
initial={false}
exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0, marginBottom: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="overflow-hidden"
>
<Frame
size={size}
tone={tone}
title={title}
icon={icon}
className={cn("pr-10", className)}
trailing={
<button
type="button"
aria-label="Dismiss"
className={closeBtn}
onClick={() => setOpen(false)}
>
<X />
</button>
}
{...props}
>
{children}
</Frame>
</motion.div>
) : null}
</AnimatePresence>
)
}
/* Link: aciklamanin altinda tek, hafif bir metin eylemi. En dusuk sesli eylem
varyanti. */
export function LinkAlert({
className,
size = "md",
tone = "info",
title = "Heads up",
children = "A single quiet text action under the description.",
icon,
...props
}: AlertProps) {
return (
<Frame
size={size}
tone={tone}
title={title}
icon={icon}
className={className}
actions={
<button type="button" className={linkBtn}>
Learn more
<ArrowRight />
</button>
}
{...props}
>
{children}
</Frame>
)
}
/* Buttons: birincil + ikincil eylem cifti. Klasik onay/vazgec duzeni. */
export function ButtonsAlert({
className,
size = "md",
tone = "info",
title = "Heads up",
children = "A primary and a secondary action under the description.",
icon,
...props
}: AlertProps) {
return (
<Frame
size={size}
tone={tone}
title={title}
icon={icon}
className={className}
actions={
<>
<button type="button" className={primaryBtn}>
Confirm
</button>
<button type="button" className={ghostBtn}>
Cancel
</button>
</>
}
{...props}
>
{children}
</Frame>
)
}
/* InlineAction: eylem baslik satirinin sagina girer, aciklama yine ikonun
altindan tam genislikte akar. 3 sutunlu grid. */
export function InlineActionAlert({
className,
size = "md",
tone = "info",
title = "Heads up",
children = "The action sits on the title row, not below the text.",
icon,
...props
}: AlertProps) {
return (
<Frame
size={size}
tone={tone}
title={title}
icon={icon}
cols={cols3}
span="col-span-3"
className={className}
trailing={
<button type="button" className={cn(ghostBtn, "col-start-3 -my-1 self-start")}>
Review
</button>
}
{...props}
>
{children}
</Frame>
)
}
/* Stacked: full-width actions stacked on top of each other. Stays readable in
narrow columns and mobile layouts. */
export function StackedAlert({
className,
size = "md",
tone = "info",
title = "Heads up",
children = "Full-width actions stacked for narrow columns.",
icon,
...props
}: AlertProps) {
return (
<Frame
size={size}
tone={tone}
title={title}
icon={icon}
className={className}
actions={
<div className="flex w-full flex-col gap-2">
<button type="button" className={cn(primaryBtn, "w-full")}>
Update now
</button>
<button type="button" className={cn(ghostBtn, "w-full")}>
Remind me later
</button>
</div>
}
{...props}
>
{children}
</Frame>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Dismiss
A working close button; internal state removes the alert.
import { DismissAlert } from "@/components/ui/alert-action"
<DismissAlert tone="info" title="Heads up">A real dismiss control backed by internal state.</DismissAlert>Link
One quiet text action under the description.
import { LinkAlert } from "@/components/ui/alert-action"
<LinkAlert tone="info" title="New release">A single quiet text action under the description.</LinkAlert>Buttons
A primary and secondary button pair under the description.
import { ButtonsAlert } from "@/components/ui/alert-action"
<ButtonsAlert tone="warning" title="Confirm change">A primary and a secondary action under the description.</ButtonsAlert>Inline action
A trailing action on the title row, on a three-column grid.
import { InlineActionAlert } from "@/components/ui/alert-action"
<InlineActionAlert tone="success" title="Review ready">The action sits on the title row, not below the text.</InlineActionAlert>Stacked
Full-width actions stacked for narrow columns and mobile.
import { StackedAlert } from "@/components/ui/alert-action"
<StackedAlert tone="danger" title="Update required">Full-width actions stacked for narrow columns.</StackedAlert>ai2 Action alerts: 5 styled variations on the token system
The ai2 Action alerts are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around alerts that carry a dismiss control or trailing actions. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: framer-motion animates the exit when the dismiss variant is closed; the rest are static. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the height collapse is skipped and the alert simply fades out.
What is in the ai2 Action alerts?
5 exports in one file: Dismiss, Link, Buttons, Inline action and Stacked. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: framer-motion animates the exit when the dismiss variant is closed; the rest are static.
- Reduced-motion aware: Under prefers-reduced-motion, the height collapse is skipped and the alert simply fades out.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Action alerts sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.