---
title: "Hit testing and events for a UI"
url: https://recursiveai.org/onerender/cookbook/hit-testing-events/
description: "Use an HTML page as an interactive UI with OneRender: find the element under the pointer, listen for clicks that bubble, get :hover and keyboard focus…"
version: "1.0.0"
---

# Hit testing and events for a UI

Use an HTML page as an interactive UI with OneRender: find the element under the pointer, listen for clicks that bubble, get `:hover` and keyboard focus, and repaint only what changed, on your own clock.

```csharp
using OneRender;
using OneRender.Image;
using OneRender.Interaction;
using OneRender.Layout;
using OneRender.Painting;
using RecursiveAI.OneHtml;

string html = """
<!doctype html>
<html lang="en">
<style>
  body { margin: 0; font: 16px sans-serif; background: #16202c; color: #e8edf3 }
  nav { display: flex; gap: 8px; padding: 16px }
  button { font: inherit; color: inherit; padding: 8px 14px; border: 0; border-radius: 6px; background: #2a3644 }
  button:hover { background: #3b82f6 }
  button:focus-visible { outline: 2px solid #fbbf24 }
</style>
<nav id="menu">
  <button id="play">Play</button>
  <button id="options">Options</button>
  <button id="quit">Quit</button>
</nav>
</html>
""";

InteractivePage ui = InteractivePage.Create(html,
    new HtmlPageOptions(FontProviders.CreateDefault()) { ViewportWidth = 400, ViewportHeight = 80 });
Document doc = ui.Page.Document;
Node menu = doc.GetElementById("menu");
Node quit = doc.GetElementById("quit");

// One listener on the container: clicks bubble up from the buttons.
ui.Events.AddEventListener(menu, "click", e =>
    Console.WriteLine($"click on #{e.Target.Node.GetAttribute("id")}, phase {e.Phase}"));
ui.Events.AddEventListener(quit, "click", e => { e.PreventDefault(); e.StopPropagation(); Console.WriteLine("quit handled here"); });

// Where is everything? Synchronous layout queries, like getBoundingClientRect.
Rect play = ui.Layout.GetBoundingClientRect(doc.GetElementById("play"));
Console.WriteLine($"#play at {play.X}, {play.Y}, {play.Width} x {play.Height}");
Console.WriteLine($"at (150, 30): #{ui.ElementFromPoint(150, 30).GetAttribute("id")}");

// Paint once; then feed input each frame with the host's clock.
var painter = new Painter();
var list = new RetainedDisplayList();
var paint = new PaintOptions(400, 80) { Scroll = ui.Page.Scroll };
painter.Paint(ui.Page.Fragments, list, paint);
list.ClearDamage();

double now = 16;
InputOutcome o = ui.HandlePointer(new PointerInput { Action = PointerAction.Move, X = play.X + 10, Y = play.Y + 10, Time = now });
Console.WriteLine($"move: {o}, hovered #{ui.HoveredElement.GetAttribute("id")}");
Click(play.X + 10, play.Y + 10);
Rect q = ui.Layout.GetBoundingClientRect(quit);
Click(q.X + 5, q.Y + 5);

o = ui.HandleKey(new KeyInput { Key = "Tab", Down = true, Time = now += 16 });
Console.WriteLine($"Tab: {o}, focused #{ui.FocusedElement.GetAttribute("id")}");

if ((o & InputOutcome.Relaid) != 0) ui.Repaint(painter, list, paint);
Console.WriteLine($"{list.Damage.Count} damaged area(s) to redraw");
File.WriteAllBytes("menu.png", DisplayListRasterizer.Render(list.Current, 400, 80).ToPng());

void Click(double x, double y)
{
    ui.HandlePointer(new PointerInput { Action = PointerAction.Down, X = x, Y = y, Button = 0, Buttons = MouseButtons.Primary, Time = now += 16 });
    ui.HandlePointer(new PointerInput { Action = PointerAction.Up, X = x, Y = y, Button = 0, Time = now += 16 });
}
```

```text
#play at 16, 16, 59.125 x 34
at (150, 30): #options
move: StatesChanged, Relaid, hovered #play
click on #play, phase Bubbling
quit handled here
Tab: StatesChanged, Relaid, FocusChanged, focused #play
2 damaged area(s) to redraw
```

In `menu.png`, Play has the yellow keyboard focus ring and Quit, still under the pointer, is blue.

## How it works

- **Events follow the DOM standard:** capture, target and bubble phases, `PreventDefault`, `StopPropagation`, and listener options (`Capture`, `Once`, `Passive`). A pointer down and up on the same element fire `click`, and focus moves to it.
- **Focus.** Clicking a button focuses it, so Tab from Quit wraps around to Play. Focus that comes from the keyboard matches `:focus-visible`; `MoveFocus`, `Focus` and `GetTabOrder` are there for your own navigation.
- **Every input returns an `InputOutcome`.** `Relaid` means layout changed: call `Repaint`, which rewrites only the changed parts of the retained list. `Animated` means only transforms or opacity moved: `PatchAnimations` updates the list with no layout. `Scrolled` asks for `painter.PatchScroll(list)`.
- **The host owns time.** Pass your clock in `Time` and call `ui.Advance(nowMs)` every frame to run CSS transitions and animations. Nothing reads the system clock, so a replay of the same inputs gives the same frames.
- **Layout queries** (`GetBoundingClientRect`, `GetClientRects`, offset, client and scroll sizes, `ElementFromPoint`, `ElementsFromPoint`) answer synchronously, after any pending change is applied.

## Also on the page

Form controls with Chrome’s default look, text editing with caret, selection, undo and IME composition (`HandleKey`, `InsertText`, `CompositionStart`), wheel scrolling with scroll snap (`HandleWheel`), and a `select` dropdown drawn in the same display list. See [Forms and text editing](https://recursiveai.org/onerender/docs/forms/) and [Transitions and animations](https://recursiveai.org/onerender/docs/animations/).

For a UI built in C# with no HTML, see [Build a styled tree in C#](https://recursiveai.org/onerender/cookbook/styled-tree/).
