Hover Card
hover-carduiRich popover triggered by hover/focus instead of click. Use for inline previews — user cards on @mentions, link previews, KPI explanations. Hand-rolled runes port (no headless dependency) with configurable open/close delay, side/align placement, and bindable open state.
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/hover-card.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/hover-card.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/hover-card.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/hover-card.jsonManifest:
https://uipkge.dev/r/svelte/hover-card.jsonInstalls to:src/lib/components/ui/hover-card/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
openControlled open state. Bind with `bind:open`. | boolean | — | optional |
openDelayDelay before opening on hover/focus, in ms. Default 700. | number | 700 | optional |
closeDelayDelay before closing after leaving, in ms. Default 300. | number | 300 | optional |
onOpenChangeCalled whenever the card opens or closes. | (open: boolean) => void | — | optional |
children | Snippet | — | optional |
Files installed (4)
src/lib/components/ui/hover-card/HoverCard.svelte2.7 kB
<script lang="ts" module> import { getContext } from 'svelte' import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' export interface HoverCardProps extends HTMLAttributes<HTMLDivElement> { /** Controlled open state. Bind with `bind:open`. */ open?: boolean /** Delay before opening on hover/focus, in ms. Default 700. */ openDelay?: number /** Delay before closing after leaving, in ms. Default 300. */ closeDelay?: number /** Called whenever the card opens or closes. */ onOpenChange?: (open: boolean) => void children?: Snippet } export interface HoverCardContext { readonly open: boolean scheduleOpen: () => void scheduleClose: () => void cancelClose: () => void } export const HOVER_CARD_CONTEXT_KEY = Symbol('uipkge:hover-card') export function getHoverCardContext(): HoverCardContext { return getContext<HoverCardContext>(HOVER_CARD_CONTEXT_KEY) } </script> <script lang="ts"> import { onDestroy, setContext } from 'svelte' import { cn } from '$lib/utils' let { class: className, open = $bindable(false), openDelay = 700, closeDelay = 300, onOpenChange, children, ...restProps }: HoverCardProps = $props() let openTimer: ReturnType<typeof setTimeout> | null = null let closeTimer: ReturnType<typeof setTimeout> | null = null function setOpen(next: boolean) { if (open === next) return open = next onOpenChange?.(next) } function scheduleOpen() { if (closeTimer) { clearTimeout(closeTimer) closeTimer = null } if (open || openTimer) return if (openDelay <= 0) { setOpen(true) return } openTimer = setTimeout(() => { openTimer = null setOpen(true) }, openDelay) } function scheduleClose() { if (openTimer) { clearTimeout(openTimer) openTimer = null } if (!open || closeTimer) return if (closeDelay <= 0) { setOpen(false) return } closeTimer = setTimeout(() => { closeTimer = null setOpen(false) }, closeDelay) } function cancelClose() { if (closeTimer) { clearTimeout(closeTimer) closeTimer = null } } onDestroy(() => { if (openTimer) clearTimeout(openTimer) if (closeTimer) clearTimeout(closeTimer) }) setContext<HoverCardContext>(HOVER_CARD_CONTEXT_KEY, { get open() { return open }, scheduleOpen, scheduleClose, cancelClose, }) </script> <div data-uipkge data-slot="hover-card" data-state={open ? 'open' : 'closed'} {...restProps} class={cn('relative inline-block', className)} > {@render children?.()} </div>src/lib/components/ui/hover-card/HoverCardContent.svelte2.9 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' export type HoverCardSide = 'top' | 'right' | 'bottom' | 'left' export type HoverCardAlign = 'start' | 'center' | 'end' export interface HoverCardContentProps extends HTMLAttributes<HTMLDivElement> { side?: HoverCardSide align?: HoverCardAlign sideOffset?: number alignOffset?: number children?: Snippet } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getHoverCardContext } from './HoverCard.svelte' let { class: className, side = 'bottom', align = 'center', sideOffset = 4, alignOffset = 0, children, ...restProps }: HoverCardContentProps = $props() const ctx = getHoverCardContext() // Absolute positioning against the root wrapper (no floating-ui / portal in // this hand-rolled port — the content anchors to the relatively-positioned // HoverCard root instead of document.body). const positionStyle = $derived.by(() => { const offset = `${sideOffset}px` let s = '' if (side === 'bottom') s += `top: calc(100% + ${offset});` else if (side === 'top') s += `bottom: calc(100% + ${offset});` else if (side === 'right') s += `left: calc(100% + ${offset});` else s += `right: calc(100% + ${offset});` if (side === 'bottom' || side === 'top') { if (align === 'start') s += `left: ${alignOffset}px;` else if (align === 'end') s += `right: ${-alignOffset}px;` else s += `left: calc(50% + ${alignOffset}px); transform: translateX(-50%);` } else { if (align === 'start') s += `top: ${alignOffset}px;` else if (align === 'end') s += `bottom: ${-alignOffset}px;` else s += `top: calc(50% + ${alignOffset}px); transform: translateY(-50%);` } return s }) </script> {#if ctx.open} <div data-uipkge data-slot="hover-card-content" role="dialog" data-state="open" data-side={side} data-align={align} {...restProps} style={positionStyle} class={cn( 'bg-popover text-popover-foreground motion-safe:data-[state=open]:animate-in motion-safe:data-[state=closed]:animate-out motion-safe:data-[state=open]:ease-emphasized motion-safe:data-[state=open]:blur-in-2 motion-safe:data-[state=closed]:blur-out-2 motion-safe:data-[state=closed]:fade-out-0 motion-safe:data-[state=open]:fade-in-0 motion-safe:data-[state=closed]:zoom-out-95 motion-safe:data-[state=open]:zoom-in-95 motion-safe:data-[side=bottom]:slide-in-from-top-2 motion-safe:data-[side=left]:slide-in-from-right-2 motion-safe:data-[side=right]:slide-in-from-left-2 motion-safe:data-[side=top]:slide-in-from-bottom-2 absolute z-50 w-64 rounded-md border p-4 shadow-md outline-hidden motion-safe:data-[state=closed]:duration-[var(--dur-exit)] motion-safe:data-[state=open]:duration-200', className, )} onmouseenter={ctx.cancelClose} onmouseleave={ctx.scheduleClose} > {@render children?.()} </div> {/if}src/lib/components/ui/hover-card/HoverCardTrigger.svelte0.8 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' export interface HoverCardTriggerProps extends HTMLAttributes<HTMLSpanElement> { children?: Snippet } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getHoverCardContext } from './HoverCard.svelte' let { class: className, children, ...restProps }: HoverCardTriggerProps = $props() const ctx = getHoverCardContext() </script> <span data-uipkge data-slot="hover-card-trigger" data-state={ctx.open ? 'open' : 'closed'} aria-expanded={ctx.open} aria-haspopup="dialog" {...restProps} class={cn(className)} onmouseenter={ctx.scheduleOpen} onmouseleave={ctx.scheduleClose} onfocusin={ctx.scheduleOpen} onfocusout={ctx.scheduleClose} > {@render children?.()} </span>src/lib/components/ui/hover-card/index.ts0.3 kB
export { default as HoverCard, type HoverCardProps, type HoverCardContext } from './HoverCard.svelte' export { default as HoverCardContent, type HoverCardContentProps, type HoverCardSide, type HoverCardAlign, } from './HoverCardContent.svelte' export { default as HoverCardTrigger, type HoverCardTriggerProps } from './HoverCardTrigger.svelte'
Raw manifest:https://uipkge.dev/r/svelte/hover-card.json