Card-styled floating panel with hover-intent open/close delays (200ms open, 300ms grace), built from @foldkit/ui HoverIntent and Popover.

Preview
Style
Basic
With Delay
src/demo/views/hover-card.ts137 lines
import { Update } from 'foldkit'
import { Match as M, Option } from 'effect'
import { Schema as S } from 'effect'
import { evo } from 'foldkit/struct'
import { defineMessageUnion } from 'foldkit/message'
import type { Html, HtmlBuilder } from 'foldkit/html'

import * as HoverCard from '../../generated/registry/ui/hover-card'

import { defineSlice, type UpdateReturn } from '../slice'
import type { Model, Message as AppMessage } from '../assemble'

const Message = defineMessageUnion({
  GotHoverCardMessage: { message: HoverCard.Message },
  GotDelayedHoverCardMessage: { message: HoverCard.Message },
})

export const hoverCardView = (model: Model, h: HtmlBuilder<AppMessage>): Html =>
  h.div(
    [h.Class('flex w-full flex-col gap-8')],
    [
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Basic']),
          h.submodel({
            slotId: model.hoverCard.popover.id,
            model: model.hoverCard,
            view: HoverCard.view,
            viewInputs: HoverCard.styledViewInputs(
              {
                trigger: 'Hover Here',
                content: [
                  h.div(
                    [h.Class('flex flex-col gap-0.5')],
                    [
                      h.div([h.Class('font-semibold')], ['@nextjs']),
                      h.div(
                        [h.Class('text-sm')],
                        ['The React Framework – created and maintained by @vercel.'],
                      ),
                      h.div(
                        [h.Class('mt-1 text-xs text-muted-foreground')],
                        ['Joined December 2021'],
                      ),
                    ],
                  ),
                ],
                contentClass: 'w-64',
              },
              h,
            ),
            toParentMessage: (message) => Message.GotHoverCardMessage({ message }),
          }),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['With Delay']),
          h.submodel({
            slotId: model.delayedHoverCard.popover.id,
            model: model.delayedHoverCard,
            view: HoverCard.view,
            viewInputs: HoverCard.styledViewInputs(
              {
                trigger: 'Hover with delay',
                content: [
                  h.p(
                    [h.Class('text-sm')],
                    ['Opens after 500ms and closes 300ms after leaving the card.'],
                  ),
                ],
                contentClass: 'w-64',
              },
              h,
            ),
            toParentMessage: (message) => Message.GotDelayedHoverCardMessage({ message }),
          }),
        ],
      ),
    ],
  )

const foldNoOp =
  <Out>(): ((out: Out) => Update.Step<State, unknown>) =>
  () =>
  (model) => ({ model })

const foldHoverCardOutMessage = M.type<HoverCard.OutMessage>().pipe(
  M.withReturnType<Update.Step<State, unknown>>(),
  M.tagsExhaustive({
    Opened: foldNoOp(),
    Closed: foldNoOp(),
  }),
)

const foldHoverCard = Update.foldChild({
  update: HoverCard.update,
  read: (model: State) => Option.some(model.hoverCard),
  write: (model, next) => evo(model, { hoverCard: () => next }),
  toParentMessage: (message) => Message.GotHoverCardMessage({ message }),
  foldOutMessage: foldHoverCardOutMessage,
})

const foldDelayedHoverCard = Update.foldChild({
  update: HoverCard.update,
  read: (model: State) => Option.some(model.delayedHoverCard),
  write: (model, next) => evo(model, { delayedHoverCard: () => next }),
  toParentMessage: (message) => Message.GotDelayedHoverCardMessage({ message }),
  foldOutMessage: foldHoverCardOutMessage,
})

const fields = { hoverCard: HoverCard.Model, delayedHoverCard: HoverCard.Model }

const stateSchema = S.Struct(fields)
type State = typeof stateSchema.Type

