Styled tooltip with trigger and hover/focus reveal, built on the @foldkit/ui Tooltip submodel.

Preview
Style
Basic
Sides
src/demo/views/tooltip.ts208 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 tooltip from '../../generated/registry/ui/tooltip'

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

const Message = defineMessageUnion({
  GotTooltipMessage: { message: tooltip.Message },
  GotTopTooltipMessage: { message: tooltip.Message },
  GotRightTooltipMessage: { message: tooltip.Message },
  GotBottomTooltipMessage: { message: tooltip.Message },
  GotLeftTooltipMessage: { message: tooltip.Message },
})

export const tooltipView = (model: Model, h: HtmlBuilder<AppMessage>): Html =>
  h.div(
    [h.Class('flex w-full flex-col items-center gap-8')],
    [
      h.div(
        [h.Class('flex w-full flex-col items-center gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Basic']),
          h.submodel({
            slotId: model.tooltip.id,
            model: model.tooltip,
            view: tooltip.view,
            viewInputs: tooltip.styledViewInputs(
              {
                anchor: { placement: 'top', gap: 4, padding: 8 },
                trigger: 'Hover',
                content: 'Add to library',
              },
              h,
            ),
            toParentMessage: (message) => Message.GotTooltipMessage({ message }),
          }),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col items-center gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Sides']),
          h.div(
            [h.Class('flex flex-wrap gap-2 justify-center')],
            [
              h.submodel({
                slotId: model.topTooltip.id,
                model: model.topTooltip,
                view: tooltip.view,
                viewInputs: tooltip.styledViewInputs(
                  { anchor: { placement: 'top', gap: 4 }, trigger: 'Top', content: 'Top tooltip' },
                  h,
                ),
                toParentMessage: (message) => Message.GotTopTooltipMessage({ message }),
              }),
              h.submodel({
                slotId: model.rightTooltip.id,
                model: model.rightTooltip,
                view: tooltip.view,
                viewInputs: tooltip.styledViewInputs(
                  {
                    anchor: { placement: 'right', gap: 4 },
                    trigger: 'Right',
                    content: 'Right tooltip',
                  },
                  h,
                ),
                toParentMessage: (message) => Message.GotRightTooltipMessage({ message }),
              }),
              h.submodel({
                slotId: model.bottomTooltip.id,
                model: model.bottomTooltip,
                view: tooltip.view,
                viewInputs: tooltip.styledViewInputs(
                  {
                    anchor: { placement: 'bottom', gap: 4 },
                    trigger: 'Bottom',
                    content: 'Bottom tooltip',
                  },
                  h,
                ),
                toParentMessage: (message) => Message.GotBottomTooltipMessage({ message }),
              }),
              h.submodel({
                slotId: model.leftTooltip.id,
                model: model.leftTooltip,
                view: tooltip.view,
                viewInputs: tooltip.styledViewInputs(
                  {
                    anchor: { placement: 'left', gap: 4 },
                    trigger: 'Left',
                    content: 'Left tooltip',
                  },
                  h,
                ),
                toParentMessage: (message) => Message.GotLeftTooltipMessage({ message }),
              }),
            ],
          ),
        ],
      ),
    ],
  )

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

const foldTooltipOutMessage = M.type<tooltip.OutMessage>().pipe(
  M.withReturnType<Update.Step<State, unknown>>(),
  M.tagsExhaustive({
    Shown: foldNoOp(),
    Hidden: foldNoOp(),
  }),
)

const foldTooltip = Update.foldChild({
  update: tooltip.update,
  read: (model: State) => Option.some(model.tooltip),
  write: (model, next) => evo(model, { tooltip: () => next }),
  toParentMessage: (message) => Message.GotTooltipMessage({ message }),
  foldOutMessage: foldTooltipOutMessage,
})

const foldTopTooltip = Update.foldChild({
  update: tooltip.update,
  read: (model: State) => Option.some(model.topTooltip),
  write: (model, next) => evo(model, { topTooltip: () => next }),
  toParentMessage: (message) => Message.GotTopTooltipMessage({ message }),
  foldOutMessage: foldTooltipOutMessage,
})

const foldRightTooltip = Update.foldChild({
  update: tooltip.update,
  read: (model: State) => Option.some(model.rightTooltip),
  write: (model, next) => evo(model, { rightTooltip: () => next }),
  toParentMessage: (message) => Message.GotRightTooltipMessage({ message }),
  foldOutMessage: foldTooltipOutMessage,
})

