# App Interaction Patterns

> Interaction design for data apps - filtering, cross-filtering, drill-down, brushing, linked views, selection, and the complete state machine every data surface needs (loading, empty, partial, stale, error, no-access, filtered-to-nothing). Trigger on "filter", "drill down", "interactive dashboard", "click a bar", "loading state", "empty state", "error state", "linked charts", "cross-filter", "make it interactive".

- Skill: `lukehle/app-interaction-patterns` (Agent Skill)
- Install (CLI): `npx skillmds@latest add lukehle/app-interaction-patterns`
- Raw SKILL.md: https://api.skillmd.com/api/skills/lukehle/app-interaction-patterns/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: Lukehle (https://skillmd.com/u/lukehle)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/lukehle/app-interaction-patterns

---


# App interaction patterns

Interactivity is not a feature list, it is a set of promises: that the page will tell you what it is
doing, what it is showing, and how to get back. Most interactive dashboards fail on the second and
third.

---

## The state machine — build this first

Every data surface has **seven** states. Designing three of them and discovering the rest in
production is the normal failure.

| State | Looks like | The mistake to avoid |
|---|---|---|
| **Loading** | Skeleton in the shape of the result | A spinner that hides the layout, so the page jumps when data lands |
| **Ready** | The data | — |
| **Empty (genuine)** | "No transactions in this period" + what would produce some | Rendering an empty chart frame, which reads as broken |
| **Filtered to nothing** | "No results for these filters" + **which filters** + a reset | Same blank panel as genuine empty — completely different meaning |
| **Partial** | The data you have, plus what is missing and why | Silently showing 8 of 12 segments as if that were all of them |
| **Stale** | The data, plus its age, plus a retry | Showing a stale figure as current |
| **Error / no access** | What failed and what to do | A blank region, or a raw error string |

**Empty and filtered-to-nothing are different states.** Conflating them is the most common
interaction bug in dashboards: the user cannot tell whether there is no data or whether they broke
the view.

Skeletons should match the final layout's dimensions so nothing shifts when data arrives.

---

## Filtering

**Show the active filter set, always.** A page filtered to one segment that looks identical to the
unfiltered page will be misread, and someone will quote the number.

```
Showing: Enterprise · North America · FY26 Q3        [Reset all]
         ×            ×               ×
```

Rules:
- Each active filter is individually removable, and there is a global reset.
- **Show what a filter excluded**: "1,204 of 14,882 rows". A count that shrinks silently hides the
  scope change.
- Filters apply on change for cheap operations; for expensive ones, debounce ~300ms or add an
  explicit Apply — but never leave the user unsure whether their change took effect.
- Preserve filter state across a re-render. Losing it on every interaction is how a page becomes
  unusable.
- Disabled options should say why, or be removed. A greyed-out option with no explanation reads as a
  bug.

---

## Cross-filtering and linked views

Clicking a bar in one chart filters the others. Powerful, and easy to make confusing.

- **Selection must be visibly persistent** — the clicked element stays highlighted while its filter
  is active. Otherwise the user cannot tell what caused the other charts to change.
- **De-emphasise rather than remove** the unselected data where it makes sense. Seeing the selection
  in context beats seeing it alone.
- **One selection model per page.** Click-to-filter in one chart and click-to-drill in another, with
  no visual distinction, is a trap.
- **Always provide the escape**: click the background, press Escape, or hit a visible Clear.
- Cross-filtering should never change a chart's *scale* silently — a bar that grows because the axis
  rescaled, not because the value changed, is a misread.

---

## Drill-down

Moving from summary to detail. The essentials:

- **Show the path**: `All accounts › 6200 Professional fees › July 2026`. Every segment is clickable
  to go back up.
- **Keep the parent context visible** — the figure you drilled from should remain on screen, so the
  detail can be checked against it.
- **Detail must reconcile to the summary.** If the drill-down rows do not sum to the bar you clicked,
  say why (exclusions, rounding, a different grain) rather than letting the reader find the gap.
- Browser back should feel natural even without URLs — Escape or a breadcrumb click is the
  substitute.

---

## Brushing and zoom

For time series with more points than pixels.

- Keep an **overview strip** showing the full range with the selection marked, so the user never
  loses the whole.
- Show the selected range as text (`2026-03-01 → 2026-06-30`), not just as a visual extent.
- **Double-click resets.** Make it discoverable with a Reset zoom control.
- Zooming changes the *view*, never the underlying aggregation, unless you say so — silently
  switching from monthly to daily on zoom changes what the marks mean.

---

## Tooltips and hover

The rule that matters most:

> **Hover reveals detail. It never reveals the primary value.**

Hover is dead on touch, invisible in print, and unreachable by keyboard. If a number matters, it is
on the page.

Good tooltips: precise value at full precision, the comparison (vs prior, vs plan), the share of
total, the record count behind an aggregate. Bad tooltips: the label that is already on the axis.

Anchor near the cursor but **flip near edges** so the tooltip never leaves the viewport, and never
let it cover the mark it describes.

---

## Selection and bulk action

For tables and lists:

- Selection count is always visible, with a clear-all.
- Shift-click selects a range; it is expected and cheap to add.
- **Actions on a selection state what they will affect**: "Export 42 rows", not "Export".
- Anything irreversible confirms, and the confirmation names the scope and the consequence.

---

## Keyboard

An app that cannot be driven by keyboard is unfinished. Minimum:

| Key | Action |
|---|---|
| `Tab` / `Shift+Tab` | Move between controls, with a visible focus ring |
| `Enter` / `Space` | Activate |
| `Escape` | Close, clear selection, exit drill-down |
| Arrows | Move within a group (tabs, table cells, legend items) |
| `/` | Focus search, where one exists |

Use one tab stop for a group of related controls plus arrow navigation inside it — otherwise a
20-item legend costs 20 tab presses to get past. See `artifact-accessibility`.

---

## Responsiveness of feedback

| Delay | Feedback needed |
|---|---|
| < 100ms | None — feels instant |
| 100ms - 1s | Subtle: cursor, control dims |
| 1s - 5s | Skeleton or progress, and keep the page interactive |
| > 5s | Progress with an estimate, and a cancel |

Never block the whole page for a partial update. Update the region that changed.

---

## Related skills

- `live-data-artifacts` — stale, error, and no-access states come from there
- `stateful-artifacts` — read-only and conflict states
- `visual-hierarchy` — controls must not outweigh results
- `microcopy-and-states` — the words each state uses
- `artifact-performance` — what makes interaction feel instant
- `artifact-accessibility` — keyboard and focus in depth

