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.json2. Use it
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
/** @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
{% 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
{# 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
{{template "tooltip" (dict
"ID" "save-tt" "Content" "Saves your draft (⌘ + S)"
"Body" (htmlSafe `<button class="…" aria-describedby="save-tt">Save</button>`)
)}}View source
{{/*
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
<.tooltip id="save-tt" content="Saves your draft (⌘ + S)">
<button class="…" aria-describedby="save-tt">Save</button>
</.tooltip>View source
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
<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
<!--
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.
<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 [&:hover>[data-slot=tooltip-content]]:opacity-100 [&:focus-within>[data-slot=tooltip-content]]:opacity-100 [&[data-suppress=true]>[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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 bg-primary text-primary-foreground hover:bg-primary/90 h-9 px-4 py-2 has-[>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>Further reading
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.
<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 [&:hover>[data-slot=tooltip-content]]:opacity-100 [&:focus-within>[data-slot=tooltip-content]]:opacity-100 [&[data-suppress=true]>[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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.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-[>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 [&:hover>[data-slot=tooltip-content]]:opacity-100 [&:focus-within>[data-slot=tooltip-content]]:opacity-100 [&[data-suppress=true]>[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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.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-[>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 [&:hover>[data-slot=tooltip-content]]:opacity-100 [&:focus-within>[data-slot=tooltip-content]]:opacity-100 [&[data-suppress=true]>[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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.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-[>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 [&:hover>[data-slot=tooltip-content]]:opacity-100 [&:focus-within>[data-slot=tooltip-content]]:opacity-100 [&[data-suppress=true]>[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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.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-[>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>Further reading
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.
// 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 [&:hover>[data-slot=tooltip-content]]:opacity-100 [&:focus-within>[data-slot=tooltip-content]]:opacity-100 [&[data-suppress=true]>[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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.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-[>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 [&:hover>[data-slot=tooltip-content]]:opacity-100 [&:focus-within>[data-slot=tooltip-content]]:opacity-100 [&[data-suppress=true]>[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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:pointer-events-none [&.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-[>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>Further reading
API Reference
<Tooltip>
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
hoverOnly | boolean | false | Skip focus-reveal. Use only when keyboard users have no need (degradation). |
class | string | — | Extra Tailwind classes appended to the root element. |
* required