shshadcn-htmx

Components

Tooltip

A short label attached to a control. Pure CSS show on hover + focus; ESC dismisses. APG-compliant — must contain text only (no buttons, no links). For interactive overlays use Dialog or the upcoming Popover.

Installation

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/tooltip.json

2. Use it

components/ui/tooltip.tsx
import { Tooltip } from "@/components/ui/tooltip"

<Tooltip id="save-tt" content="Saves your draft (⌘ + S)" side="top">
  <Button>Save</Button>
</Tooltip>
Or copy the source manually
components/ui/tooltip.tsx
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cloneElement, isValidElement } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"

// Tooltip — shadcn-htmx, htmx v4 + Tailwind v4.
//
// shadcn upstream uses Radix Tooltip. For our SSR setup we lean on CSS
// hover + focus-within to show the tooltip — no client state machine
// needed for the common case. A tiny ESC handler in public/site.js
// implements the APG dismissal contract.
//
// APG rules we implement:
//   - Tooltip appears on hover AND keyboard focus (not just hover).
//   - ESC dismisses the visible tooltip.
//   - The tooltip is referenced by aria-describedby on the trigger so AT
//     announces it after the trigger's own name ("Save, Saves your work
//     to the server").
//   - The tooltip must NOT contain interactive content (no buttons, no
//     links) — if you need that, use Popover (coming) instead.
//
// Refs:
//   repos/aria-practices/content/patterns/tooltip/
//   repos/mdn/files/en-us/web/accessibility/aria/reference/roles/tooltip_role/
//   repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-describedby/

export type TooltipSide = "top" | "right" | "bottom" | "left"

const wrapperBase =
  // inline-block + w-fit so the wrapper shrink-wraps the trigger button.
  // The absolute-positioned tooltip child must not contribute width OR
  // cause the wrapper to stretch. Two pitfalls:
  //   1. inline-flex includes abs children in some sizing calcs → wider
  //      than the button → broken horizontal centring.
  //   2. CSS Grid and Flexbox blockify inline-* direct children (inline-block
  //      → block), which stretches the wrapper to fill the cell.
  // w-fit (width: fit-content) survives both — even when blockified to
  // block by a grid parent, the wrapper still shrinks to the trigger's
  // intrinsic width.
  "relative inline-block w-fit group/tooltip align-middle " +
  // Show on hover OR focus-within (APG: keyboard users get the same reveal).
  "[&:hover>[data-slot=tooltip-content]]:opacity-100 " +
  "[&:focus-within>[data-slot=tooltip-content]]:opacity-100 " +
  // ESC handler sets data-suppress="true"; we forcibly hide while it's set.
  "[&[data-suppress=true]>[data-slot=tooltip-content]]:opacity-0!"

const contentBase =
  "pointer-events-none absolute z-50 w-max max-w-xs rounded-md bg-foreground px-2 py-1 text-xs font-medium text-background shadow-md opacity-0 transition-opacity duration-150 " +
  // dark mode flips colors so tooltip stays high-contrast.
  "dark:bg-foreground dark:text-background"

const sidePosition: Record<TooltipSide, string> = {
  top: "left-1/2 -translate-x-1/2 bottom-[calc(100%+0.5rem)]",
  bottom: "left-1/2 -translate-x-1/2 top-[calc(100%+0.5rem)]",
  left: "top-1/2 -translate-y-1/2 right-[calc(100%+0.5rem)]",
  right: "top-1/2 -translate-y-1/2 left-[calc(100%+0.5rem)]",
}

type TooltipProps = PropsWithChildren<{
  // Required for aria-describedby pairing.
  id: string
  // Tooltip text (must be plain or limited inline content — no buttons).
  content: string
  side?: TooltipSide
  // Show only on hover (skip focus). Defaults to false — both hover and
  // focus reveal, per APG. Setting true is a degradation; keyboard users
  // lose discoverability.
  hoverOnly?: boolean
  class?: ClassValue
  contentClass?: ClassValue
}>

