Tessera UI

Components

Tooltip

A short hint for a control, shown when the pointer rests on it or it has focus. It adds a description, never the only name: a control that needs a label has one of its own.

The preview needs JavaScript. The code is in the Code tab.

Tooltip-0.tess
<template>
  <div>
    <Tooltip>
      <Button tone="neutral" emphasis="outline">Save</Button>
      <span slot="content">Saves your changes as a draft</span>
    </Tooltip>
    <Tooltip placement="bottom">
      <Icon name="circle-help" label="Help" />
      <span slot="content">What the fields mean</span>
    </Tooltip>
  </div>
</template>

Installation

npx @pixelscriptio/tessera add Tooltip
pnpm exec tessera add Tooltip
yarn tessera add Tooltip
bunx @pixelscriptio/tessera add Tooltip
  • Also installs Button, which it uses.
  • Works with Tessera 0.9.0 or newer.
  1. Copy and paste the code into your project

    Put it in system/components. Add Button the same way.

    system/components/Tooltip.tess
    <contract>
    {
      "name": "Tooltip",
      "role": "generic",
      "description": "A short hint for a control, shown when the pointer rests on it or it has focus. It adds a description, never the only name: a control that needs a label has one of its own.",
      "since": "0.8.0",
      "parts": ["root", "tip"],
      "variants": {
        "placement": ["top", "bottom", "right", "left"] // first value is the default
      },
      "states": ["hover", "focus-visible"],
      "slots": {
        "default": "what the tip describes: a button, a link or a field",
        "content": "the hint, one short line of plain text"
      },
      "preview": {
        "slots": {
          "default": {
            "component": "Button",
            "props": { "tone": "neutral", "emphasis": "outline" },
            "slots": { "default": "Hover or focus me" }
          },
          "content": "Saves your changes as a draft"
        }
      }
    }
    </contract>
    
    <script>
    const { delay } = props({ delay: 400 })
    
    const uid = `t-tooltip-${Math.random().toString(36).slice(2, 9)}`
    const box = state(null)
    const tip = state(null)
    const shown = state(false)
    const anchored = typeof CSS !== 'undefined' && CSS.supports('position-area', 'bottom')
    
    // What the tip describes is the first control in the slot. It is pointed at the tip, so a screen reader reads the hint
    // after the name. With no control in the slot, the wrapper itself can be reached with the keyboard, so the hint is not
    // for the pointer alone.
    effect(() => {
      const root = box.value
      if (!root) return
      const found = [...root.querySelectorAll('button, a[href], input, select, textarea, [tabindex]')]
      const control = found.find((el) => !(tip.value && tip.value.contains(el)))
      const target = control ?? root
      if (!control) root.setAttribute('tabindex', '0')
      target.setAttribute('aria-describedby', uid + '-tip')
    })
    
    let timer = null
    function reveal() {
      const el = tip.value
      const root = box.value
      if (!el || !el.showPopover || el.matches(':popover-open')) return
      el.showPopover()
      if (!anchored && root) {
        const rect = root.getBoundingClientRect()
        el.style.top = rect.bottom + 6 + 'px'
        el.style.left = rect.left + 'px'
      }
      shown.value = true
    }
    function conceal() {
      const el = tip.value
      if (el && el.hidePopover && el.matches(':popover-open')) el.hidePopover()
      shown.value = false
    }
    
    // Focus shows it at once. The pointer waits a moment, so passing over does not flash it, and leaving waits a moment
    // too, so the pointer can reach the tip itself (it stays open while it is over it).
    function enter(event) {
      clearTimeout(timer)
      timer = setTimeout(reveal, delay.value)
    }
    function focused() {
      clearTimeout(timer)
      reveal()
    }
    function leave() {
      clearTimeout(timer)
      timer = setTimeout(conceal, 100)
    }
    function key(event) {
      if (event.key === 'Escape') {
        clearTimeout(timer)
        conceal()
      }
    }
    onCleanup(() => clearTimeout(timer))
    </script>
    
    <template>
      <span
        class="root"
        ref="box"
        :style="'anchor-name: --' + uid"
        @mouseenter="enter($event)"
        @mouseleave="leave()"
        @focusin="focused()"
        @focusout="leave()"
        @keydown="key($event)"
        >
        <slot />
        <span
          class="tip"
          ref="tip"
          popover="manual"
          role="tooltip"
          :id="uid + '-tip'"
          :style="'position-anchor: --' + uid"
          ><slot name="content"
            /></span>
      </span>
    </template>
    
    <style>
    .root {
      display: inline-flex;
    }
    
    .tip {
      position: fixed;
      inset: auto;
      margin: $space.2;
      max-width: 20rem;
      padding: $space.1 $space.3;
      border-radius: $radius.sm;
      background: $color.surface.inverse;
      color: $color.surface.default;
      font-family: var(--t-font-family-sans);
      font-size: $font.size.sm;
      line-height: 1.4;
      position-area: top;
      position-try-fallbacks: flip-block, flip-inline;
    }
    
    .root[data-placement='bottom'] .tip {
      position-area: bottom;
    }
    
    .root[data-placement='right'] .tip {
      position-area: right;
    }
    
    .root[data-placement='left'] .tip {
      position-area: left;
    }
    
    .tip:not(:popover-open) {
      display: none;
    }
    </style>
    
  2. Check your design tokens

    Your design system needs these. tessera add reports the ones that are missing; by hand, run npx @pixelscriptio/tessera check.

    color.surface.default color.surface.inverse font.size.sm radius.sm space.1 space.2 space.3

Usage

Using Tooltip

A short hint for a control, shown when the pointer rests on it or it has focus.

  • Put the control in the default slot and the hint in content. The hint is one short line of plain text. If it needs more, use a Popover.
  • A tooltip describes, it does not name. A control with only an icon needs its own label (label on an Icon, or text); the tooltip may repeat it for sighted people but is not the only source. Screen readers read the hint after the name.
  • It shows when the pointer rests on the control for a moment (delay, 400 ms), and at once on keyboard focus. It stays while the pointer is over it, and Escape hides it, so it never traps what is under it.
  • If the slot has no button, link or field, the wrapper itself takes focus, so keyboard users can reach the hint too.
  • Never put a link, a button or anything to press in a tooltip: it disappears when focus moves.
  • placement is the side it prefers; the browser moves it to another side when there is no room.

The preview needs JavaScript. The code is in the Code tab.

Tooltip-0.tess
<template>
  <div>
    <Tooltip>
      <Button tone="neutral" emphasis="outline">Save</Button>
      <span slot="content">Saves your changes as a draft</span>
    </Tooltip>
    <Tooltip placement="bottom">
      <Icon name="circle-help" label="Help" />
      <span slot="content">What the fields mean</span>
    </Tooltip>
  </div>
</template>

API reference

Variants

VariantValues
placementtop default bottom right left

Slots

SlotContent
defaultwhat the tip describes: a button, a link or a field
contentthe hint, one short line of plain text

Anatomy

Rolegeneric
Partsroot tip
Stateshover focus-visible
Since0.8.0

Design tokens it uses

Your design system needs these. tessera add checks that it has them, and can add the ones it lacks.

color.surface.default color.surface.inverse font.size.sm radius.sm space.1 space.2 space.3

Checksum

705cecd323a7850cf5913a8b975065e867143993e402dda6abbcfd131e8f1399