Tooltip

Helpful hints, right where you need them.

zweiundeins <sb-tooltip> hint popover help Since 2026-09-21 MIT licence 1.0 kB0 Open in playground Edit on GitHub

Playground

Hover me

A short hint that appears on hover and keyboard focus, with a small pixel arrow. Wrap a focusable trigger, such as a button or a link, that has an accessible name of its own.

Examples

Placement

Top Bottom Left Right
<div style="display: flex; gap: 24px; padding-block: 48px">
  <sb-tooltip content="Top" placement="top"><sb-button variant="outline" size="sm">Top</sb-button></sb-tooltip>
  <sb-tooltip content="Bottom" placement="bottom"><sb-button variant="outline" size="sm">Bottom</sb-button></sb-tooltip>
  <sb-tooltip content="Left" placement="left"><sb-button variant="outline" size="sm">Left</sb-button></sb-tooltip>
  <sb-tooltip content="Right" placement="right"><sb-button variant="outline" size="sm">Right</sb-button></sb-tooltip>
</div>

Dynamic content

content is a normal attribute, so data-attr keeps it live.

Burn fuel
<div data-signals:_fuel="72" style="padding-block-start: 48px">
  <sb-tooltip data-attr:content="'Fuel: ' + $_fuel + '%'">
    <sb-button size="sm" data-on:click="$_fuel = Math.max(0, $_fuel - 9)">Burn fuel</sb-button>
  </sb-tooltip>
</div>

Styling

Style it from your page's CSS, without changing the component or importing anything into it. Custom properties, inherited properties and ::part() all reach into its shadow root.

  • Size: the tip is at most 16rem wide; set max-inline-size on ::part(tip) for another limit.
  • Fonts: the tip uses your page's font.
  • Colours: the tip is --sb-surface-raised with --sb-text-1 text, and --sb-border-strong draws its edge and the arrow. These tokens reach your trigger too, so set the text colour on the part rather than the token.
  • Shadow: --sb-shadow-overlay sets the tip's drop shadow: one shadow without spread, such as 0 8px 16px rgb(0 0 0 / 0.3), or none.
  • Stacking: the tip sits at --sb-z-tooltip (70). It is positioned inside the component, not in the top layer, so an ancestor with overflow: hidden clips it.
  • Parts: tip. Your page's ::part() rules win over the component's own, without !important.
Fuel
<style>
  .my-tip { --sb-surface-raised: var(--sb-brand); --sb-border-strong: var(--sb-brand); }
  .my-tip::part(tip) { color: var(--sb-text-on-brand); font-weight: 400; max-inline-size: 12rem; }
</style>
<div style="padding-block-end: 4rem">
  <sb-tooltip class="my-tip" open placement="bottom" content="Fuel is topped up before every launch."><sb-button size="sm" variant="outline">Fuel</sb-button></sb-tooltip>
</div>

Accessibility

  • Shown on hover and focus. Focus keeps the tip up while the pointer comes and goes. A mouse click focuses the trigger without pinning the tip: it still goes when the pointer leaves.
  • Dismissible, hoverable, persistent (WCAG 1.4.13). Escape hides it wherever the focus is; the pointer can move from the trigger onto the tip, which stays until the pointer leaves both, the focus moves on or Escape is pressed. A shown tip takes the pointer, so it covers what is under it.
  • Announced when it appears. The tip lives in the component's shadow root, so it can't be the trigger's aria-describedby. Instead it appears inside a polite live region, which screen readers announce after the trigger's own name. While hidden it is out of the accessibility tree, so it isn't read as stray text.
  • The trigger carries the name. Give it a name of its own: an icon button needs an aria-label, because the tip is read after the name, never as the name. A trigger that can't take focus, like a plain image, is out of reach for keyboard users.

Keep tooltips short and supplementary: never put the only copy of important information in one.

Installation

Add Datastar with Rocket and the Starbase autoloader once per page, then use the tag: the autoloader imports each component the first time its tag appears, including tags a Datastar morph adds later. The Datastar here is v1.0.4 with Starbase's fixes until they are released; the components also run on the official release.

