Motion hover cards
Five hover cards with showy entrances: fade, scale, slide, pop and spring. Each is self-contained (no radix), sized, token-driven, and opens on hover or keyboard focus.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/hover-card-motionDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motionCopy the source
components/ui/hover-card-motion.tsx"use client"
import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Motion hover-card family: 5 showy hover cards. The card itself is plain
(bg-popover); the DIFFERENCE is the opening entrance animation. As in essentials
it is a trigger + a card positioned below it, opened on HOVER (mouseenter/leave)
AND focus (focus/blur); NO radix or portal, self-contained. AnimatePresence
carries the enter/exit animation. The card is centered horizontally with x:"-50%"
(a motion value rather than the translate CSS, so motion does not override the
transform). Under reduced motion ALL transforms drop away and only an opacity
fade remains. Color comes ONLY from tokens; alpha via color-mix. Size = card
width. trigger = the trigger, children = the card content. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
const cardWidth: Record<StyledSize, string> = {
sm: "w-56",
md: "w-64",
lg: "w-72",
xl: "w-80",
}
/* Card shell: position plus appearance. Horizontal centring is done with the motion x value, which is why there is NO -translate-x-1/2 here. lucide svg and remixicon i are kept at parity for icon compatibility. */
const cardBase =
"absolute left-1/2 top-full z-20 mt-2 block origin-top rounded-xl border border-border bg-popover p-4 text-left text-popover-foreground shadow-lg [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
/* Trigger link appearance plus focus ring (same as essentials). */
const triggerBase =
"inline-flex items-center rounded-md font-medium text-primary underline-offset-4 outline-none hover:underline focus-visible:ring-[3px] focus-visible:ring-ring/50"
interface Props {
size?: StyledSize
trigger?: React.ReactNode
children?: React.ReactNode
className?: string
}
/* The enter/exit animation supplied from outside; it differs in every export. Every value carries x:"-50%" so the card stays horizontally centred (the motion transform must not override it). */
type CardMotion = {
initial: Record<string, number | string>
animate: Record<string, number | string>
exit: Record<string, number | string>
transition?: Record<string, unknown>
}
/* Shared shell: trigger plus an AnimatePresence card. Hover AND focus are tracked separately, and the card is visible while either is active (it stays open on focus even after the mouse leaves). */
function HoverCardShell({
props,
cardMotion,
cardClassName,
}: {
props: Props
cardMotion: CardMotion
cardClassName?: string
}) {
const { size = "md", trigger, children, className } = props
const [hovered, setHovered] = React.useState(false)
const [focused, setFocused] = React.useState(false)
const reduce = useReducedMotion()
const isOpen = hovered || focused
/* reduced motion: no transform, fade only; x is kept for centring. */
const fade: CardMotion = {
initial: { opacity: 0, x: "-50%" },
animate: { opacity: 1, x: "-50%" },
exit: { opacity: 0, x: "-50%" },
transition: { duration: 0.18, ease: "easeOut" },
}
const m = reduce ? fade : cardMotion
return (
<span
data-slot="styled-hover-card"
className="relative inline-flex"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
>
{trigger ?? (
<button type="button" className={triggerBase}>
Hover me
</button>
)}
<AnimatePresence>
{isOpen ? (
<motion.span
role="dialog"
className={cn(cardBase, cardWidth[size], cardClassName, className)}
initial={m.initial}
animate={m.animate}
exit={m.exit}
transition={m.transition ?? { duration: 0.18, ease: "easeOut" }}
>
{children ?? "A quick preview card that opens on hover or focus."}
</motion.span>
) : null}
</AnimatePresence>
</span>
)
}
/* Fade: it appears softly with opacity alone (no transform). */
export function FadeHoverCard(props: Props) {
return (
<HoverCardShell
props={props}
cardMotion={{
initial: { opacity: 0, x: "-50%" },
animate: { opacity: 1, x: "-50%" },
exit: { opacity: 0, x: "-50%" },
transition: { duration: 0.18, ease: "easeOut" },
}}
/>
)
}
/* Scale: it opens by growing from the top edge (origin-top, scale 0.92 -> 1). */
export function ScaleHoverCard(props: Props) {
return (
<HoverCardShell
props={props}
cardMotion={{
initial: { opacity: 0, x: "-50%", scale: 0.92 },
animate: { opacity: 1, x: "-50%", scale: 1 },
exit: { opacity: 0, x: "-50%", scale: 0.96 },
transition: { duration: 0.18, ease: "easeOut" },
}}
/>
)
}
/* Slide: alttan yukari kayarak girer (y 8 -> 0). */
export function SlideHoverCard(props: Props) {
return (
<HoverCardShell
props={props}
cardMotion={{
initial: { opacity: 0, x: "-50%", y: 8 },
animate: { opacity: 1, x: "-50%", y: 0 },
exit: { opacity: 0, x: "-50%", y: 6 },
transition: { duration: 0.2, ease: "easeOut" },
}}
/>
)
}
/* Pop: dusuk damping spring ile overshoot yaparak patlar (scale 0.8 -> 1). */
export function PopHoverCard(props: Props) {
return (
<HoverCardShell
props={props}
cardMotion={{
initial: { opacity: 0, x: "-50%", scale: 0.8 },
animate: { opacity: 1, x: "-50%", scale: 1 },
exit: { opacity: 0, x: "-50%", scale: 0.9 },
transition: { type: "spring", stiffness: 500, damping: 12 },
}}
/>
)
}
/* Spring: yumusak spring ile hafif yukselerek ve buyuyerek girer. */
export function SpringHoverCard(props: Props) {
return (
<HoverCardShell
props={props}
cardMotion={{
initial: { opacity: 0, x: "-50%", y: 10, scale: 0.96 },
animate: { opacity: 1, x: "-50%", y: 0, scale: 1 },
exit: { opacity: 0, x: "-50%", y: 6 },
transition: { type: "spring", stiffness: 300, damping: 22 },
}}
/>
)
}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.
Fade
Fades in softly with opacity only.
import { FadeHoverCard } from "@/components/ui/hover-card-motion"
<FadeHoverCard>A quick preview card that opens on hover or focus.</FadeHoverCard>Scale
Grows open from the top edge.
import { ScaleHoverCard } from "@/components/ui/hover-card-motion"
<ScaleHoverCard>A quick preview card that opens on hover or focus.</ScaleHoverCard>Slide
Slides up from just below the trigger.
import { SlideHoverCard } from "@/components/ui/hover-card-motion"
<SlideHoverCard>A quick preview card that opens on hover or focus.</SlideHoverCard>Pop
Pops in with a spring overshoot.
import { PopHoverCard } from "@/components/ui/hover-card-motion"
<PopHoverCard>A quick preview card that opens on hover or focus.</PopHoverCard>Spring
Rises and scales in on a soft spring.
import { SpringHoverCard } from "@/components/ui/hover-card-motion"
<SpringHoverCard>A quick preview card that opens on hover or focus.</SpringHoverCard>ai2 Motion hover cards: 5 styled variations on the token system
The ai2 Motion hover cards are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around self-contained hover cards with showy entrances. 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 a distinct fade, scale, upward slide, pop overshoot or soft spring on enter and exit when the trigger is hovered or focused. 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 the card fades instead.
What is in the ai2 Motion hover cards?
5 exports in one file: Fade, Scale, Slide, Pop and Spring. 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 a distinct fade, scale, upward slide, pop overshoot or soft spring on enter and exit when the trigger is hovered or focused.
- Reduced-motion aware: Under prefers-reduced-motion, the transforms are skipped and the card fades instead.
- 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 hover 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.