Building components for Underglass
This is the standard for components, your own or a library’s, that should work inside a Underglass app. The goal: the component is ordinary React DOM first, and GPU presentation is an optional layer on top that never changes how it behaves.
A component that follows these rules works three ways without changes: in a plain React app, in a Underglass app in ordinary DOM mode, and in GPU mode.
The rules
Section titled “The rules”1. Plain DOM first, no clones
Section titled “1. Plain DOM first, no clones”- Render real elements, keep state in React, and use native controls.
- Never render a second “display copy” of an interactive element for the GPU to draw. Underglass draws the original element, and the original receives the input.
- Do not read layout to fake positions (for example, absolutely positioning a duplicate where you think the GPU drew something).
2. Expose one boundary element
Section titled “2. Expose one boundary element”A layer is one element. A component offers its root element as that boundary in one of two ways:
-
App-owned layers (recommended for libraries). Forward a
refto your root element and acceptclassNameandstyle. The app decides whether to enroll it:export const Card = forwardRef<HTMLElement, CardProps>(function Card({ title, children, ...rest }, ref) {return <article ref={ref} className="card" {...rest}><h3>{title}</h3>{children}</article>;});// In a Underglass app:const ref = useSurfaceRef<HTMLElement>({ id: "welcome-card", origin: "center" });<Card ref={ref} title="Welcome" />;Enrolled elements must be direct children of the Underglass root, so render the card at the top level of the app tree, positioned absolutely by its own CSS.
-
Component-owned layers. An app-level component can render
<Surface>directly, with typedlayoutandappearance(they compile to CSS, so the component still has a DOM fallback).
Do not nest layers. A layer inside another layer falls back to DOM.
3. Leave the boundary’s transform to Underglass
Section titled “3. Leave the boundary’s transform to Underglass”The boundary’s transform, translate, rotate, scale, opacity and
transform-origin belong to the runtime in GPU mode.
- Do not animate them with CSS or
element.animate()on the boundary itself. Usetransition,PresenceorcreateMotion, or hand an existing animation over withownAnimation/ownCSSAnimation(see Motion). - Inside the boundary anything goes: CSS transitions, keyframe animations and hover effects on descendants are repainted and recaptured automatically.
- No
z-indexon the boundary; layers paint in DOM order.
4. Keep everything inside the box
Section titled “4. Keep everything inside the box”A layer is captured at its own size. Anything painted outside the boundary is cut
off: box shadows, outlines, glows, or content overflowing with overflow: visible.
- Put shadows on an inner element and give the boundary enough padding, or use borders instead.
- Let content scroll (
overflow: auto, or<NativeScroll>) or clip it, rather than letting it spill out. Visible overflow falls back to DOM. - Rotated layers must not sit inside a clipping ancestor, and must not clip their own overflowing content (hit testing on a rotated clip is not exact in Chrome). Rotate layers that are children of the root and have no hidden overflow.
5. Send dialogs, popovers and menus to the overlay host
Section titled “5. Send dialogs, popovers and menus to the overlay host”<dialog>, popover, and contenteditable regions inside a layer need ordinary
DOM. Render them through the adapter’s overlay instead:
-
<NativeOverlay>{children}</NativeOverlay>portals into the overlay host. -
Fluent UI v9: wrap the app in
<PortalMountNodeProvider value={useNativeOverlayHost()}>(no DOM of its own). Every Fluent portal then mounts in the overlay host with its theme.FluentProviderrenders a<div>, so put one inside each layer rather than around the layers. The showcase’sfluent.tsxdoes both. -
For libraries with a portal
containerprop (Radix, Headless UI, MUI), passuseNativeOverlayHost():const overlay = useNativeOverlayHost();<Dialog.Portal container={overlay}>...</Dialog.Portal>
Text inputs and textareas are fine inside layers: editing, caret, selection and IME composition stay native.
6. Separate decoration from interaction
Section titled “6. Separate decoration from interaction”Continuous shader effects only run on decorative layers: inert, aria-hidden,
with no buttons, links, inputs or focusable elements. If your component has artwork
worth animating with effects, render it as a sibling decorative element the app can
enroll with decorative: true, next to the interactive part. Do not mark
interactive content aria-hidden to make it eligible.
An interactive layer can carry a transition effect (interaction: "inert") while
it enters, leaves or lands: its content is inert for that time, then interactive again
(see Effects). Keep the layer’s
boundary element itself non-interactive (a wrapper, not a <button>).
7. Accessibility is the DOM’s
Section titled “7. Accessibility is the DOM’s”GPU mode keeps the original elements in the accessibility tree; the output canvas
is not aria-hidden. Label controls, keep focus order sensible and respect
prefers-reduced-motion in your own CSS. Underglass already respects it for its own
motion and effects.
Checklist before you ship a component
Section titled “Checklist before you ship a component”- Works in a plain React app with no Underglass.
- Root element accepts a
ref(or the component renders aSurface). - No transform or opacity animation on the root itself; descendants are free.
- No shadow, outline or content painted outside the root’s box.
- Dialogs, popovers and menus accept a portal container.
- Decorative artwork is separable from controls.
- In the showcase or playground: enable GPU, use every control, move or rotate
the layer, and check that clicks land and
controller.state().modestays"gpu". If it drops to DOM, thereasonnames the rule (see Troubleshooting).
Example in the repository
Section titled “Example in the repository”The task app in examples/playground/src/apps/todo is an ordinary React component
(forms, list, detail view, dialog) reused unchanged by the showcase. Only its
decorative poster carries an effect. The showcase’s deck.tsx, notices.tsx and
pond.tsx show Surface, Presence, origin and decorative layers in practice.