export const slice = defineSlice({
  fields,
  init: {
    hoverCard: HoverCard.init({ id: 'hover-card-demo' }),
    delayedHoverCard: HoverCard.init({ id: 'hover-card-delay-demo', openDelay: 500 }),
  },
  messages: [Message.GotHoverCardMessage, Message.GotDelayedHoverCardMessage],
  handlers: (model: State) => ({
    GotHoverCardMessage: (payload: typeof Message.GotHoverCardMessage.Type): UpdateReturn =>
      foldHoverCard(model, payload.message),
    GotDelayedHoverCardMessage: (
      payload: typeof Message.GotDelayedHoverCardMessage.Type,
    ): UpdateReturn => foldDelayedHoverCard(model, payload.message),
  }),
  samples: [],
  // Open/close flows entirely through the submodel (hover, focus, Escape);
  // the card emits no parent commands, so there are no top-level samples.
})

This code is shown verbatim — it’s the exact file used for this preview. Much of the surrounding wiring (slice, fields, messages, handlers) belongs to this site’s demo harness; you only need the view and the state that matters for your own app. Adapt what you need.

Installation

Add this component to your project:

pnpm dlx shadcn@latest add @foldcn/hover-card

Source

The component ships as plain source — no build step, no wrapper. Copy it and make it yours.

registry/default/ui/hover-card.ts346 lines
/** Stateful submodel — import the whole module as a namespace and wire its
 *  Model/Message/init/update into your app:
 *  `import * as HoverCard from '@/components/ui/hover-card'`
 */
import { HoverIntent, Popover as FoldkitPopover } from '@foldkit/ui'
import type { AnchorConfig } from '@foldkit/ui/anchor'
import { Duration, Match as M, Option, Schema as S } from 'effect'
import * as Command from 'foldkit/command'
import { childAttributes, type ChildAttribute, type Html, type HtmlBuilder } from 'foldkit/html'
import { defineMessageUnion } from 'foldkit/message'
import { evo } from 'foldkit/struct'
import { defineView } from 'foldkit/submodel'
import * as Update from 'foldkit/update'

import { cn } from '@/lib/utils'

type Child = Html | string

/** HoverIntent owns delayed pointer/focus engagement; Popover supplies the
 * floating anchor and enter/leave lifecycle. */
export const Model = S.Struct({
  hoverIntent: HoverIntent.Model,
  popover: FoldkitPopover.Model,
})
export type Model = typeof Model.Type

export const Message = defineMessageUnion({
  GotHoverIntentMessage: { message: HoverIntent.Message },
  GotPopoverMessage: { message: FoldkitPopover.Message },
})
export type Message = typeof Message.Type

export const OutMessage = defineMessageUnion({
  Opened: {},
  Closed: {},
})
export type OutMessage = typeof OutMessage.Type

export type InitConfig = Readonly<{
  id: string
  openDelay?: Duration.Input
  closeDelay?: Duration.Input
  isAnimated?: boolean
}>

export const init = (config: InitConfig): Model => ({
  hoverIntent: HoverIntent.init({
    openDelay: config.openDelay,
    closeDelay: config.closeDelay,
  }),
  popover: FoldkitPopover.init({
    id: config.id,
    isAnimated: config.isAnimated ?? true,
    contentFocus: true,
  }),
})

export const HOVER_CARD_ANCHOR: AnchorConfig = {
  placement: 'bottom',
  gap: 4,
  padding: 8,
}

export const hoverCardTriggerClass =
  'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 underline-offset-4 hover:underline'

export const hoverCardContentClass =
  'data-enter:animate-in data-leave:animate-out data-leave:fade-out-0 data-enter:fade-in-0 data-leave:zoom-out-95 data-enter:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground w-64 rounded-lg p-2.5 text-sm shadow-md ring-1 duration-100 not-data-[side=bottom]:data-[placement=bottom]:slide-in-from-top-2 not-data-[side=left]:data-[placement=left]:slide-in-from-right-2 not-data-[side=right]:data-[placement=right]:slide-in-from-left-2 not-data-[side=top]:data-[placement=top]:slide-in-from-bottom-2 max-h-none! overflow-visible! data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 origin-(--transform-origin) outline-hidden'

export const hoverCardContentAnimatedClass = hoverCardContentClass
export const hoverCardWrapperClass = 'relative inline-block'
export const hoverCardHeaderClass = 'flex flex-col gap-0.5 text-sm'
export const hoverCardTitleClass = 'font-medium'
export const hoverCardDescriptionClass = 'text-muted-foreground'

type StyleConfig = Readonly<{ className?: string }>

export const header = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [
      h.DataAttribute('slot', 'hover-card-header'),
      h.Class(cn(hoverCardHeaderClass, config.className)),
    ],
    children,
  )

