Layout hover cards
Five hover cards that keep one open mechanism and change only the inner arrangement: a horizontal avatar row, a compact dense line, a wide two-column card, a vertical stack with a stat row and a split card with a separated action bar. 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-layoutDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motion lucide-reactCopy the source
components/ui/hover-card-layout.tsx"use client"
import type * as React from "react"
import { Activity, Building2, Star, User, Zap } from "lucide-react"
import { useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Styled hover-card "Layout" family: 5 decorative hover cards share the same
opening mechanism but the INNER content layout changes. SELF-CONTAINED (no radix,
no portal) - a trigger + a card positioned below it, opened with CSS group-hover /
group-focus (opacity + a slight slide). The opening mechanism and the data-slot
are preserved from essentials. The transition/transform is disabled through
useReducedMotion(): in the reduce case the card appears instantly. Color comes
ONLY from tokens; alpha via color-mix. Size = card width. trigger = the trigger,
children = extra card content (optional). The content is a tokenized placeholder;
each export shows a different layout. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
/* The standard card width scale. */
const cardWidth: Record<StyledSize, string> = {
sm: "w-56",
md: "w-64",
lg: "w-72",
xl: "w-80",
}
/* Compact duzen bir adim dar. */
const compactWidth: Record<StyledSize, string> = {
sm: "w-44",
md: "w-48",
lg: "w-52",
xl: "w-56",
}
/* The Wide layout is one step wider, for two columns. */
const wideWidth: Record<StyledSize, string> = {
sm: "w-72",
md: "w-80",
lg: "w-96",
xl: "w-[28rem]",
}
/* The card shell: position + visibility toggle. No padding - each layout pads its
own section, so the Split top/bottom band reaches all the way to the edge. */
const cardShellBase =
"invisible absolute left-1/2 top-full z-20 mt-2 -translate-x-1/2 overflow-hidden rounded-xl border border-border bg-popover text-left text-popover-foreground opacity-0 shadow-lg outline-none group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100 [&_svg]:shrink-0 [&_i]:leading-none"
/* Added only when motion is on: an initial slide plus a transition. Dropped under reduce. */
const cardMotion =
"translate-y-1 transition-[opacity,transform] duration-(--motion-base) ease-(--motion-ease) group-hover:translate-y-0 group-focus-within:translate-y-0"
const triggerBase =
"inline-flex items-center gap-1.5 rounded-md font-medium text-primary 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 group wrapper plus trigger plus a card that opens on hover. The width comes from the layout; the reduce hook turns off the transform and transition. */
function Shell({
width,
trigger,
className,
children,
}: {
width: string
trigger: React.ReactNode
className?: string
children: React.ReactNode
}) {
const reduce = useReducedMotion()
const fallback = (
<button type="button" className={triggerBase}>
Hover me
</button>
)
return (
<span data-slot="styled-hover-card" className="group relative inline-flex">
{trigger ?? fallback}
<span
role="dialog"
data-slot="styled-hover-card-content"
className={cn(cardShellBase, !reduce && cardMotion, width, className)}
>
{children}
</span>
</span>
)
}
/* HorizontalHoverCard: avatar solda, metin sagda. Klasik profil ozeti satiri. */
export function HorizontalHoverCard({ size = "md", trigger, children, className }: Props) {
return (
<Shell width={cardWidth[size]} trigger={trigger} className={className}>
<span className="flex items-start gap-3 p-4">
<span
data-slot="styled-hover-card-avatar"
className="flex size-10 shrink-0 items-center justify-center rounded-full bg-secondary text-secondary-foreground [&_svg]:size-5 [&_i]:text-xl"
>
<User />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold text-foreground">ai2 team</span>
<span className="block text-xs text-muted-foreground">@ai2</span>
<span className="mt-1.5 block text-sm text-muted-foreground">
{children ?? "A distributed component system you can trust."}
</span>
</span>
</span>
</Shell>
)
}
/* CompactHoverCard: small and dense. An icon plus one tight block of text, narrow width. */
export function CompactHoverCard({ size = "md", trigger, children, className }: Props) {
return (
<Shell width={compactWidth[size]} trigger={trigger} className={className}>
<span className="flex items-center gap-2.5 p-3">
<span
data-slot="styled-hover-card-icon"
className="flex size-7 shrink-0 items-center justify-center rounded-md bg-secondary text-secondary-foreground [&_svg]:size-4 [&_i]:text-base"
>
<Zap />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">Quick note</span>
<span className="block truncate text-xs text-muted-foreground">
{children ?? "Dense single row."}
</span>
</span>
</span>
</Shell>
)
}
/* WideHoverCard: wide, two columns. Text on the left, a token statistics list on the right. */
export function WideHoverCard({ size = "md", trigger, children, className }: Props) {
return (
<Shell width={wideWidth[size]} trigger={trigger} className={className}>
<span className="grid grid-cols-2 gap-4 p-4">
<span className="min-w-0">
<span className="flex items-center gap-2">
<span
data-slot="styled-hover-card-icon"
className="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary text-secondary-foreground [&_svg]:size-3.5 [&_i]:text-sm"
>
<Building2 />
</span>
<span className="block text-sm font-semibold text-foreground">ai2 labs</span>
</span>
<span className="mt-2 block text-sm text-muted-foreground">
{children ?? "A wider card splits into two readable columns."}
</span>
</span>
<span className="flex flex-col gap-2 border-l border-border pl-4">
<span className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Members</span>
<span className="text-sm font-semibold text-foreground">24</span>
</span>
<span className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Projects</span>
<span className="text-sm font-semibold text-foreground">12</span>
</span>
<span className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Uptime</span>
<span className="text-sm font-semibold text-foreground">99.9%</span>
</span>
</span>
</span>
</Shell>
)
}
/* StackedHoverCard: dikey istif - baslik + aciklama + istatistik satiri alt alta. */
export function StackedHoverCard({ size = "md", trigger, children, className }: Props) {
return (
<Shell width={cardWidth[size]} trigger={trigger} className={className}>
<span className="flex flex-col p-4">
<span className="flex items-center gap-2">
<span
data-slot="styled-hover-card-icon"
className="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary text-secondary-foreground [&_svg]:size-3.5 [&_i]:text-sm"
>
<Star />
</span>
<span className="block text-sm font-semibold text-foreground">Design system</span>
</span>
<span className="mt-2 block text-sm text-muted-foreground">
{children ?? "A title and description stacked above a stat row."}
</span>
<span className="mt-3 grid grid-cols-3 gap-2">
<span className="flex flex-col items-center rounded-lg bg-secondary px-2 py-2">
<span className="text-sm font-semibold text-secondary-foreground">128</span>
<span className="text-xs text-muted-foreground">Tokens</span>
</span>
<span className="flex flex-col items-center rounded-lg bg-secondary px-2 py-2">
<span className="text-sm font-semibold text-secondary-foreground">51</span>
<span className="text-xs text-muted-foreground">Parts</span>
</span>
<span className="flex flex-col items-center rounded-lg bg-secondary px-2 py-2">
<span className="text-sm font-semibold text-secondary-foreground">6</span>
<span className="text-xs text-muted-foreground">Axes</span>
</span>
</span>
</span>
</Shell>
)
}
/* SplitHoverCard: ust icerik + alt aksiyon bari ayrik. Aradaki border iki bolgeyi
gorsel olarak ayirir. */
export function SplitHoverCard({ size = "md", trigger, children, className }: Props) {
return (
<Shell width={cardWidth[size]} trigger={trigger} className={className}>
<span className="block p-4">
<span className="flex items-center gap-2">
<span
data-slot="styled-hover-card-icon"
className="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary text-secondary-foreground [&_svg]:size-3.5 [&_i]:text-sm"
>
<Activity />
</span>
<span className="block text-sm font-semibold text-foreground">Live status</span>
</span>
<span className="mt-2 block text-sm text-muted-foreground">
{children ?? "Content on top, an action bar separated below."}
</span>
</span>
<span
data-slot="styled-hover-card-actions"
className="flex items-center justify-between border-t border-border bg-[color-mix(in_oklab,var(--color-secondary)_45%,transparent)] px-4 py-2.5"
>
<span className="text-xs text-muted-foreground">Updated just now</span>
<span className="inline-flex items-center rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground">
Open
</span>
</span>
</Shell>
)
}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.
Horizontal
An avatar on the left with the text on the right.
import { HorizontalHoverCard } from "@/components/ui/hover-card-layout"
<HorizontalHoverCard />Compact
A small, dense single row for a quick label.
import { CompactHoverCard } from "@/components/ui/hover-card-layout"
<CompactHoverCard />Wide
A wide card split into two readable columns.
import { WideHoverCard } from "@/components/ui/hover-card-layout"
<WideHoverCard />Stacked
A vertical stack: title, description and a stat row.
import { StackedHoverCard } from "@/components/ui/hover-card-layout"
<StackedHoverCard />Split
Top content with a separated action bar below.
import { SplitHoverCard } from "@/components/ui/hover-card-layout"
<SplitHoverCard />ai2 Layout hover cards: 5 styled variations on the token system
The ai2 Layout hover cards are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around inner content layouts on a hover-opened card. 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: the cards fade and rise on token CSS transitions 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 transitions and transform are skipped and the card appears instantly.
What is in the ai2 Layout hover cards?
5 exports in one file: Horizontal, Compact, Wide, Stacked and Split. 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: the cards fade and rise on token CSS transitions when the trigger is hovered or focused.
- Reduced-motion aware: Under prefers-reduced-motion, the transitions and transform are skipped 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 Layout 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.