Hover Card
For sighted users to preview content available behind a link.
@nextjs
VC
@nextjs
The React Framework – created and maintained by @vercel.
Joined December 2021
import { css } from 'remix/component'
import { Avatar, AvatarFallback, AvatarImage } from '#app/ui/avatar.tsx'
import { buttonVariants } from '#app/ui/button.tsx'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '#app/ui/hover-card.tsx'import { css } from 'remix/component'
import { Avatar, AvatarFallback, AvatarImage } from '#app/ui/avatar.tsx'
import { buttonVariants } from '#app/ui/button.tsx'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '#app/ui/hover-card.tsx'
export function HoverCardDemo() {
return () => (
<HoverCard>
<HoverCardTrigger
href="https://nextjs.org"
data-slot="button"
mix={buttonVariants({ variant: 'link' })}
>
@nextjs
</HoverCardTrigger>
<HoverCardContent mix={css({ width: '20rem' })}>
<div mix={css({ display: 'flex', justifyContent: 'space-between', gap: '1rem' })}>
<Avatar>
<AvatarImage src="https://github.com/vercel.png" />
<AvatarFallback>VC</AvatarFallback>
</Avatar>
<div mix={css({ '& > :not(:last-child)': { marginBlockEnd: '0.25rem' } })}>
<h4 mix={css({ fontSize: '0.875rem', lineHeight: 'calc(1.25 / 0.875)', fontWeight: 600 })}>
@nextjs
</h4>
<p mix={css({ fontSize: '0.875rem', lineHeight: 'calc(1.25 / 0.875)' })}>
The React Framework – created and maintained by @vercel.
</p>
<div
mix={css({
fontSize: '0.75rem',
lineHeight: 'calc(1 / 0.75)',
color: 'var(--muted-foreground)',
})}
>
Joined December 2021
</div>
</div>
</div>
</HoverCardContent>
</HoverCard>
)
}Installation
$ bunx --bun shadcn@latest add https://www.shadcn-remix.com/r/hover-card.json$ npx shadcn@latest add https://www.shadcn-remix.com/r/hover-card.json$ pnpm dlx shadcn@latest add https://www.shadcn-remix.com/r/hover-card.json$ yarn dlx shadcn@latest add https://www.shadcn-remix.com/r/hover-card.jsonInstall the following dependencies:
$ bun add remix$ npm install remix$ pnpm add remix$ yarn add remixCopy and paste the following code into your project.
import { css, on, ref, type Handle, type Props, type RemixNode } from 'remix/component'
import { fadeZoomByState, floatingLayer, slideInFromSide, type Align, type Side } from './popover.tsx'
interface HoverCardContext {
readonly open: boolean
trigger: HTMLElement | null
/** Opens after `openDelay`; cancels a pending close. */
scheduleOpen(): void
/** Closes after `closeDelay`; cancels a pending open. */
scheduleClose(): void
setOpen(open: boolean): void
}
export interface HoverCardProps {
children?: RemixNode
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
openDelay?: number
closeDelay?: number
}
export function HoverCard(handle: Handle<HoverCardProps, HoverCardContext>) {
let uncontrolledOpen = handle.props.defaultOpen ?? false
let timer = 0
let context: HoverCardContext = {
get open() {
return handle.props.open ?? uncontrolledOpen
},
trigger: null,
scheduleOpen() {
clearTimeout(timer)
timer = window.setTimeout(() => context.setOpen(true), handle.props.openDelay ?? 700)
},
scheduleClose() {
clearTimeout(timer)
timer = window.setTimeout(() => context.setOpen(false), handle.props.closeDelay ?? 300)
},
setOpen(open) {
clearTimeout(timer)
if (open === context.open) return
if (handle.props.open === undefined) {
uncontrolledOpen = open
void handle.update()
}
handle.props.onOpenChange?.(open)
},
}
handle.signal.addEventListener('abort', () => clearTimeout(timer))
handle.context.set(context)
return () => handle.props.children
}
export type HoverCardTriggerProps = Props<'a'>
/**
* Renders an `<a>` like Radix. shadcn's `asChild` maps to `mix`:
* `<HoverCardTrigger href="…" mix={buttonVariants({ variant: 'link' })}>@nextjs</HoverCardTrigger>`.
*/
export function HoverCardTrigger(handle: Handle<HoverCardTriggerProps>) {
let context = handle.context.get(HoverCard)
let behavior = [
ref<HTMLElement>((node, signal) => {
context.trigger = node
signal.addEventListener('abort', () => {
if (context.trigger === node) context.trigger = null
})
}),
on<HTMLElement, 'pointerenter'>('pointerenter', (event) => {
if (event.pointerType !== 'touch') context.scheduleOpen()
}),
on<HTMLElement, 'pointerleave'>('pointerleave', (event) => {
if (event.pointerType !== 'touch') context.scheduleClose()
}),
on<HTMLElement, 'focus'>('focus', () => context.scheduleOpen()),
on<HTMLElement, 'blur'>('blur', () => context.scheduleClose()),
]
return () => {
let { mix, ...props } = handle.props
return (
<a
data-slot="hover-card-trigger"
data-state={context.open ? 'open' : 'closed'}
{...props}
mix={[behavior, mix]}
/>
)
}
}
export interface HoverCardContentProps extends Props<'div'> {
side?: Side
align?: Align
sideOffset?: number
alignOffset?: number
}
const contentStyles = css({
zIndex: '50',
width: '16rem',
transformOrigin: 'var(--radix-hover-card-content-transform-origin)',
borderRadius: 'calc(var(--radius) - 2px)',
borderWidth: '1px',
backgroundColor: 'var(--popover)',
padding: '1rem',
color: 'var(--popover-foreground)',
'--tw-shadow': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
boxShadow: 'var(--tw-ring-shadow), var(--tw-shadow)',
outline: '2px solid transparent',
outlineOffset: '2px',
...slideInFromSide,
...fadeZoomByState,
})
export function HoverCardContent(handle: Handle<HoverCardContentProps>) {
let context = handle.context.get(HoverCard)
return () => {
let { side = 'bottom', align = 'center', sideOffset = 4, alignOffset = 0, mix, ...props } = handle.props
return (
<div
data-slot="hover-card-content"
data-state={context.open ? 'open' : 'closed'}
{...props}
mix={[
contentStyles,
floatingLayer({
open: context.open,
anchor: () => context.trigger,
name: 'hover-card',
side,
align,
sideOffset,
alignOffset,
inside: (target) => !!context.trigger?.contains(target),
onDismiss: () => context.setOpen(false),
}),
on<HTMLElement, 'pointerenter'>('pointerenter', (event) => {
if (event.pointerType !== 'touch') context.scheduleOpen()
}),
on<HTMLElement, 'pointerleave'>('pointerleave', (event) => {
if (event.pointerType !== 'touch') context.scheduleClose()
}),
mix,
]}
/>
)
}
}Update the import paths to match your project setup.
Usage
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '#app/ui/hover-card.tsx'<HoverCard>
<HoverCardTrigger>Hover</HoverCardTrigger>
<HoverCardContent>
The React Framework – created and maintained by @vercel.
</HoverCardContent>
</HoverCard>API Reference
HoverCard
| Prop | Type | Default |
|---|---|---|
open | boolean | - |
defaultOpen | boolean | - |
onOpenChange | ((open: boolean) => void) | - |
openDelay | number | - |
closeDelay | number | - |
HoverCardTrigger
Renders an <a> like Radix. shadcn's asChild maps to mix: <HoverCardTrigger href="…" mix={buttonVariants({ variant: 'link' })}>@nextjs</HoverCardTrigger>.
| Prop | Type | Default |
|---|---|---|
mix | MixValue | - |
HoverCardContent
| Prop | Type | Default |
|---|---|---|
side | Side | "bottom" |
align | Align | "center" |
sideOffset | number | 4 |
alignOffset | number | 0 |
mix | MixValue | - |