export function Tooltip(props: TooltipProps) {
  const {
    id,
    content,
    side = "top",
    hoverOnly = false,
    class: className,
    contentClass,
    children,
  } = props
  // APG/MDN: aria-describedby must live on the element that RECEIVES FOCUS —
  // the trigger — not on this inert wrapper span, or AT won't announce the
  // tooltip when the trigger is focused. Clone the single child to attach it.
  // Fall back to the wrapper only if children isn't one valid element, so the
  // description relationship is never silently dropped.
  const onTrigger = isValidElement(children)
  const trigger = onTrigger
    ? cloneElement(children as any, { "aria-describedby": id })
    : children
  return (
    <span
      data-slot="tooltip"
      data-side={side}
      data-tooltip-trigger
      // Tab-targetable for keyboard reveal. Skip when hoverOnly.
      tabindex={hoverOnly ? -1 : undefined}
      class={cn(wrapperBase, className)}
      aria-describedby={onTrigger ? undefined : id}
    >
      {trigger}
      <span
        id={id}
        role="tooltip"
        data-slot="tooltip-content"
        data-side={side}
        class={cn(contentBase, sidePosition[side], contentClass)}
      >
        {content}
      </span>
    </span>
  )
}

1. Save the file

Copy tooltip.html into templates/components/.

2. Use it

templates/components/tooltip.html
{% from "components/tooltip.html" import tooltip_open %}

