Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4a13cc3
feat(widgets): add custom tooltip system for widget buttons
chrisgervang Jul 9, 2026
83e103a
fix(widgets): use firstElementChild for tooltip positioning
chrisgervang Jul 9, 2026
1b184e8
fix(widgets): fix tooltip positioning, button group styling, and inst…
chrisgervang Jul 9, 2026
334933b
feat(widgets): support `false` to explicitly disable tooltips
chrisgervang Jul 9, 2026
f6d5f88
fix(widgets): use pointerEnter/Leave and correct accessibility docs
chrisgervang Jul 10, 2026
63a8a4e
fix(widgets): update tests for tooltip system (title → aria-label)
chrisgervang Jul 10, 2026
b1828c0
docs(widgets): add tooltips page to table of contents
chrisgervang Jul 10, 2026
752d0d5
fix(widgets): address PR review feedback
chrisgervang Jul 10, 2026
ba2eb05
Merge branch 'master' into chr/widget-tooltip-system
chrisgervang Jul 10, 2026
1318337
fix(widgets): address PR review feedback
chrisgervang Jul 10, 2026
f879189
docs(widgets): remove exhaustive tooltip table from overview
chrisgervang Jul 10, 2026
c09b97c
docs(widgets): clarify label tooltip behavior on loading/selector wid…
chrisgervang Jul 10, 2026
ed96783
feat(widgets): add playTooltip/pauseTooltip props to TimelineWidget
chrisgervang Jul 10, 2026
8c3a379
feat(widgets): add tooltip prop to LoadingWidget and SelectorWidget
chrisgervang Jul 10, 2026
3f0e274
fix(widgets): standardize tooltip JSDoc comments
chrisgervang Jul 10, 2026
6011051
docs(widgets): make tooltip prop docs more brief and consistent
chrisgervang Jul 10, 2026
4c165b4
docs(widgets): clarify label vs tooltip design in overview
chrisgervang Jul 10, 2026
88f2ad9
fix(widgets): memoize HTMLElement tooltip ref to avoid DOM thrashing
chrisgervang Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/api-reference/widgets/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,10 @@ new Deck({
});
```

## Tooltips

Built-in button widgets show styled tooltips on hover. See [Widget Tooltips](./tooltips) for customization and usage in custom widgets.

## Themes and Styling

deck.gl widget appearance can be customized using [themes and CSS](./styling).
7 changes: 7 additions & 0 deletions docs/api-reference/widgets/styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,13 @@ Additionally, refer to each widget's API reference for variables specific to tha
| `--menu-text` | [Color](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value) | `rgb(24, 24, 26)` |
| `--menu-item-hover` | [Color](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value) | `rgba(0, 0, 0, 0.08)` |

### Tooltip

| Name | Type | Default |
| ---- | ---- | ------- |
| `--tooltip-max-width` | [Dimension](https://developer.mozilla.org/en-US/docs/Web/CSS/dimension) | `240px` |
| `--tooltip-z-index` | Number | `1000` |

### Range input

| Name | Type | Default |
Expand Down
149 changes: 149 additions & 0 deletions docs/api-reference/widgets/tooltips.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# Widget Tooltips

<img src="https://img.shields.io/badge/from-v9.5-green.svg?style=flat-square" alt="from v9.5" />

Built-in button widgets ship with styled tooltips that appear on hover and keyboard focus. These replace the slow native browser title tooltips with themed, customizable alternatives.

## Customizing Built-in Tooltips

### Overriding tooltip text

Each button widget accepts a tooltip prop that overrides the default label:

```ts
new ZoomWidget({ zoomInTooltip: 'Zoom In (Ctrl+Plus)' })
new FullscreenWidget({ enterTooltip: 'Go Fullscreen (F)' })
```

### Overriding with custom HTML

For rich content (e.g. keyboard shortcut badges), pass an `HTMLElement`:

```ts
const tip = document.createElement('span');
tip.innerHTML = 'Zoom In <kbd>⌘+</kbd>';
new ZoomWidget({ zoomInTooltip: tip })
```

### Disabling tooltips

Pass an empty string to suppress the tooltip for a specific button:

```ts
new ZoomWidget({ zoomInTooltip: '' })
```

### Styling tooltips

Tooltip appearance inherits the widget theme via CSS variables. You can customize them globally or per-widget:

```css
.deck-widget {
--tooltip-max-width: 300px;
--tooltip-z-index: 2000;
}
```

| Name | Type | Default |
| ---- | ---- | ------- |
| `--tooltip-max-width` | [Dimension](https://developer.mozilla.org/en-US/docs/Web/CSS/dimension) | `240px` |
| `--tooltip-z-index` | Number | `1000` |

Tooltips also inherit the following [menu variables](./styling.md#menu): `--menu-background`, `--menu-shadow`, `--menu-backdrop-filter`, `--menu-text`.

## Writing Tooltips in Custom Widgets

### Using Preact (with \_Tooltip component)

If your custom widget uses Preact for rendering, import the `_Tooltip` component:

```tsx
import {_Tooltip as Tooltip} from '@deck.gl/widgets';
import {render} from 'preact';

