This page covers what differs when you use the DOM API from C— read [About the DOM API](/docs/2.0/about-the-dom-api) and the C++ DOM pages first for underlying concepts, and see [C API Conventions](/docs/2.0/c-api-conventions) for shared rules.

> 📘 Preview API
>
> The C DOM API is a preview— names and behavior may still change after 2.0.

`CAPI_DOMDocument.h` doesn't declare the `ulDOMElement*` functions or include `CAPI_DOMElement.h`, so include both— see [C API Conventions](/docs/2.0/c-api-conventions#content-including-headers) for the other DOM headers.

## Differences from C++

The C API replaces C++ DOM objects, properties, and operator overloads with opaque handles and C functions.

| C++ | C | What Changes |
| :--- | :--- | :--- |
| `dom::Document document(view)` | `ulViewGetDOMDocument()` | Call inside the DOM-ready callback to receive an owned handle you must destroy. |
| Property proxies (`el.textContent = "Saved"`) | `ulDOMElementSetTextContent()` and getters | Setters take a pointer and byte length, while getters return an owned `ULString`. |
| Typed views (`el.AsInput()`, `event.AsMouse()`) | `ulDOMElement*` and `ulDOMEvent*` functions | Calling a function on an incompatible element or event type returns a fallback value (such as 0, -1, `false`, or `NULL`). |
| `std::optional` (`getAttribute()`, `selectedIndex`) | `NULL` and sentinel return values | Missing attributes return `NULL`, and missing indices return -1. |
| `el.dataset["topic"]` | `ulDOMElementSetAttribute()` | Custom dataset properties are set and read through explicit `data-*` attribute names. |
| Range-based `for` over `querySelectorAll()` | `ULDOMElementList` snapshot | Queries return a snapshot list that you iterate by index and destroy. |
| `el.style.left = dom::StyleValue::Px(x)` | `ulDOMElementSetStylePropertyNumeric()` and `ulDOMElementSetStylePropertyColor()` | Set properties by name and unit. For per-frame updates, intern the name once with `ulDOMInternStyleProperty()` and set by ID (valid only in the calling process). |
| `dom::Checked` and `dom::Result` | `ULDOMError*` out-parameter | Page errors write to an optional out-parameter (see [C API Conventions](/docs/2.0/c-api-conventions#content-handling-errors)). |
| `dom::AddEventListenerOptions` and `dom::AbortController` | `ULDOMEventFlags` bit flags | Bit flags replace options structs. You remove listeners manually with `ulDOMEventListenerRemove()`. |
| `dom::Triggers` object | `ULDOMTriggers` handle | Attach the handle to a View to maintain listeners across page navigations. |

## Getting the Document

To access a page's DOM, retrieve its document handle inside your DOM-ready callback.

```c
#include <Ultralight/CAPI.h>
#include <Ultralight/CAPI/CAPI_DOMDocument.h>
#include <Ultralight/CAPI/CAPI_DOMElement.h>
#include <string.h>

static void OnDOMReady(void* user_data, ULView caller,
                       unsigned long long frame_id, bool is_main_frame,
                       ULString url) {
  (void)user_data;
  (void)frame_id;
  (void)url;
  if (!is_main_frame)
    return;

  ULDOMDocument document = ulViewGetDOMDocument(caller);
  ULDOMElement status = ulDOMDocumentGetElementById(document, "status");

  const char* text = "Connected";
  ulDOMElementSetTextContent(status, text, strlen(text));
  ulDOMElementClassListAdd(status, "online", NULL);

  ulDestroyDOMElement(status);
  ulDestroyDOMDocument(document);
}

void WatchPages(ULView view) {
  ulViewSetDOMReadyCallback(view, OnDOMReady, NULL, NULL);
}
```

The DOM-ready callback runs for every frame in the View. Check `is_main_frame` so you process only the root document.

Each page navigation creates a new document. Previous handles stop working once the page navigates away (see [C API Conventions](/docs/2.0/c-api-conventions#content-handle-lifetimes-across-navigations)).

### Frame Documents

To inspect the document inside an `<iframe>` or `<frame>`, call `ulDOMElementGetContentDocument()` on the frame element. The returned handle is an owned document that stops working when the frame navigates or is removed.

### Passing Text

Functions that take text and an explicit byte length accept `NULL` with a length of 0 to represent an empty string— calling `ulDOMElementSetTextContent(element, NULL, 0)` clears the element. Passing `NULL` with a non-zero length fails as if the handle's page were gone.

## Destroying Handles

Every handle a DOM function returns is yours to destroy (see [C API Conventions](/docs/2.0/c-api-conventions#content-managing-memory-and-ownership)).

```c
#include <Ultralight/CAPI.h>
#include <Ultralight/CAPI/CAPI_DOMDocument.h>
#include <Ultralight/CAPI/CAPI_DOMElement.h>

void MarkSeen(ULDOMDocument document) {
  ULDOMElementList items =
      ulDOMDocumentQuerySelectorAll(document, "#log li", NULL);
  size_t count = ulDOMElementListGetLength(items);

  for (size_t i = 0; i < count; i++) {
    ULDOMElement item = ulDOMElementListGetElement(items, i);
    ulDOMElementClassListAdd(item, "seen", NULL);
    ulDestroyDOMElement(item);
  }

  ulDestroyDOMElementList(items);
}
```

`ulDOMDocumentQuerySelectorAll()` returns a snapshot list handle. Each call to `ulDOMElementListGetElement()` creates a fresh element handle that you own, so destroy each item before destroying the list with `ulDestroyDOMElementList()`.

> 🚧 Callback Handles Are Borrowed
>
> The `ULDOMEvent` handle, a delegated callback's `matched_target`, and a triggers hook's `ULDOMDocument` are borrowed— they remain valid only while the callback runs. Never destroy them or store them across calls. To keep one longer, call `ulCreateDOMElementRef()` or `ulCreateDOMDocumentRef()`. Calling `ulDOMEventGetTarget()` inside the callback returns an owned handle, so you must destroy it.

> 📘 Reusing Error Structs
>
> When reusing a `ULDOMError` struct across calls, destroy any non-`NULL` `message` with `ulDestroyString()` and zero the struct again. A new DOM error overwrites both fields without freeing the previous message, leaking the string.

## Listening for Events

To listen for events on an element, register a callback with a state pointer and a destroy hook.

```c
#include <Ultralight/CAPI.h>
#include <Ultralight/CAPI/CAPI_DOMDocument.h>
#include <Ultralight/CAPI/CAPI_DOMElement.h>
#include <Ultralight/CAPI/CAPI_DOMEvent.h>
#include <stdlib.h>

typedef struct {
  int clicks;
} SaveState;

static ULDOMEventListener save_listener = NULL;

static void OnSaveClick(void* user_data, ULDOMEvent event) {
  SaveState* state = user_data;
  state->clicks++;

  ULDOMElement button = ulDOMEventGetTarget(event);
  ulDOMElementSetTextContent(button, "Saved", 5);
  ulDestroyDOMElement(button);
}

void WireSave(ULDOMDocument document) {
  ULDOMElement save = ulDOMDocumentGetElementById(document, "save");
  SaveState* state = calloc(1, sizeof(SaveState));

  // The library calls free(state) once it's done with the listener.
  save_listener = ulDOMElementAddEventListener(
      save, "click", kULDOMEventFlags_None, OnSaveClick, state, free);
  ulDestroyDOMElement(save);
}

void UnwireSave(void) {
  ulDOMEventListenerRemove(save_listener);
  ulDestroyDOMEventListener(save_listener);
  save_listener = NULL;
}
```

Destroying a listener handle doesn't stop the listener. To stop receiving events, call `ulDOMEventListenerRemove()` before destroying the handle (see [C API Conventions](/docs/2.0/c-api-conventions#content-what-destroying-a-handle-does)).

Ownership of `user_data` transfers directly to the listener, so never free it yourself. The `destroy_user_data` hook runs once on the Renderer's thread in one of two situations:

- **After removing the listener and destroying its handle** — runs during a later call to `ulUpdate()`. A listener registered with `kULDOMEventFlags_Once` counts as removed as soon as it fires.
- **When the page goes away** — runs during page teardown (such as navigation or inside `ulDestroyView()`), even if you never removed the listener.

Other options and listener types include:

- **Flags customize dispatch behavior.** Pass flags such as `kULDOMEventFlags_Capture` or `kULDOMEventFlags_Once`.
- **Delegated listeners match descendants dynamically.** Register a callback with `ulDOMElementAddDelegatedEventListener()`. It follows the same removal rules.
- **Window listeners need a separate header.** Include `<Ultralight/CAPI/CAPI_DOMWindow.h>`.

For details on event phases and delegation, see [Handling DOM Events](/docs/2.0/handling-dom-events).

### When Callbacks Run

Callbacks run synchronously on the Renderer's thread, inside the call that dispatched the event. Input events run inside `ulViewFireMouseEvent()` or `ulViewFireKeyEvent()`. Custom events run inside `ulDOMElementDispatchEvent()`. Events the page dispatches on its own run during `ulUpdate()`.

Scroll and resize events dispatch when the View paints during `ulRender()`— never inside `ulViewFireScrollEvent()`.

## Wiring Every Page

To wire event listeners and hooks that apply to every page a View loads, attach a `ULDOMTriggers` set (see [DOM Triggers and Navigation](/docs/2.0/page-wiring-and-navigation)).

```c
#include <Ultralight/CAPI.h>
#include <Ultralight/CAPI/CAPI_DOMDocument.h>
#include <Ultralight/CAPI/CAPI_DOMElement.h>
#include <Ultralight/CAPI/CAPI_DOMTriggers.h>

static ULDOMTriggers ui = NULL;

static void OnAnySave(void* user_data, ULDOMEvent event,
                      ULDOMElement button) {
  (void)user_data;
  (void)event;
  // Borrowed: don't destroy button.
  ulDOMElementSetTextContent(button, "Saved", 5);
}

static void OnReady(void* user_data, ULDOMDocument document) {
  (void)user_data;
  ULDOMElement status = ulDOMDocumentGetElementById(document, "status");
  ulDOMElementSetTextContent(status, "Ready", 5);
  ulDestroyDOMElement(status);
}

bool SetupUI(ULView view) {
  ui = ulCreateDOMTriggers();
  ulDOMTriggersOn(ui, "#save", "click", kULDOMEventFlags_None, OnAnySave,
                  NULL, NULL);
  ulDOMTriggersOnDOMReady(ui, OnReady, NULL, NULL);

  // Attach before loading content so the first page gets the set too.
  return ulViewAttachDOMTriggers(view, ui, kULDOMTriggersAttachFlags_None,
                                 NULL, 0);
}

void ShutdownUI(void) {
  // The last owning handle: the set detaches from every View.
  ulDestroyDOMTriggers(ui);
  ui = NULL;
}
```

Keep a few things in mind when working with trigger sets:

- **Trigger sets follow JavaScript origin rules and detach on destruction.** They use the JavaScript API's origin rules (see [Choosing Which Pages Get the API](/docs/2.0/extending-javascript-with-native-api#content-choosing-which-pages-get-the-api)). Destroying the last owning handle detaches the set (see [What Destroying a Handle Does](/docs/2.0/c-api-conventions#content-what-destroying-a-handle-does)).
- **You can't remove individual registrations.** Call `ulViewDetachDOMTriggers()` to remove the entire set from a View.
- **The back-forward cache restores listeners automatically.** Neither the View's DOM-ready callback nor DOM-ready hooks run for cached pages— call `ulDOMTriggersOnRestore()` to receive the restored page's `document`.

## Threading Rules

You must call DOM functions on the Renderer's thread (see [C API Conventions](/docs/2.0/c-api-conventions#content-threading-rules)).

In addition to the standard reference, destroy, and alive checks, a few DOM operations are safe to call from any thread:

- Handle conversions (such as `ulDOMElementAsNode()`)
- List length queries and element lookups (`ulDOMElementListGetLength()` and `ulDOMElementListGetElement()`)
- Destroying an element list (`ulDestroyDOMElementList()`)

To inspect or modify the DOM from another thread, post a task to the Renderer's thread with `ulRendererPostTask()` (see [Renderer in C](/docs/2.0/c-renderer)).