{% call tooltip_open(id="save-tt", content="Saves your draft (⌘ + S)") %}
  <button class="…" aria-describedby="save-tt">Save</button>
{% endcall %}
View source
templates/components/tooltip.html
{# Tooltip macro — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/tooltip.tsx. Pure CSS hover + focus-within; ESC
   dismissal lives in public/site.js.

   Usage — put aria-describedby on YOUR trigger (reuse the same id). The macro
   can't reach into the {% call %} slot to add it, and APG requires it on the
   focusable trigger, not this wrapper:
     {% from "components/tooltip.html" import tooltip_open %}

     {% call tooltip_open(id="save-tt", content="Saves your draft", side="top") %}
       <button class="…" aria-describedby="save-tt">Save</button>
     {% endcall %} #}

{% macro tooltip_open(id, content, side="top", extra_class="", content_class="") %}
{%- set wrapper -%}
relative inline-block w-fit group/tooltip align-middle [&:hover>[data-slot=tooltip-content]]:opacity-100 [&:focus-within>[data-slot=tooltip-content]]:opacity-100 [&[data-suppress=true]>[data-slot=tooltip-content]]:opacity-0!
{%- endset -%}
{%- set content_base -%}
pointer-events-none absolute z-50 w-max max-w-xs rounded-md bg-foreground px-2 py-1 text-xs font-medium text-background shadow-md opacity-0 transition-opacity duration-150 dark:bg-foreground dark:text-background
{%- endset -%}
{%- set sides = {
    "top":    "left-1/2 -translate-x-1/2 bottom-[calc(100%+0.5rem)]",
    "bottom": "left-1/2 -translate-x-1/2 top-[calc(100%+0.5rem)]",
    "left":   "top-1/2 -translate-y-1/2 right-[calc(100%+0.5rem)]",
    "right":  "top-1/2 -translate-y-1/2 left-[calc(100%+0.5rem)]"
} -%}
<span data-slot="tooltip" data-side="{{ side }}" data-tooltip-trigger
      class="{{ wrapper }} {{ extra_class }}">
  {{ caller() }}
  <span id="{{ id }}" role="tooltip" data-slot="tooltip-content" data-side="{{ side }}"
        class="{{ content_base }} {{ sides[side] }} {{ content_class }}">{{ content }}</span>
</span>
{% endmacro %}

1. Save the file

Add tooltip.tmpl alongside button.tmpl.

2. Use it

templates/components/tooltip.tmpl
{{template "tooltip" (dict
  "ID" "save-tt" "Content" "Saves your draft (⌘ + S)"
  "Body" (htmlSafe `<button class="…" aria-describedby="save-tt">Save</button>`)
)}}
View source
templates/components/tooltip.tmpl
{{/*
  Tooltip template — shadcn-htmx, htmx v4 + Tailwind v4.

      type TooltipArgs struct {
          ID, Content, Side string
          Body              template.HTML  // the trigger element
          Class             string         // optional: appended to the wrapper span
          ContentClass      string         // optional: appended to the content span
      }

  Put aria-describedby="<ID>" on the trigger element you pass as Body — APG
  requires it on the focusable trigger, and the template can't add it for you.
*/}}

{{define "tooltip"}}
{{- $side := or .Side "top" -}}
{{- $wrapper := "relative inline-block w-fit group/tooltip align-middle [&:hover>[data-slot=tooltip-content]]:opacity-100 [&:focus-within>[data-slot=tooltip-content]]:opacity-100 [&[data-suppress=true]>[data-slot=tooltip-content]]:opacity-0!" -}}
{{- $contentBase := "pointer-events-none absolute z-50 w-max max-w-xs rounded-md bg-foreground px-2 py-1 text-xs font-medium text-background shadow-md opacity-0 transition-opacity duration-150 dark:bg-foreground dark:text-background" -}}
{{- $sides := dict
    "top"    "left-1/2 -translate-x-1/2 bottom-[calc(100%+0.5rem)]"
    "bottom" "left-1/2 -translate-x-1/2 top-[calc(100%+0.5rem)]"
    "left"   "top-1/2 -translate-y-1/2 right-[calc(100%+0.5rem)]"
    "right"  "top-1/2 -translate-y-1/2 left-[calc(100%+0.5rem)]" -}}
<span data-slot="tooltip" data-side="{{$side}}" data-tooltip-trigger
      class="{{$wrapper}} {{.Class}}">
  {{.Body}}
  <span id="{{.ID}}" role="tooltip" data-slot="tooltip-content" data-side="{{$side}}"
        class="{{$contentBase}} {{index $sides $side}} {{.ContentClass}}">{{.Content}}</span>
</span>
{{end}}

1. Save the file

Drop tooltip.ex into lib/my_app_web/components/.

2. Use it

lib/my_app_web/components/tooltip.ex
<.tooltip id="save-tt" content="Saves your draft (⌘ + S)">
  <button class="…" aria-describedby="save-tt">Save</button>
</.tooltip>
View source
lib/my_app_web/components/tooltip.ex
defmodule ShadcnHtmx.Components.Tooltip do
  @moduledoc """
  Tooltip — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  CSS-only show/hide on hover + focus-within. ESC dismissal handled by
  public/site.js. APG tooltip pattern: must reveal on keyboard focus too,
  must be dismissible with ESC, and must NOT contain interactive content.

  ## Examples

  Put aria-describedby on your trigger (reuse the id) — APG requires it on the
  focusable trigger, not this wrapper, and the slot content can't be rewritten:

      <.tooltip id="save-tt" content="Saves your draft">
        <button class="…" aria-describedby="save-tt">Save</button>
      </.tooltip>
  """

  use Phoenix.Component

  @wrapper "relative inline-block w-fit group/tooltip align-middle " <>
             "[&:hover>[data-slot=tooltip-content]]:opacity-100 " <>
             "[&:focus-within>[data-slot=tooltip-content]]:opacity-100 " <>
             "[&[data-suppress=true]>[data-slot=tooltip-content]]:opacity-0!"

  @content_base "pointer-events-none absolute z-50 w-max max-w-xs rounded-md bg-foreground px-2 py-1 text-xs font-medium text-background shadow-md opacity-0 transition-opacity duration-150 dark:bg-foreground dark:text-background"

  @sides %{
    "top" => "left-1/2 -translate-x-1/2 bottom-[calc(100%+0.5rem)]",
    "bottom" => "left-1/2 -translate-x-1/2 top-[calc(100%+0.5rem)]",
    "left" => "top-1/2 -translate-y-1/2 right-[calc(100%+0.5rem)]",
    "right" => "top-1/2 -translate-y-1/2 left-[calc(100%+0.5rem)]"
  }

  attr :id, :string, required: true
  attr :content, :string, required: true
  attr :side, :string, default: "top", values: ~w(top right bottom left)
  attr :class, :string, default: nil
  slot :inner_block, required: true

  def tooltip(assigns) do
    assigns =
      assigns
      |> assign(:wrapper, @wrapper)
      |> assign(:content_base, @content_base)
      |> assign(:side_class, Map.fetch!(@sides, assigns.side))

    ~H"""
    <span
      data-slot="tooltip"
      data-side={@side}
      data-tooltip-trigger
      class={[@wrapper, @class]}
    >
      {render_slot(@inner_block)}
      <span
        id={@id}
        role="tooltip"
        data-slot="tooltip-content"
        data-side={@side}
        class={[@content_base, @side_class]}
      >
        {@content}
      </span>
    </span>
    """
  end
end

1. Save the file

Includes the ESC dismissal script. Copy once per page.

2. Use it

index.html
<span data-slot="tooltip" data-side="top" data-tooltip-trigger
      class="relative inline-block w-fit group/tooltip align-middle [&:hover>[data-slot=tooltip-content]]:opacity-100 …">
  <button aria-describedby="save-tt">Save</button>
  <span id="save-tt" role="tooltip" class="…">Saves your draft (⌘ + S)</span>
</span>
View source
index.html
<!--
  shadcn-htmx — raw HTML tooltip snippet.

  Pure CSS show on :hover + :focus-within. ESC dismissal via a tiny global
  keydown handler (copy at the bottom).

  WRAPPER (note: inline-block, NOT inline-flex — flex sizing was including
  the absolute tooltip width, which broke the left-1/2 horizontal centring):
    relative inline-block group/tooltip align-middle
    [&:hover>[data-slot=tooltip-content]]:opacity-100
    [&:focus-within>[data-slot=tooltip-content]]:opacity-100
    [&[data-suppress=true]>[data-slot=tooltip-content]]:opacity-0!
  CONTENT:
    pointer-events-none absolute z-50 w-max max-w-xs rounded-md bg-foreground
    px-2 py-1 text-xs font-medium text-background shadow-md opacity-0
    transition-opacity duration-150
-->

<span data-slot="tooltip" data-side="top" data-tooltip-trigger
      class="relative inline-block w-fit group/tooltip align-middle [&:hover>[data-slot=tooltip-content]]:opacity-100 [&:focus-within>[data-slot=tooltip-content]]:opacity-100 [&[data-suppress=true]>[data-slot=tooltip-content]]:opacity-0!">
  <!-- aria-describedby goes on the focusable trigger (APG), not the wrapper. -->
  <button aria-describedby="save-tt" class="inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90">
    Save
  </button>
  <span id="save-tt" role="tooltip" data-slot="tooltip-content" data-side="top"
        class="pointer-events-none absolute z-50 w-max max-w-xs rounded-md bg-foreground px-2 py-1 text-xs font-medium text-background shadow-md opacity-0 transition-opacity duration-150 left-1/2 -translate-x-1/2 bottom-[calc(100%+0.5rem)]">
    Saves your draft to the server (⌘ + S)
  </span>
</span>

<!-- ESC dismissal — copy once per page -->
<script>
  document.addEventListener('keydown', function (e) {
    if (e.key !== 'Escape') return
    var active = document.activeElement
    if (!active || !active.closest) return
    var trigger = active.closest('[data-tooltip-trigger]')
    if (!trigger) return
    trigger.setAttribute('data-suppress', 'true')
    active.blur()
  })
  document.addEventListener('mouseleave', function (e) {
    if (e.target && e.target.removeAttribute) e.target.removeAttribute('data-suppress')
  }, true)
  // Clear suppress when focus leaves the trigger too — keyboard users
  // who Tab away and Tab back should see the tooltip again.
  document.addEventListener('focusout', function (e) {
    var t = e.target && e.target.closest && e.target.closest('[data-tooltip-trigger]')
    if (t) t.removeAttribute('data-suppress')
  }, true)
</script>

Examples

Basic — hover or focus to reveal

Hover the button or tab to it; the tooltip slides in. The trigger has aria-describedby pointing at the tooltip text so AT announces it after the trigger's own name.

APG says a tooltip is "a popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it." Both reveal triggers matter: keyboard users can't hover, so focus-reveal is non-negotiable. The text must be passive — no buttons, no links inside.

Saves your draft to the server (⌘ + S)
<Tooltip id="save-tt" content="Saves your draft (⌘ + S)">
  <Button>Save</Button>
</Tooltip>
{% call tooltip_open(id="save-tt", content="Saves your draft (⌘ + S)") %}
  <button class="…" aria-describedby="save-tt">Save</button>
{% endcall %}
{{template "tooltip" (dict
  "ID" "save-tt" "Content" "Saves your draft (⌘ + S)"
  "Body" (htmlSafe `<button class="…" aria-describedby="save-tt">Save</button>`)
)}}
<.tooltip id="save-tt" content="Saves your draft (⌘ + S)">
  <button class="…" aria-describedby="save-tt">Save</button>
</.tooltip>
<div class="flex items-center justify-center">
  <span data-slot="tooltip" data-side="top" data-tooltip-trigger="true" class="relative inline-block w-fit group/tooltip align-middle [&amp;:hover&gt;[data-slot=tooltip-content]]:opacity-100 [&amp;:focus-within&gt;[data-slot=tooltip-content]]:opacity-100 [&amp;[data-suppress=true]&gt;[data-slot=tooltip-content]]:opacity-0!">
    <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2 has-[&gt;svg]:px-3" aria-describedby="ex-tt-save" data-slot="button" data-variant="default" data-size="default">Save</button>
    <span id="ex-tt-save" role="tooltip" data-slot="tooltip-content" data-side="top" class="pointer-events-none absolute z-50 w-max max-w-xs rounded-md bg-foreground px-2 py-1 text-xs font-medium text-background shadow-md opacity-0 transition-opacity duration-150 dark:bg-foreground dark:text-background left-1/2 -translate-x-1/2 bottom-[calc(100%+0.5rem)]">Saves your draft to the server (⌘ + S)</span>
  </span>
</div>

Sides — top, right, bottom, left

Pick the side that won't clip against the viewport edge. Default is top.

For a smarter "auto-flip" behaviour you'd need the CSS Anchor Positioning API (still experimental) or a JS positioner like Floating UI. For most uses, picking the right side at author time covers it.

On topOn the rightOn the bottomOn the left
<Tooltip side="top"    content="On top">…</Tooltip>
<Tooltip side="right"  content="…">…</Tooltip>
<Tooltip side="bottom" content="…">…</Tooltip>
<Tooltip side="left"   content="…">…</Tooltip>
{% call tooltip_open(id="…", content="On top", side="top") %}…{% endcall %}
{% call tooltip_open(id="…", content="…",     side="right") %}…{% endcall %}
{{template "tooltip" (dict "ID" "…" "Content" "On top"    "Side" "top"    "Body" (htmlSafe `…`))}}
{{template "tooltip" (dict "ID" "…" "Content" "…"         "Side" "right"  "Body" (htmlSafe `…`))}}
<.tooltip id="…" content="On top"    side="top"></.tooltip>
<.tooltip id="…" content="…"         side="right"></.tooltip>
<div class="flex flex-wrap items-center justify-around gap-x-24 gap-y-16 py-16">
  <span data-slot="tooltip" data-side="top" data-tooltip-trigger="true" class="relative inline-block w-fit group/tooltip align-middle [&amp;:hover&gt;[data-slot=tooltip-content]]:opacity-100 [&amp;:focus-within&gt;[data-slot=tooltip-content]]:opacity-100 [&amp;[data-suppress=true]&gt;[data-slot=tooltip-content]]:opacity-0!">
    <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-8 gap-1.5 rounded-md px-3 has-[&gt;svg]:px-2.5" aria-describedby="ex-tt-t" data-slot="button" data-variant="outline" data-size="sm">top</button>
    <span id="ex-tt-t" role="tooltip" data-slot="tooltip-content" data-side="top" class="pointer-events-none absolute z-50 w-max max-w-xs rounded-md bg-foreground px-2 py-1 text-xs font-medium text-background shadow-md opacity-0 transition-opacity duration-150 dark:bg-foreground dark:text-background left-1/2 -translate-x-1/2 bottom-[calc(100%+0.5rem)]">On top</span>
  </span>
  <span data-slot="tooltip" data-side="right" data-tooltip-trigger="true" class="relative inline-block w-fit group/tooltip align-middle [&amp;:hover&gt;[data-slot=tooltip-content]]:opacity-100 [&amp;:focus-within&gt;[data-slot=tooltip-content]]:opacity-100 [&amp;[data-suppress=true]&gt;[data-slot=tooltip-content]]:opacity-0!">
    <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-8 gap-1.5 rounded-md px-3 has-[&gt;svg]:px-2.5" aria-describedby="ex-tt-r" data-slot="button" data-variant="outline" data-size="sm">right</button>
    <span id="ex-tt-r" role="tooltip" data-slot="tooltip-content" data-side="right" class="pointer-events-none absolute z-50 w-max max-w-xs rounded-md bg-foreground px-2 py-1 text-xs font-medium text-background shadow-md opacity-0 transition-opacity duration-150 dark:bg-foreground dark:text-background top-1/2 -translate-y-1/2 left-[calc(100%+0.5rem)]">On the right</span>
  </span>
  <span data-slot="tooltip" data-side="bottom" data-tooltip-trigger="true" class="relative inline-block w-fit group/tooltip align-middle [&amp;:hover&gt;[data-slot=tooltip-content]]:opacity-100 [&amp;:focus-within&gt;[data-slot=tooltip-content]]:opacity-100 [&amp;[data-suppress=true]&gt;[data-slot=tooltip-content]]:opacity-0!">
    <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-8 gap-1.5 rounded-md px-3 has-[&gt;svg]:px-2.5" aria-describedby="ex-tt-b" data-slot="button" data-variant="outline" data-size="sm">bottom</button>
    <span id="ex-tt-b" role="tooltip" data-slot="tooltip-content" data-side="bottom" class="pointer-events-none absolute z-50 w-max max-w-xs rounded-md bg-foreground px-2 py-1 text-xs font-medium text-background shadow-md opacity-0 transition-opacity duration-150 dark:bg-foreground dark:text-background left-1/2 -translate-x-1/2 top-[calc(100%+0.5rem)]">On the bottom</span>
  </span>
  <span data-slot="tooltip" data-side="left" data-tooltip-trigger="true" class="relative inline-block w-fit group/tooltip align-middle [&amp;:hover&gt;[data-slot=tooltip-content]]:opacity-100 [&amp;:focus-within&gt;[data-slot=tooltip-content]]:opacity-100 [&amp;[data-suppress=true]&gt;[data-slot=tooltip-content]]:opacity-0!">
    <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-8 gap-1.5 rounded-md px-3 has-[&gt;svg]:px-2.5" aria-describedby="ex-tt-l" data-slot="button" data-variant="outline" data-size="sm">left</button>
    <span id="ex-tt-l" role="tooltip" data-slot="tooltip-content" data-side="left" class="pointer-events-none absolute z-50 w-max max-w-xs rounded-md bg-foreground px-2 py-1 text-xs font-medium text-background shadow-md opacity-0 transition-opacity duration-150 dark:bg-foreground dark:text-background top-1/2 -translate-y-1/2 right-[calc(100%+0.5rem)]">On the left</span>
  </span>
</div>

Focus + ESC dismissal

Tab to the trigger — tooltip appears. Press ESC — tooltip hides until you move pointer/focus elsewhere (the APG dismissal contract).

The ESC contract matters: users who have a tooltip blocking what they want to read need a way to dismiss it. Our handler sets data-suppress="true" on the trigger which a CSS rule honours; the suppression clears the next time the user mouseleaves the trigger.

Press Tab to focus me; ESC to dismissSame — try keyboard alone
// Hover OR focus reveals; ESC dismisses (handled by site.js).
<Tooltip id="kb" content="Press Tab to focus me; ESC to dismiss">
  <Button>Tab here</Button>
</Tooltip>
{% call tooltip_open(id="kb", content="Press Tab to focus me; ESC to dismiss") %}
  {{ button("Tab here") }}
{% endcall %}
{{template "tooltip" (dict "ID" "kb" "Content" "Press Tab to focus me; ESC to dismiss"
  "Body" (htmlSafe `{{template "button" (dict "Label" "Tab here")}}`))}}
<.tooltip id="kb" content="Press Tab to focus me; ESC to dismiss">
  <.button>Tab here</.button>
</.tooltip>
<div class="flex items-center justify-center gap-4">
  <span data-slot="tooltip" data-side="top" data-tooltip-trigger="true" class="relative inline-block w-fit group/tooltip align-middle [&amp;:hover&gt;[data-slot=tooltip-content]]:opacity-100 [&amp;:focus-within&gt;[data-slot=tooltip-content]]:opacity-100 [&amp;[data-suppress=true]&gt;[data-slot=tooltip-content]]:opacity-0!">
    <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-9 px-4 py-2 has-[&gt;svg]:px-3" aria-describedby="ex-tt-kb-1" data-slot="button" data-variant="outline" data-size="default">Tab here</button>
    <span id="ex-tt-kb-1" role="tooltip" data-slot="tooltip-content" data-side="top" class="pointer-events-none absolute z-50 w-max max-w-xs rounded-md bg-foreground px-2 py-1 text-xs font-medium text-background shadow-md opacity-0 transition-opacity duration-150 dark:bg-foreground dark:text-background left-1/2 -translate-x-1/2 bottom-[calc(100%+0.5rem)]">Press Tab to focus me; ESC to dismiss</span>
  </span>
  <span data-slot="tooltip" data-side="top" data-tooltip-trigger="true" class="relative inline-block w-fit group/tooltip align-middle [&amp;:hover&gt;[data-slot=tooltip-content]]:opacity-100 [&amp;:focus-within&gt;[data-slot=tooltip-content]]:opacity-100 [&amp;[data-suppress=true]&gt;[data-slot=tooltip-content]]:opacity-0!">
    <button type="button" class="inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]:size-4 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 h-9 px-4 py-2 has-[&gt;svg]:px-3" aria-describedby="ex-tt-kb-2" data-slot="button" data-variant="outline" data-size="default">Then here</button>
    <span id="ex-tt-kb-2" role="tooltip" data-slot="tooltip-content" data-side="top" class="pointer-events-none absolute z-50 w-max max-w-xs rounded-md bg-foreground px-2 py-1 text-xs font-medium text-background shadow-md opacity-0 transition-opacity duration-150 dark:bg-foreground dark:text-background left-1/2 -translate-x-1/2 bottom-[calc(100%+0.5rem)]">Same — try keyboard alone</span>
  </span>
</div>

API Reference

<Tooltip>

PropTypeDefaultDescription
id*string
Used in the trigger's aria-describedby.
content*string
Tooltip text. Must be plain — no buttons or links (APG).
side"top"|"right"|"bottom"|"left""top"
Placement relative to trigger.
hoverOnlybooleanfalse
Skip focus-reveal. Use only when keyboard users have no need (degradation).
classstring
Extra Tailwind classes appended to the root element.

* required