export const title = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [
      h.DataAttribute('slot', 'hover-card-title'),
      h.Class(cn(hoverCardTitleClass, config.className)),
    ],
    children,
  )

export const description = <M>(
  config: StyleConfig,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.p(
    [
      h.DataAttribute('slot', 'hover-card-description'),
      h.Class(cn(hoverCardDescriptionClass, config.className)),
    ],
    children,
  )

export const buttonId = FoldkitPopover.buttonId

type UpdateReturn = Update.ReturnWithOutMessage<Model, Message, OutMessage>

const toGotHoverIntentMessage = (message: HoverIntent.Message): Message =>
  Message.GotHoverIntentMessage({ message })

const toGotPopoverMessage = (message: FoldkitPopover.Message): Message =>
  Message.GotPopoverMessage({ message })

const mapPopoverCommands = (
  commands: NonNullable<ReturnType<typeof FoldkitPopover.open>['commands']> = [],
) =>
  Command.mapMessages(
    commands.filter((command) => command.name !== 'FocusButton'),
    toGotPopoverMessage,
  )

const syncPopover = (
  model: Model,
  operation: typeof FoldkitPopover.open | typeof FoldkitPopover.close,
  outMessage: OutMessage,
): UpdateReturn => {
  const result = operation(model.popover)
  return {
    model: evo(model, { popover: () => result.model }),
    commands: mapPopoverCommands(result.commands),
    outMessage,
  }
}

const foldHoverIntentOutMessage = M.type<HoverIntent.OutMessage>().pipe(
  M.withReturnType<Update.StepWithOutMessage<Model, Message, OutMessage>>(),
  M.tagsExhaustive({
    Opened: () => (model) => syncPopover(model, FoldkitPopover.open, OutMessage.Opened()),
    Closed: () => (model) => syncPopover(model, FoldkitPopover.close, OutMessage.Closed()),
  }),
)

const foldHoverIntent = Update.foldChild({
  update: HoverIntent.update,
  read: (model: Model) => Option.some(model.hoverIntent),
  write: (model, hoverIntent) => evo(model, { hoverIntent: () => hoverIntent }),
  toParentMessage: toGotHoverIntentMessage,
  foldOutMessage: foldHoverIntentOutMessage,
})

const releaseTriggerFocus = (model: Model): Model =>
  evo(model, {
    hoverIntent: (hoverIntent) =>
      evo(hoverIntent, {
        maybeFocusLocation: () =>
          Option.filter(hoverIntent.maybeFocusLocation, (location) => location !== 'Trigger'),
      }),
  })

export const update = (model: Model, message: Message): UpdateReturn =>
  M.value(message).pipe(
    M.withReturnType<UpdateReturn>(),
    M.tagsExhaustive({
      GotHoverIntentMessage: ({ message: hoverIntentMessage }) =>
        hoverIntentMessage._tag === 'FocusedPanel' || hoverIntentMessage._tag === 'BlurredPanel'
          ? { model }
          : foldHoverIntent(
              hoverIntentMessage._tag === 'LeftTrigger' ? releaseTriggerFocus(model) : model,
              hoverIntentMessage,
            ),
      GotPopoverMessage: ({ message: popoverMessage }) => {
        const result = FoldkitPopover.update(model.popover, popoverMessage)
        const hoverIntent =
          popoverMessage._tag === 'RequestedClose'
            ? HoverIntent.close(model.hoverIntent)
            : { model: model.hoverIntent }
        const base = {
          model: evo(model, {
            hoverIntent: () => hoverIntent.model,
            popover: () => result.model,
          }),
          commands: mapPopoverCommands(result.commands),
        }
        if (hoverIntent.outMessage === undefined) return base
        return { ...base, outMessage: hoverIntent.outMessage }
      },
    }),
  )

export type ViewInputs = Readonly<{
  anchor: AnchorConfig
  toView: (render: RenderInfo) => Html
  isDisabled?: boolean
  ariaLabel?: string
  ariaLabelledBy?: string
}>

export type RenderInfo = Readonly<{
  trigger: ReadonlyArray<ChildAttribute>
  panel: ReadonlyArray<ChildAttribute>
  backdrop: ReadonlyArray<ChildAttribute>
  isVisible: boolean
}>

/** Popover has click-toggle handlers; HoverIntent exclusively owns interaction. */
const withoutPopoverInteractions = (
  attributes: ReadonlyArray<ChildAttribute>,
): ReadonlyArray<ChildAttribute> =>
  attributes.filter((wrapped) => {
    // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: foldkit attribute._tag
    const tag = (wrapped.attribute as { readonly _tag?: string } | undefined)?._tag
    return ![
      'OnPointerDown',
      'OnClick',
      'OnKeyDownPreventDefault',
      'OnKeyUpPreventDefault',
    ].includes(tag ?? '')
  })

export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h) => {
  const { anchor, toView, isDisabled, ariaLabel, ariaLabelledBy } = viewInputs

  return h.submodel({
    slotId: `${model.popover.id}-hover-intent`,
    model: model.hoverIntent,
    view: HoverIntent.view,
    viewInputs: {
      focusTriggerSelector: `#${FoldkitPopover.buttonId(model.popover.id)}`,
      toView: ({ trigger, panel }) =>
        h.submodel({
          slotId: `${model.popover.id}-popover`,
          model: model.popover,
          view: FoldkitPopover.view,
          viewInputs: {
            anchor,
            // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread
            ...(isDisabled === undefined ? {} : { isDisabled }),
            // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread
            ...(ariaLabel === undefined ? {} : { ariaLabel }),
            // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread
            ...(ariaLabelledBy === undefined ? {} : { ariaLabelledBy }),
            toView: ({ button, panel: popoverPanel, backdrop, isVisible }) =>
              toView({
                trigger: [
                  ...withoutPopoverInteractions(button),
                  ...(isDisabled ? [] : trigger),
                  ...childAttributes([
                    h.AriaDescribedBy(`${model.popover.id}-panel`),
                    h.DataAttribute('slot', 'hover-card-trigger'),
                  ]),
                ],
                panel: [
                  ...withoutPopoverInteractions(popoverPanel),
                  ...(isDisabled ? [] : panel),
                  ...childAttributes([h.DataAttribute('slot', 'hover-card-content')]),
                ],
                backdrop: [
                  ...withoutPopoverInteractions(backdrop),
                  ...childAttributes([
                    h.OnClick(
                      Message.GotPopoverMessage({
                        message: FoldkitPopover.Message.RequestedClose(),
                      }),
                    ),
                    h.DataAttribute('slot', 'hover-card-backdrop'),
                  ]),
                ],
                isVisible,
              }),
          },
          toParentMessage: toGotPopoverMessage,
        }),
    },
    toParentMessage: toGotHoverIntentMessage,
  })
})