<script type="importmap">
  { "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/autoloader.js"></script>

<sb-tooltip content="More info"><button type="button" aria-label="Help">?</button></sb-tooltip>

No flash of undefined elements: put class="sb-cloak" on <html> and add .sb-cloak :not(:defined) { visibility: hidden } to your CSS. The autoloader removes the class once the page's components are defined.

Load just this component, pinned to this version: all its modules in one minified file with its integrity hash, so the browser refuses it if a single byte changes.

<script type="importmap">
  {
    "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
    "integrity": {
      "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/tooltip@f00b7e5c49d2/tooltip.bundle.min.js" integrity="sha384-GDnEOKqDISiMQBldrD8TF3aY2vOpvS9aQzdg1tsqq+mb4Kp1veqZ7ao5KLtEmB+G"></script>

<sb-tooltip content="More info"><button type="button" aria-label="Help">?</button></sb-tooltip>

Or the minified module files (one file, which the browser requests as it finds their imports), each pinned in the import map:

<script type="importmap">
  {
    "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
    "integrity": {
      "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
      "https://starbase.zweiundeins.gmbh/c/tooltip@f00b7e5c49d2/tooltip.min.js": "sha384-1te2G1wx3qTduYIuQCAN+Ft5lMkZ2/7D7iIzJmvPPntq8BEW1Ic4Ns8SQgT29LwI"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/tooltip@f00b7e5c49d2/tooltip.min.js" integrity="sha384-1te2G1wx3qTduYIuQCAN+Ft5lMkZ2/7D7iIzJmvPPntq8BEW1Ic4Ns8SQgT29LwI"></script>

<sb-tooltip content="More info"><button type="button" aria-label="Help">?</button></sb-tooltip>

In production, pin today's whole catalog: its autoloader and an import map with integrity hashes, so the browser refuses any file that changed. This map covers Datastar and this component; importmap.json has the hashes of every component, to merge into yours.

<script type="importmap">
  {
    "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
    "integrity": {
      "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
      "https://starbase.zweiundeins.gmbh/c/tooltip@f00b7e5c49d2/tooltip.bundle.min.js": "sha384-GDnEOKqDISiMQBldrD8TF3aY2vOpvS9aQzdg1tsqq+mb4Kp1veqZ7ao5KLtEmB+G"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@339249aca48f/autoloader.js" integrity="sha384-ZNpMayI1XShztIEX0iDHyCDDbhT8H+t+zEESUq4BCrN4b+7m931LQEl5x0vbY+Vo"></script>

<sb-tooltip content="More info"><button type="button" aria-label="Help">?</button></sb-tooltip>

Components import only datastar and their own files, so copying is enough: save each component's one file (and a file it loads on first use, keeping its folder), and point the import map at your own copy of datastar-rocket.js (the /js/ paths stand for yours).

  • <sb-tooltip>
    • tooltip.bundle.min.js one file 1.0 kB brotli
<script type="importmap">
  { "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/tooltip/tooltip.bundle.min.js"></script>

<sb-tooltip content="More info"><button type="button" aria-label="Help">?</button></sb-tooltip>

Or copy the module files, keeping each component's folder, and load each component's module:

<script type="importmap">
  { "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/tooltip/tooltip.min.js"></script>

<sb-tooltip content="More info"><button type="button" aria-label="Help">?</button></sb-tooltip>

Size

A page loads this component as one file, its bundle, compressed the way it is served (gzip -9, brotli -11): brotli is what it downloads. Datastar and Rocket are shared by every component and not counted.

FileOriginalgzipbrotli
tooltip.bundle.min.js 2.5 kB1.2 kB1.0 kB
As module files: one request, 971 B minified with brotli

For a page that loads the module files as they are, requesting each one as it finds its import, or bundles them itself. Each file compressed on its own; the minified one is what the page downloads, and the readable source is always there too.

FileOriginalgzipbrotliminified
tooltip.js 4.0 kB1.8 kB1.5 kB971 B

API reference

Props

AttributeTypeDefaultDescription
contentstring""Tooltip text.
placement"top" | "bottom" | "left" | "right""top"Side of the trigger.
openbooleanfalseKeep the tooltip visible.

Slots

NameDescription
defaultThe trigger element.