Motion cards
Five cards that share one calm token surface and differ only in movement: a hover lift, a fade, a spring pop, a slide and a clip-path reveal. Every variation gates on prefers-reduced-motion and settles in the same resting state.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/card-motionDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motionCopy the source
components/ui/card-motion.tsx"use client"
import type * as React from "react"
import { motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Motion card family: 5 cards carrying an entry or hover motion. The surface is the same calm token card in every variant; the difference is in the motion alone (lift, fade, pop, slide, reveal). Every variant is turned off with useReducedMotion(): instead of moving, the card simply sits in its final state. Colour comes ONLY from semantic tokens, transparency through color-mix. size = the padding plus radius scale (the same as card-styled). */
export type StyledSize = "sm" | "md" | "lg" | "xl"
const radii: Record<StyledSize, string> = {
sm: "rounded-lg",
md: "rounded-xl",
lg: "rounded-2xl",
xl: "rounded-2xl",
}
const pads: Record<StyledSize, string> = {
sm: "p-4",
md: "p-5",
lg: "p-6",
xl: "p-8",
}
type Props = React.ComponentProps<"div"> & { size?: StyledSize }
const shell =
"relative w-64 border border-border bg-card text-card-foreground shadow-[0_1px_2px_color-mix(in_oklab,var(--color-foreground)_10%,transparent)]"
const spring = { type: "spring" as const, stiffness: 320, damping: 24 }
const defaultBody = (
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-foreground">Card title</span>
<span className="text-xs leading-relaxed text-muted-foreground">
A short description that shows how content sits inside the card.
</span>
</div>
)
/* Lift: the card rises on hover and its shadow grows. */
export function LiftCard({ className, size = "md", children, ...props }: Props) {
const reduce = useReducedMotion()
return (
<div data-slot="styled-card" className={cn("relative", className)} {...props}>
<motion.div
className={cn(
shell,
"transition-shadow duration-(--motion-base) ease-(--motion-ease) hover:shadow-[0_18px_36px_-16px_color-mix(in_oklab,var(--color-foreground)_45%,transparent)] motion-reduce:transition-none",
radii[size],
pads[size]
)}
whileHover={reduce ? undefined : { y: -6 }}
transition={spring}
>
{children ?? defaultBody}
</motion.div>
</div>
)
}
/* Fade: the card appears softly. Visible immediately under reduced-motion. */
export function FadeCard({ className, size = "md", children, ...props }: Props) {
const reduce = useReducedMotion()
return (
<div data-slot="styled-card" className={cn("relative", className)} {...props}>
<motion.div
className={cn(shell, radii[size], pads[size])}
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.45, ease: "easeOut" }}
>
{children ?? defaultBody}
</motion.div>
</div>
)
}
/* Pop: the card enters growing with a spring and grows slightly further on hover. */
export function PopCard({ className, size = "md", children, ...props }: Props) {
const reduce = useReducedMotion()
return (
<div data-slot="styled-card" className={cn("relative", className)} {...props}>
<motion.div
className={cn(shell, radii[size], pads[size])}
initial={reduce ? false : { opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
whileHover={reduce ? undefined : { scale: 1.03 }}
transition={spring}
>
{children ?? defaultBody}
</motion.div>
</div>
)
}
/* Slide: kart soldan kayarak girer. */
export function SlideCard({ className, size = "md", children, ...props }: Props) {
const reduce = useReducedMotion()
return (
<div data-slot="styled-card" className={cn("relative", className)} {...props}>
<motion.div
className={cn(shell, radii[size], pads[size])}
initial={reduce ? false : { opacity: 0, x: -28 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.4, ease: "easeOut" }}
>
{children ?? defaultBody}
</motion.div>
</div>
)
}
/* Reveal: the content emerges through a clip-path as if opening from the top down. No clipping under reduced-motion, the content is fully visible. */
export function RevealCard({ className, size = "md", children, ...props }: Props) {
const reduce = useReducedMotion()
return (
<div data-slot="styled-card" className={cn("relative", className)} {...props}>
<motion.div
className={cn(shell, "overflow-hidden", radii[size], pads[size])}
initial={reduce ? false : { clipPath: "inset(0 0 100% 0)", opacity: 0 }}
animate={{ clipPath: "inset(0 0 0% 0)", opacity: 1 }}
transition={{ duration: 0.5, ease: "easeOut" }}
>
{children ?? defaultBody}
</motion.div>
</div>
)
}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.
Lift
The card rises on hover and its shadow grows.
import { LiftCard } from "@/components/ui/card-motion"
<LiftCard />Fade
The card fades in on mount.
import { FadeCard } from "@/components/ui/card-motion"
<FadeCard />Pop
A spring scale on entry and a small lift on hover.
import { PopCard } from "@/components/ui/card-motion"
<PopCard />Slide
The card slides in from the left.
import { SlideCard } from "@/components/ui/card-motion"
<SlideCard />Reveal
A clip-path reveal that opens from the top down.
import { RevealCard } from "@/components/ui/card-motion"
<RevealCard />ai2 Motion cards: 5 styled variations on the token system
The ai2 Motion cards are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around entrance and hover motion on a single calm card surface. 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 drives the entrance and hover transitions, with springs on the lift and the pop. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the transforms are skipped and each card renders in its resting state.
What is in the ai2 Motion cards?
5 exports in one file: Lift, Fade, Pop, Slide 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 drives the entrance and hover transitions, with springs on the lift and the pop.
- Reduced-motion aware: Under prefers-reduced-motion, the transforms are skipped and each card renders in its resting state.
- 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 cards 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.