class MyWidget extends Widget {
className = 'my-widget';
placement = 'top-left';

onRenderHTML(rootElement) {
render(
<Tooltip content="My tooltip text">
<button aria-label="My Action" onClick={...}>
Do thing
</button>
</Tooltip>,
rootElement
);
}
}
```

#### TooltipProps

| Prop | Type | Default | Description |
| ---- | ---- | ------- | ----------- |
| `content` | `string \| ComponentChildren` | — | Tooltip content to display |
Comment thread
chrisgervang marked this conversation as resolved.
| `placement` | `Placement` | `'right'` | Position relative to the trigger (uses [@floating-ui/dom](https://floating-ui.com/docs/computePosition#placement) placement values) |
| `children` | `ComponentChildren` | — | The trigger element |

### Without Preact (CSS class + your own logic)

For custom widgets using vanilla JS, React, or any other framework, implement your own show/hide behavior and apply the `.deck-widget-tooltip` CSS class to get consistent theming:

```ts
class MyWidget extends Widget {
className = 'my-widget';
placement = 'top-left';

onRenderHTML(rootElement) {
const btn = document.createElement('button');
btn.setAttribute('aria-label', 'My Action');
btn.setAttribute('aria-describedby', 'my-tooltip');

const tooltip = document.createElement('div');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a nit: This example is rather confusing to me and makes the tooltip system read as more complex than it is.

My knee jerk reaction, is why would we suggest users to do all this? It makes it seems very complicated.

For the folks who don't want to import preact and configure JSX in their project for just one custom widget, can't we just export the a plain JS function that renders the internal Preact into an HTML element and this also handles the positioning so we don't have to ask the user to do all this and install the external library etc.

Separately, since we clearly care about Accessibility, for me it would make more sense to have a section that describes Accessibility first (perhaps in every widget page).

Then the example we are giving here would seem less random, as it is clearly implementing the explicit accessibility spec above, rather than forcing the user to try to understand what those HTML element stanzas are for - are they standard DOM fields or some deck convention etc.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Idea: since we clearly care about Accessibility, for me it would make more sense to have a section that describes Accessibility support/considerations first (perhaps even in every widget page).

There are clearly a number of things that need to be done, setting roles and aria-... attributes etc. and not every developer is up to date on that.

Then the non-Preact example we are giving would seem less random to, as it would clearly be implementing the explicit accessibility spec above, rather than forcing the user to try to understand what those HTML element stanzas are for - are they standard DOM fields or some deck.gl/widgets convention etc.

tooltip.id = 'my-tooltip';
tooltip.className = 'deck-widget-tooltip';
tooltip.setAttribute('role', 'tooltip');
tooltip.textContent = 'My Action';
tooltip.hidden = true;

btn.addEventListener('pointerenter', () => { tooltip.hidden = false; });
btn.addEventListener('pointerleave', () => { tooltip.hidden = true; });

rootElement.replaceChildren(btn, tooltip);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replacing all children seems a bit heavy handed?

}
}
```

The `.deck-widget-tooltip` class provides themed background, shadow, text color, font, border-radius, and max-width — matching the rest of the widget UI. You are responsible for:

- Positioning (consider [@floating-ui/dom](https://floating-ui.com/docs/getting-started) or CSS anchor positioning)
- Show/hide behavior (pointer events, focus, keyboard dismiss)

## Accessibility

Built-in widget tooltips follow these accessibility practices:

- Buttons use `aria-label` for screen reader announcements
- Tooltip elements have `role="tooltip"`
- Triggers link to their tooltip via `aria-describedby`
- Tooltips appear on keyboard focus (`:focus-visible`)
- Pressing `Escape` dismisses the tooltip
Comment thread
chrisgervang marked this conversation as resolved.

Custom widget authors should follow the same patterns.

## Tooltip override props by widget

| Widget | Tooltip prop(s) |
| ------ | --------------- |
| ZoomWidget | `zoomInTooltip`, `zoomOutTooltip` |
| FullscreenWidget | `enterTooltip`, `exitTooltip` |
| CompassWidget | `tooltip` |
| GimbalWidget | `tooltip` |
| ScreenshotWidget | `tooltip` |
| ResetViewWidget | `tooltip` |
| ThemeWidget | `lightModeTooltip`, `darkModeTooltip` |
| IconWidget | `tooltip` |
| ToggleWidget | `tooltip`, `onTooltip` |

All tooltip props accept `string | HTMLElement` and default to the widget's label prop value.
53 changes: 30 additions & 23 deletions modules/widgets/src/compass-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import {Widget, FlyToInterpolator, WebMercatorViewport, _GlobeViewport} from '@deck.gl/core';
import type {Viewport, WidgetPlacement, WidgetProps} from '@deck.gl/core';
import {render} from 'preact';
import {Tooltip} from './lib/components/tooltip';

export type CompassWidgetProps = WidgetProps & {
/** Widget positioning within the view. Default 'top-left'. */
Expand All @@ -13,6 +14,8 @@ export type CompassWidgetProps = WidgetProps & {
viewId?: string | null;
/** Tooltip message. */
label?: string;
/** Custom tooltip content. Overrides label for tooltip display. */
tooltip?: string | HTMLElement;
/** Bearing and pitch reset transition duration in ms. */
transitionDuration?: number;
/**
Expand All @@ -36,6 +39,7 @@ export class CompassWidget extends Widget<CompassWidgetProps> {
placement: 'top-left',
viewId: null,
label: 'Reset Compass',
tooltip: undefined!,
transitionDuration: 200,
onReset: () => {}
};
Expand All @@ -60,31 +64,34 @@ export class CompassWidget extends Widget<CompassWidgetProps> {
const widgetViewport = this.viewports[viewId];
const [rz, rx] = this.getRotation(widgetViewport);

const tooltipContent = this.props.tooltip ?? this.props.label;
const ui = (
<div className="deck-widget-button" style={{perspective: 100}}>
<button
type="button"
onClick={() => {
for (const viewport of Object.values(this.viewports)) {
this.handleCompassReset(viewport);
}
}}
title={this.props.label}
style={{transform: `rotateX(${rx}deg)`}}
>
<svg fill="none" width="100%" height="100%" viewBox="0 0 26 26">
<g transform={`rotate(${rz},13,13)`}>
<path
d="M10 13.0001L12.9999 5L15.9997 13.0001H10Z"
fill="var(--icon-compass-north-color, rgb(240, 92, 68))"
/>
<path
d="M16.0002 12.9999L13.0004 21L10.0005 12.9999H16.0002Z"
fill="var(--icon-compass-south-color, rgb(204, 204, 204))"
/>
</g>
</svg>
</button>
<Tooltip content={tooltipContent}>
<button
type="button"
onClick={() => {
for (const viewport of Object.values(this.viewports)) {
this.handleCompassReset(viewport);
}
}}
aria-label={this.props.label}
style={{transform: `rotateX(${rx}deg)`}}
>
<svg fill="none" width="100%" height="100%" viewBox="0 0 26 26">
<g transform={`rotate(${rz},13,13)`}>
<path
d="M10 13.0001L12.9999 5L15.9997 13.0001H10Z"
fill="var(--icon-compass-north-color, rgb(240, 92, 68))"
/>
<path
d="M16.0002 12.9999L13.0004 21L10.0005 12.9999H16.0002Z"
fill="var(--icon-compass-south-color, rgb(204, 204, 204))"
/>
</g>
</svg>
</button>
</Tooltip>
</div>
);

Expand Down
7 changes: 7 additions & 0 deletions modules/widgets/src/fullscreen-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ export type FullscreenWidgetProps = WidgetProps & {
enterLabel?: string;
/** Tooltip message when fullscreen. */
exitLabel?: string;
/** Custom tooltip content when out of fullscreen. Overrides enterLabel for tooltip display. */
enterTooltip?: string | HTMLElement;
/** Custom tooltip content when fullscreen. Overrides exitLabel for tooltip display. */
exitTooltip?: string | HTMLElement;
/**
* A compatible DOM element which should be made full screen. By default, the map container element will be made full screen.
* @see https://developer.mozilla.org/en-US/docs/Web/API/Element/requestFullScreen#Compatible_elements
Expand All @@ -38,6 +42,8 @@ export class FullscreenWidget extends Widget<FullscreenWidgetProps> {
viewId: null,
enterLabel: 'Enter Fullscreen',
exitLabel: 'Exit Fullscreen',
enterTooltip: undefined!,
exitTooltip: undefined!,
container: undefined!,
onFullscreenChange: () => {}
};
Expand Down Expand Up @@ -67,6 +73,7 @@ export class FullscreenWidget extends Widget<FullscreenWidgetProps> {
this.handleClick().catch(err => log.error(err)());
}}
label={isFullscreen ? this.props.exitLabel : this.props.enterLabel}
tooltip={isFullscreen ? this.props.exitTooltip : this.props.enterTooltip}
className={isFullscreen ? 'deck-widget-fullscreen-exit' : 'deck-widget-fullscreen-enter'}
/>,
rootElement
Expand Down
Loading
Loading