const foldBottomTooltip = Update.foldChild({
  update: tooltip.update,
  read: (model: State) => Option.some(model.bottomTooltip),
  write: (model, next) => evo(model, { bottomTooltip: () => next }),
  toParentMessage: (message) => Message.GotBottomTooltipMessage({ message }),
  foldOutMessage: foldTooltipOutMessage,
})

const foldLeftTooltip = Update.foldChild({
  update: tooltip.update,
  read: (model: State) => Option.some(model.leftTooltip),
  write: (model, next) => evo(model, { leftTooltip: () => next }),
  toParentMessage: (message) => Message.GotLeftTooltipMessage({ message }),
  foldOutMessage: foldTooltipOutMessage,
})

const fields = {
  tooltip: tooltip.Model,
  topTooltip: tooltip.Model,
  rightTooltip: tooltip.Model,
  bottomTooltip: tooltip.Model,
  leftTooltip: tooltip.Model,
}

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

export const slice = defineSlice({
  fields,
  init: {
    tooltip: tooltip.init({ id: 'tooltip-demo' }),
    topTooltip: tooltip.init({ id: 'tooltip-top-demo' }),
    rightTooltip: tooltip.init({ id: 'tooltip-right-demo' }),
    bottomTooltip: tooltip.init({ id: 'tooltip-bottom-demo' }),
    leftTooltip: tooltip.init({ id: 'tooltip-left-demo' }),
  },
  messages: [
    Message.GotTooltipMessage,
    Message.GotTopTooltipMessage,
    Message.GotRightTooltipMessage,
    Message.GotBottomTooltipMessage,
    Message.GotLeftTooltipMessage,
  ],
  handlers: (model: State) => ({
    GotTooltipMessage: (payload: typeof Message.GotTooltipMessage.Type): UpdateReturn =>
      foldTooltip(model, payload.message),
    GotTopTooltipMessage: (payload: typeof Message.GotTopTooltipMessage.Type): UpdateReturn =>
      foldTopTooltip(model, payload.message),
    GotRightTooltipMessage: (payload: typeof Message.GotRightTooltipMessage.Type): UpdateReturn =>
      foldRightTooltip(model, payload.message),
    GotBottomTooltipMessage: (payload: typeof Message.GotBottomTooltipMessage.Type): UpdateReturn =>
      foldBottomTooltip(model, payload.message),
    GotLeftTooltipMessage: (payload: typeof Message.GotLeftTooltipMessage.Type): UpdateReturn =>
      foldLeftTooltip(model, payload.message),
  }),
  samples: [],
  // Tooltip show/hide flows entirely through the submodel; the public
  // @foldkit/ui namespace exports no child-message constructors, so there
  // are no top-level samples to feed update().
})

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/tooltip

Source

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

registry/default/ui/tooltip.ts144 lines
/** Stateful submodel — import the whole module as a namespace and wire its
 *  Model/Message/init/update into your app:
 *  `import * as Tooltip from '@/components/ui/tooltip'`
 */
import { Tooltip as FoldkitTooltip } from '@foldkit/ui'
import type { AnchorConfig } from '@foldkit/ui/tooltip'
import type { Attribute, ChildAttribute, Html, HtmlBuilder } from 'foldkit/html'

type Child = Html | string

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

// Re-export the @foldkit/ui Tooltip submodel surface.

export const Model = FoldkitTooltip.Model
export type Model = typeof Model.Type

export const Message = FoldkitTooltip.Message
export type Message = typeof Message.Type

export const OutMessage = FoldkitTooltip.OutMessage
export type OutMessage = typeof OutMessage.Type

export const update = FoldkitTooltip.update
export const reflectShowDelay = FoldkitTooltip.reflectShowDelay
export const triggerId = FoldkitTooltip.triggerId
export const view = FoldkitTooltip.view

export type RenderInfo = FoldkitTooltip.RenderInfo

export type InitConfig = FoldkitTooltip.InitConfig

/** Hover-to-show delay in milliseconds. Matches the shadcn reference
 *  `TooltipProvider` default (`delay = 0`): tooltips appear immediately on
 *  hover/focus. Pass `showDelay` (e.g. `Duration.millis(400)` or `'400 millis'`)
 *  to wait before revealing. */
export const DEFAULT_SHOW_DELAY = 0

/** Create an initial tooltip model. Defaults the delay to `0` (immediate),
 *  matching the shadcn base tooltip. Any caller-supplied `showDelay` wins. */
export const init = (config: InitConfig): Model =>
  FoldkitTooltip.init({ showDelay: DEFAULT_SHOW_DELAY, ...config })

