Motion alerts
Five alerts that keep one calm surface and vary only how they arrive: a slide from the left, a spring pop, a soft fade, a short shake and a clip-path reveal. The animation runs on mount only, with no loop and no randomness. 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-motionDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/alert-motion.tsx"use client"
import type * as React from "react"
import { CircleAlert, CircleCheck, Info, TriangleAlert } from "lucide-react"
import { motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Motion alert family: 5 decorative alert surfaces. The surface is THE SAME in
every variant (a calm card + a thin tone-tinted border); the whole difference
is in the ENTRANCE animation - how the alert arrives on the page: sliding in
from the side, springing, settling, shaking, or unfolding from the left. The
animation runs only on MOUNT, never loops; every keyframe is written out
literally, so there is no source of time or randomness and every render
produces the same result (deterministic). Under reduced-motion there is NO
transform or clip, everything falls back to a plain fade. Color comes ONLY
from semantic tokens, via alpha color-mix or the tailwind opacity modifier.
Anatomy (Berkay's decision): the icon marks ONLY the title line - the icon and
the title share line 1, and the description starts BELOW the icon and takes
the full width. */
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: icon column plus content. 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 grid-cols-[calc(var(--spacing)*4)_1fr] 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"
/* 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",
}
/* The surface is the same in every variant: a calm card plus a tone-tinted border. */
const surfaceTone: Record<StyledTone, string> = {
info: "border border-info/30 bg-card text-card-foreground shadow-sm",
success: "border border-success/30 bg-card text-card-foreground shadow-sm",
warning: "border border-warning/40 bg-card text-card-foreground shadow-sm",
danger: "border border-danger/30 bg-card text-card-foreground shadow-sm",
}
type AlertProps = Omit<React.ComponentProps<"div">, "title"> & {
size?: StyledSize
tone?: StyledTone
title?: React.ReactNode
icon?: React.ReactNode
}
/* motion.div carries its own animation props that clash with React.ComponentProps<"div">, so we strip the clashing ones. */
type MotionSafeProps = Omit<
React.ComponentProps<"div">,
| "title"
| "onAnimationStart"
| "onAnimationEnd"
| "onAnimationIteration"
| "onDrag"
| "onDragStart"
| "onDragEnd"
| "style"
>
/* The spec types are derived from motion.div's own prop types - that way no extra type import is needed and they stay exactly in step with what framer expects. */
type MotionDivProps = React.ComponentProps<typeof motion.div>
interface MotionSpec {
initial: MotionDivProps["initial"]
animate: MotionDivProps["animate"]
transition: MotionDivProps["transition"]
}
/* Shared shell: only the entry animation changes. When reduced=true the caller already sends the fade spec. */
function Frame({
size,
tone,
title,
icon,
children,
className,
spec,
...props
}: MotionSafeProps & {
size: StyledSize
tone: StyledTone
title?: React.ReactNode
icon?: React.ReactNode
spec: MotionSpec
}) {
return (
<motion.div
data-slot="styled-alert"
data-tone={tone}
role="alert"
className={cn(base, pad[size], iconTone[tone], surfaceTone[tone], className)}
initial={spec.initial}
animate={spec.animate}
transition={spec.transition}
{...props}
>
{icon ?? toneIcon[tone]}
<div
data-slot="styled-alert-title"
className="col-start-2 min-h-4 font-medium tracking-tight"
>
{title}
</div>
<div
data-slot="styled-alert-description"
className="col-span-2 col-start-1 text-sm opacity-90 [&_p]:leading-relaxed"
>
{children}
</div>
</motion.div>
)
}
/* The shared reduced-motion fallback: opacity only. */
const fadeSpec: MotionSpec = {
initial: { opacity: 0 },
animate: { opacity: 1 },
transition: { duration: 0.2, ease: "easeOut" },
}
/* Slide: soldan iceri kayarak girer. */
export function SlideAlert({
className,
size = "md",
tone = "info",
title = "Heads up",
children = "The alert slides in from the left as it mounts.",
icon,
...props
}: AlertProps) {
const reduce = useReducedMotion()
const spec: MotionSpec = reduce
? fadeSpec
: {
initial: { opacity: 0, x: -28 },
animate: { opacity: 1, x: 0 },
transition: { duration: 0.35, ease: "easeOut" },
}
return (
<Frame size={size} tone={tone} title={title} icon={icon} spec={spec} className={className} {...props}>
{children}
</Frame>
)
}
/* Pop: yaylanarak buyur. Spring transition tipi sabit ("spring" as const). */
export function PopAlert({
className,
size = "md",
tone = "info",
title = "Heads up",
children = "The alert springs up to full scale as it mounts.",
icon,
...props
}: AlertProps) {
const reduce = useReducedMotion()
const spec: MotionSpec = reduce
? fadeSpec
: {
initial: { opacity: 0, scale: 0.88 },
animate: { opacity: 1, scale: 1 },
transition: { type: "spring" as const, stiffness: 420, damping: 22 },
}
return (
<Frame size={size} tone={tone} title={title} icon={icon} spec={spec} className={className} {...props}>
{children}
</Frame>
)
}
/* Fade: en sakin giris - kucuk bir yukselme + sonumlenme. */
export function FadeAlert({
className,
size = "md",
tone = "info",
title = "Heads up",
children = "The quietest entrance: a small rise and a fade.",
icon,
...props
}: AlertProps) {
const reduce = useReducedMotion()
const spec: MotionSpec = reduce
? fadeSpec
: {
initial: { opacity: 0, y: 10 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.4, ease: "easeOut" },
}
return (
<Frame size={size} tone={tone} title={title} icon={icon} spec={spec} className={className} {...props}>
{children}
</Frame>
)
}
/* Shake: a short shudder on entry. For error and attention states; the keyframe sequence is fixed, nothing random. */
export function ShakeAlert({
className,
size = "md",
tone = "danger",
title = "Heads up",
children = "A short shake on entry for errors that need attention.",
icon,
...props
}: AlertProps) {
const reduce = useReducedMotion()
const spec: MotionSpec = reduce
? fadeSpec
: {
initial: { opacity: 0, x: 0 },
animate: { opacity: 1, x: [0, -9, 9, -6, 6, -3, 0] },
transition: { duration: 0.5, ease: "easeOut" },
}
return (
<Frame size={size} tone={tone} title={title} icon={icon} spec={spec} className={className} {...props}>
{children}
</Frame>
)
}
/* Reveal: it opens left to right with clip-path. The surface stays in place, only
the visible area grows. */
export function RevealAlert({
className,
size = "md",
tone = "info",
title = "Heads up",
children = "A clip-path wipe opens the alert from left to right.",
icon,
...props
}: AlertProps) {
const reduce = useReducedMotion()
const spec: MotionSpec = reduce
? fadeSpec
: {
initial: { opacity: 1, clipPath: "inset(0 100% 0 0)" },
animate: { opacity: 1, clipPath: "inset(0 0% 0 0)" },
transition: { duration: 0.45, ease: "easeOut" },
}
return (
<Frame size={size} tone={tone} title={title} icon={icon} spec={spec} className={className} {...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.
Slide
Slides in from the left on mount.
import { SlideAlert } from "@/components/ui/alert-motion"
<SlideAlert tone="info" title="Heads up">The alert slides in from the left as it mounts.</SlideAlert>Pop
Springs up to full scale on mount.
import { PopAlert } from "@/components/ui/alert-motion"
<PopAlert tone="success" title="Done">The alert springs up to full scale as it mounts.</PopAlert>Fade
The quietest entrance: a small rise and a fade.
import { FadeAlert } from "@/components/ui/alert-motion"
<FadeAlert tone="info" title="Note">The quietest entrance: a small rise and a fade.</FadeAlert>Shake
A short, fixed shake on entry for errors.
import { ShakeAlert } from "@/components/ui/alert-motion"
<ShakeAlert tone="danger" title="Error">A short shake on entry for errors that need attention.</ShakeAlert>Reveal
A clip-path wipe that opens from left to right.
import { RevealAlert } from "@/components/ui/alert-motion"
<RevealAlert tone="warning" title="Careful">A clip-path wipe opens the alert from left to right.</RevealAlert>ai2 Motion alerts: 5 styled variations on the token system
The ai2 Motion alerts are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around alerts that share one surface and differ only in their entrance animation. 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 entrance on mount; there is no loop and no randomness. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, every transform and wipe is skipped and each alert simply fades in.
What is in the ai2 Motion alerts?
5 exports in one file: Slide, Pop, Fade, Shake and Reveal. 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 entrance on mount; there is no loop and no randomness.
- Reduced-motion aware: Under prefers-reduced-motion, every transform and wipe is skipped and each alert simply fades in.
- 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 Motion 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.