A quiet preview card for links, with delayed opening and collision-aware positioning.
pnpm dlx shadcn@latest add @latheui/hover-cardFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add motion radix-uiAdd the Lathe items this component imports.
lib/motion.tshooks/use-controllable-state.tsCopy the source into your project.
"use client"
import * as React from "react"
import { AnimatePresence, motion } from "motion/react"
import { HoverCard as Primitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { fade, spring, useMotionSafe } from "@/lib/motion"
// Spread rather than written as an attribute: in Base UI projects the shadcn CLI
// rewrites a literal `asChild` into a `render` prop, and these are Radix parts.
const slot = { asChild: true } as const
const OpenContext = React.createContext(false)
export type HoverCardProps = Omit<
React.ComponentProps<typeof Primitive.Root>,
"open" | "defaultOpen" | "onOpenChange"
> & {
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}
/** A preview that opens beside a link shortly after the pointer or focus rests on it. */
function HoverCard({
open: openProp,
defaultOpen = false,
onOpenChange,
// Long enough to ignore a pointer passing over, short enough to feel attached to the link.
openDelay = 150,
closeDelay = 100,
...props
}: HoverCardProps) {
const [open, setOpen] = useControllableState({
value: openProp,
defaultValue: defaultOpen,
onChange: onOpenChange,
})
return (
<OpenContext.Provider value={open}>
<Primitive.Root open={open} onOpenChange={setOpen} openDelay={openDelay} closeDelay={closeDelay} {...props} />
</OpenContext.Provider>
)
}
function HoverCardTrigger(props: React.ComponentProps<typeof Primitive.Trigger>) {
return <Primitive.Trigger {...slot} {...props} />
}
function HoverCardContent({
className,
children,
sideOffset = 6,
...props
}: React.ComponentProps<typeof Primitive.Content>) {
const open = React.useContext(OpenContext)
const { reduced, safe } = useMotionSafe()
return (
<AnimatePresence>
{open && (
<Primitive.Portal forceMount>
<Primitive.Content {...slot} forceMount sideOffset={sideOffset} collisionPadding={8} {...props}>
<motion.div
data-slot="hover-card-content"
initial={{ opacity: 0, scale: reduced ? 1 : 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: reduced ? 1 : 0.97, transition: fade }}
transition={safe(spring.snappy)}
className={cn(
"z-50 w-72 max-w-[calc(100vw-2rem)] origin-(--radix-hover-card-content-transform-origin) rounded-lg border border-border bg-popover p-4 text-sm text-popover-foreground shadow-lg outline-none",
className
)}
>
{children}
</motion.div>
</Primitive.Content>
</Primitive.Portal>
)}
</AnimatePresence>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }Depends on
| Prop | Type | Default | Description |
|---|---|---|---|
| open / defaultOpen | boolean | — | Controlled or initial visibility on the root. |
| onOpenChange | (open: boolean) => void | — | Visibility callback. |
| openDelay | number | 150 | Milliseconds the pointer rests on the trigger before the card opens. |
| closeDelay | number | 100 | Milliseconds after the pointer leaves the trigger and the card before it closes. |
| children | ReactNode | — | Compose Root, Trigger and Content. Trigger slots one child. |
| side / align / sideOffset | Radix placement props | — | Content placement and collision avoidance. |
Combine the control with your own layout. Semantic colors follow the active theme.
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card"
export default function Demo(){return <HoverCard><HoverCardTrigger><a href="https://github.com" className="rounded-sm text-sm font-medium underline underline-offset-4 outline-none focus-visible:ring-2 focus-visible:ring-ring">@lathe-studio</a></HoverCardTrigger><HoverCardContent><p className="font-medium">Lathe Studio</p><p className="mt-1 text-muted-foreground">Small details. Considered interfaces.</p><p className="mt-3 text-xs text-muted-foreground">Building since 2026</p></HoverCardContent></HoverCard>}