/** Default anchor matching the shadcn reference `TooltipContent` defaults:
 *  `side="top"`, `sideOffset=4`, `align="center"`, `alignOffset=0`.
 *  `placement` maps side+align (a bare side centers the tooltip), `gap` maps
 *  sideOffset, `offset` maps alignOffset and defaults to 0. */
export const TOOLTIP_ANCHOR: AnchorConfig = {
  placement: 'top',
  gap: 4,
}

export const tooltipTriggerClass = 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*=\'size-\'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none [&_svg]:pointer-events-none [&_svg]:shrink-0 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2'

/**
 * The foldkit anchor writes the resolved side to `data-placement`; this view
 * additionally emits `data-side` so upstream's data-[side=…] variants resolve.
 *
 */
export const tooltipContentClass =
  'data-enter:animate-in data-enter:fade-in-0 data-enter:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-leave:animate-out data-leave:fade-out-0 data-leave:zoom-out-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 inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm 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 w-fit max-w-xs origin-(--transform-origin) bg-foreground text-background'

// Mirrors the shadcn v4 base tooltip arrow (`cn-tooltip-arrow` +
// `cn-tooltip-arrow-logical` + Arrow props), inlined and mapped to
// `data-placement`. The logical (`inline-start`/`inline-end`) variants are
// omitted: foldkit placements are always physical (LTR), so the physical
// left/right rules already cover them. `absolute` is added because foldcn
// renders its own arrow element where base-ui's Arrow is positioned by the
// library; the panel's inline `position: absolute` (from the anchor mount)
// is its containing block.
/** Upstream arrow string plus `absolute` (foldcn renders its own arrow
 *  element where Base UI positions the Arrow itself). Side variants key on
 *  the emitted data-side attribute. */
export const tooltipArrowClass =
  'data-[side=bottom]:left-1/2 data-[side=bottom]:-translate-x-1/2 data-[side=top]:left-1/2 data-[side=top]:-translate-x-1/2 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 absolute z-50 bg-foreground fill-foreground size-2.5 rotate-45 rounded-[2px] translate-y-[calc(-50%-2px)] data-[side=bottom]:top-1 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5'

export const tooltipWrapperClass = 'relative inline-block'

export type StyledViewInputs<M = never> = Readonly<{
  /** Positioning overrides. Defaults to `TOOLTIP_ANCHOR`
   *  (`placement: 'top'`, `gap: 4`), matching the shadcn reference. */
  anchor?: AnchorConfig
  /** Trigger element content. */
  trigger: Child
  /** Tooltip text. */
  content: Child
  className?: string
  triggerClass?: string
  contentClass?: string
  wrapperClass?: string
  /** When true, the trigger is skipped by keyboard focus and only pointer
   *  hover can reveal the tooltip. */
  hoverOnly?: boolean
  /** Attributes applied to the generated trigger element. */
  triggerAttributes?: ReadonlyArray<Attribute<M> | ChildAttribute>
}>

/** Build styled `Tooltip.ViewInputs`. Pass your view's `h`. */
export const styledViewInputs = <M>(
  viewInputs: StyledViewInputs<M>,
  h: HtmlBuilder<M>,
): FoldkitTooltip.ViewInputs => {
  const anchor = { ...TOOLTIP_ANCHOR, ...viewInputs.anchor }
  const side = (anchor.placement ?? 'top').split('-')[0] || 'top'
  return {
    anchor,
    toView: ({ trigger, panel, isVisible }) =>
      h.div(
        [
          h.Class(cn(tooltipWrapperClass, viewInputs.wrapperClass)),
          h.DataAttribute('slot', 'tooltip'),
        ],
        [
          h.button(
            [
              ...(viewInputs.triggerAttributes ?? []),
              ...trigger,
              ...(viewInputs.hoverOnly === true ? [h.Attribute('tabindex', '-1')] : []),
              h.Class(cn(tooltipTriggerClass, viewInputs.triggerClass)),
              h.DataAttribute('slot', 'tooltip-trigger'),
            ],
            [viewInputs.trigger],
          ),
          ...(isVisible
            ? [
                h.div(
                  [
                    ...panel,
                    h.DataAttribute('slot', 'tooltip-content'),
                    h.DataAttribute('side', side),
                    h.Class(cn(tooltipContentClass, viewInputs.contentClass)),
                  ],
                  [
                    viewInputs.content,
                    h.div([h.DataAttribute('side', side), h.Class(tooltipArrowClass)], []),
                  ],
                ),
              ]
            : []),
        ],
      ),
  }
}