export type StyledViewInputs = Readonly<{
  anchor?: AnchorConfig
  trigger: Child
  content: ReadonlyArray<Child>
  isDisabled?: boolean
  ariaLabel?: string
  ariaLabelledBy?: string
  className?: string
  triggerClass?: string
  contentClass?: string
  wrapperClass?: string
  isAnimated?: boolean
}>

export const styledViewInputs = <AppMessage>(
  viewInputs: StyledViewInputs,
  h: HtmlBuilder<AppMessage>,
): ViewInputs => ({
  anchor: viewInputs.anchor ?? HOVER_CARD_ANCHOR,
  isDisabled: viewInputs.isDisabled,
  ariaLabel: viewInputs.ariaLabel,
  ariaLabelledBy: viewInputs.ariaLabelledBy,
  toView: ({ trigger, panel, backdrop, isVisible }) =>
    h.div(
      [
        h.Class(cn(hoverCardWrapperClass, viewInputs.wrapperClass)),
        h.DataAttribute('slot', 'hover-card'),
      ],
      [
        h.button(
          [...trigger, h.Class(cn(hoverCardTriggerClass, viewInputs.triggerClass))],
          [viewInputs.trigger],
        ),
        ...(isVisible
          ? [
              h.div([...backdrop, h.Class('fixed inset-0 z-0')]),
              h.div(
                [
                  ...panel,
                  h.Class(
                    cn(
                      viewInputs.isAnimated !== false
                        ? hoverCardContentAnimatedClass
                        : hoverCardContentClass,
                      viewInputs.contentClass,
                    ),
                  ),
                ],
                viewInputs.content,
              ),
            ]
          : []),
      ],
    ),
})