Skip to content

Package

remix/ui

Runtime UI primitives for Remix apps, including the component runtime, server rendering, frame hydration, reusable mixins, and headless first-party behavior primitives.

Features

  • Component runtime APIs for rendering, hydration, link and form frame navigation, and JSX
  • Server rendering APIs for streaming Remix UI trees and frames
  • mix composition with event, ref, CSS, and animation helpers
  • Headless behavior primitives for controls such as menus, listboxes, popovers, selects, and comboboxes
  • Lower-level utilities for keyboard events, typeahead search, refs, attributes, and CSS transition timing

Installation

npm i remix

Usage

Compose behavior primitives with your own markup and styles:

import { css } from 'remix/ui'
import * as popover from 'remix/ui/popover'

let triggerCss = css({
  border: '1px solid #d1d5db',
  borderRadius: '6px',
  padding: '6px 10px',
})

let surfaceCss = css({
  background: 'white',
  border: '1px solid #d1d5db',
  borderRadius: '6px',
  padding: '8px',
})

function ViewOptions() {
  let open = false

  return () => (
    <popover.Context>
      <button
        mix={[triggerCss, popover.anchor({ placement: 'bottom-end' }), popover.focusOnHide()]}
        onClick={() => {
          open = true
        }}
        type="button"
      >
        View options
      </button>
      <div
        mix={[
          surfaceCss,
          popover.surface({
            open,
            onHide() {
              open = false
            },
          }),
        ]}
      >
        Panel content
      </div>
    </popover.Context>
  )
}

Button styling is available as a composable mixin:

import button from 'remix/ui/button'

function Actions() {
  return () => <button mix={button({ tone: 'primary' })}>Create project</button>
}

Frame Navigation

run() progressively enhances same-origin links and forms using a default resolveFrame that fetches the frame source:

import { run } from 'remix/ui'

let app = run({
  async loadModule(moduleUrl, exportName) {
    let mod = await import(moduleUrl)
    return mod[exportName]
  },
})

await app.ready()

The default resolver is equivalent to:

async function resolveFrame(src, options) {
  let response = await fetch(src, {
    body: getRequestBody(options),
    headers: { Accept: 'text/html' },
    method: options?.method,
    signal: options?.signal,
  })

  if (!response.ok) {
    throw new Error(`Failed to resolve frame: ${response.status} ${response.statusText}`.trimEnd())
  }

  return response
}

function getRequestBody(options) {
  let formData = options?.formData
  if (!formData || options?.method?.toLowerCase() === 'get') return

  if (options?.encType === 'text/plain') {
    let body = ''
    for (let [name, value] of formData) {
      name = normalizeLineBreaks(name)
      value = normalizeLineBreaks(typeof value === 'string' ? value : value.name)
      body += `${name}=${value}\r\n`
    }
    return new Blob([body], { type: 'text/plain' })
  }

  if (options?.encType !== 'application/x-www-form-urlencoded') return formData

  let body = new URLSearchParams()
  for (let [name, value] of formData) {
    body.append(name, typeof value === 'string' ? value : value.name)
  }
  return body
}

function normalizeLineBreaks(value) {
  return value.replace(/\r\n|\r|\n/g, '\r\n')
}

The default resolver requests HTML. GET form values are already encoded in src; application/x-www-form-urlencoded submissions use URLSearchParams, text/plain submissions use CRLF-delimited text, and multipart/form-data submissions use FormData. Pass a custom resolveFrame when the server requires additional headers, another body encoding, or a different response policy.

Add data-rmx-document to a link or form to leave its navigation to the browser.

The default resolver rejects non-OK responses with an error containing their status and status text. A custom resolveFrame may return a Response with any status when it wants Remix UI to render the response body.

Forms remain ordinary HTML forms before the runtime starts. Add data-rmx-target to reload a named frame, or data-rmx-document to require a full-document submission:

import { Frame } from 'remix/ui'

function AccountPage() {
  return () => (
    <>
      <Frame name="account" src="/account/edit" />
      <form action="/account/edit" method="post" data-rmx-target="account">
        <label for="display-name">Display name</label>
        <input id="display-name" name="displayName" required />
        <button type="submit">Save</button>
      </form>
    </>
  )
}

Native constraint validation and submitter overrides still apply. GET form values arrive in src; non-GET forms provide formData, method, and encType to the resolver. See Frames for targeting, history behavior, request encoding, opt-outs, and server response guidance.

Use data-rmx-history="push|replace" on an enhanced anchor or form to control how the navigation updates history. This can override the automatic replacement used for non-GET form submissions to the current URL.

Preserving Client-Owned DOM

Use data-rmx-preserve-dom on the smallest element whose live DOM should belong to client code after initial render, such as a custom element or third-party widget:

<pagefind-ui data-rmx-key="search" data-rmx-preserve-dom>
  <button type="button">Search</button>
</pagefind-ui>

Remix UI still renders the element's children during SSR and still hydrates any initial client entries inside it. On later frame reloads, matched data-rmx-preserve-dom elements keep their current attributes and children instead of accepting incoming DOM updates. See Preserving client-owned DOM for guidance and caveats.

Cascade Layers

Remix UI emits generated css(...) rules under the rmx cascade layer. Unlayered CSS outranks layered CSS, so use explicit layer order when mixing Remix UI with global styles.

Put layers that should lose to Remix UI before rmx:

@layer base, rmx;

@layer base {
  button,
  input,
  textarea,
  select {
    font: inherit;
    margin: 0;
    padding: 0;
  }
}

License

See LICENSE