Tone hover cards
Five semantic tone hover cards: primary, success, warning, danger and info. Each is self-contained (no radix), sized, token-driven, carries its tone's soft ground and icon, 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-toneDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/hover-card-tone.tsx"use client"
import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import {
AlertOctagon,
AlertTriangle,
CheckCircle,
Info,
Sparkles,
type LucideIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
/* HoverCard Tone family: 5 semantically toned hover cards. Self-contained (no
radix, no portal) - a trigger + a softly toned card positioned below it, opened
on hover/focus. The opening uses framer-motion (fade + a slight rise + scale) and
is pinned by reduced-motion. Each card carries the -soft background of its own
tone + the on-plan color + its icon. Color comes ONLY from semantic tokens; since
primary has no -soft, its background is derived from the primary token with
color-mix. Size = card width. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
const cardWidth: Record<StyledSize, string> = {
sm: "w-56",
md: "w-64",
lg: "w-72",
xl: "w-80",
}
type Tone = "primary" | "success" | "warning" | "danger" | "info"
/* Tone map: per tone a soft ground plus an on-plan colour plus the trigger colour plus the icon plus the default body text. Success, warning, danger and info use the real -soft tokens; primary has no -soft, so its ground and border are derived from the primary token through color-mix. */
const tones: Record<
Tone,
{
Icon: LucideIcon
title: string
surface: string
trigger: string
body: string
}
> = {
primary: {
Icon: Sparkles,
title: "Primary",
surface:
"text-primary [background-color:color-mix(in_oklab,var(--color-primary)_12%,transparent)] [border-color:color-mix(in_oklab,var(--color-primary)_28%,transparent)]",
trigger: "text-primary",
body: "The primary tone highlights the main action or the most important detail in a view.",
},
success: {
Icon: CheckCircle,
title: "Success",
surface:
"bg-success-soft text-success-soft-foreground [border-color:color-mix(in_oklab,var(--color-success)_28%,transparent)]",
trigger: "text-success-soft-foreground",
body: "The action completed successfully and everything is in a healthy state.",
},
warning: {
Icon: AlertTriangle,
title: "Warning",
surface:
"bg-warning-soft text-warning-soft-foreground [border-color:color-mix(in_oklab,var(--color-warning)_28%,transparent)]",
trigger: "text-warning-soft-foreground",
body: "Something needs your attention before you continue, though nothing has failed yet.",
},
danger: {
Icon: AlertOctagon,
title: "Danger",
surface:
"bg-danger-soft text-danger-soft-foreground [border-color:color-mix(in_oklab,var(--color-danger)_28%,transparent)]",
trigger: "text-danger-soft-foreground",
body: "A destructive or failing state that needs to be resolved before you move on.",
},
info: {
Icon: Info,
title: "Info",
surface:
"bg-info-soft text-info-soft-foreground [border-color:color-mix(in_oklab,var(--color-info)_28%,transparent)]",
trigger: "text-info-soft-foreground",
body: "A neutral, informational note that adds context without demanding an action.",
},
}
/* Tetikleyici temeli: focus ring + ikon uyumu (lucide svg + remixicon i). */
const triggerBase =
"inline-flex items-center gap-1.5 rounded-md font-medium underline-offset-4 outline-none hover:underline focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
interface Props {
size?: StyledSize
trigger?: React.ReactNode
children?: React.ReactNode
className?: string
}
/* Shared shell: a toned card that opens on hover and focus. AnimatePresence watches a single motion child; to avoid a horizontal-centring transform clash, x:-50% is applied through motion (instead of Tailwind's -translate-x-1/2). */
function ToneHoverCard({
tone,
size = "md",
trigger,
children,
className,
}: Props & { tone: Tone }) {
const reduce = useReducedMotion()
const [open, setOpen] = React.useState(false)
const t = tones[tone]
const Icon = t.Icon
const show = () => setOpen(true)
const hide = () => setOpen(false)
const fallback = (
<button type="button" className={cn(triggerBase, t.trigger)}>
Hover me
</button>
)
return (
<span
data-slot="styled-hover-card"
data-tone={tone}
className="relative inline-flex"
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
>
{trigger ?? fallback}
<AnimatePresence>
{open ? (
<motion.span
role="dialog"
data-slot="styled-hover-card-content"
initial={
reduce
? { opacity: 0, x: "-50%" }
: { opacity: 0, x: "-50%", y: 4, scale: 0.98 }
}
animate={
reduce
? { opacity: 1, x: "-50%" }
: { opacity: 1, x: "-50%", y: 0, scale: 1 }
}
exit={
reduce
? { opacity: 0, x: "-50%" }
: { opacity: 0, x: "-50%", y: 4, scale: 0.98 }
}
transition={
reduce ? { duration: 0 } : { type: "spring", stiffness: 320, damping: 26 }
}
className={cn(
"absolute left-1/2 top-full z-20 mt-2 block origin-top rounded-xl border p-4 text-left shadow-lg [&_i]:text-base [&_i]:leading-none [&_svg]:size-4 [&_svg]:shrink-0",
cardWidth[size],
t.surface,
className
)}
>
<span className="flex items-center gap-2 font-semibold">
<Icon aria-hidden="true" />
<span className="text-sm">{t.title}</span>
</span>
<span className="mt-2 block text-sm opacity-90">
{children ?? t.body}
</span>
</motion.span>
) : null}
</AnimatePresence>
</span>
)
}
/* Primary: primary token'indan turetilmis yumusak zemin + Sparkles ikonu. */
export function PrimaryHoverCard(props: Props) {
return <ToneHoverCard tone="primary" {...props} />
}
/* Success: success-soft zemin + CheckCircle ikonu. */
export function SuccessHoverCard(props: Props) {
return <ToneHoverCard tone="success" {...props} />
}
/* Warning: warning-soft zemin + AlertTriangle ikonu. */
export function WarningHoverCard(props: Props) {
return <ToneHoverCard tone="warning" {...props} />
}
/* Danger: danger-soft zemin + AlertOctagon ikonu. */
export function DangerHoverCard(props: Props) {
return <ToneHoverCard tone="danger" {...props} />
}
/* Info: info-soft zemin + Info ikonu. */
export function InfoHoverCard(props: Props) {
return <ToneHoverCard tone="info" {...props} />
}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.
Primary
A soft primary-tinted card with a Sparkles icon.
import { PrimaryHoverCard } from "@/components/ui/hover-card-tone"
<PrimaryHoverCard>A soft-toned card that opens on hover or focus.</PrimaryHoverCard>Success
A success-soft card with a CheckCircle icon.
import { SuccessHoverCard } from "@/components/ui/hover-card-tone"
<SuccessHoverCard>A soft-toned card that opens on hover or focus.</SuccessHoverCard>Warning
A warning-soft card with an AlertTriangle icon.
import { WarningHoverCard } from "@/components/ui/hover-card-tone"
<WarningHoverCard>A soft-toned card that opens on hover or focus.</WarningHoverCard>Danger
A danger-soft card with an AlertOctagon icon.
import { DangerHoverCard } from "@/components/ui/hover-card-tone"
<DangerHoverCard>A soft-toned card that opens on hover or focus.</DangerHoverCard>Info
An info-soft card with an Info icon.
import { InfoHoverCard } from "@/components/ui/hover-card-tone"
<InfoHoverCard>A soft-toned card that opens on hover or focus.</InfoHoverCard>ai2 Tone hover cards: 5 styled variations on the token system
The ai2 Tone hover cards are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around semantic tone colors on soft-tinted cards. 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 fades the card in and lifts it with a spring 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 spring and lift are removed and the card appears instantly.
What is in the ai2 Tone hover cards?
5 exports in one file: Primary, Success, Warning, Danger and Info. 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 fades the card in and lifts it with a spring when the trigger is hovered or focused.
- Reduced-motion aware: Under prefers-reduced-motion, the spring and lift are removed and the card appears instantly.
- 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 Tone 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.