Hone UI

Visual editor

Click a component in your running app, change its props and sx in a panel, and save the edit to the source file.

@hone-ui/editor adds a panel to your dev server. Click a component on the page, change a prop or an sx value, and the edit lands in the JSX of your source file. The dev server's hot reload repaints the page. The editor only runs in development. A production build has no editor code, no stamped attributes and no endpoint.

Set up in Vite

Install the package as a dev dependency. @hone-ui/sx is already in your project.

pnpm add -D @hone-ui/editor

The sx plugin stamps your components in dev and serves the save endpoint, so a Vite config with the plugin needs no change.

vite.config.ts
import sx from '@hone-ui/sx/vite';
import react from '@vitejs/plugin-react';

export default { plugins: [sx(), react()] };

Render DevEditor once, near the root.

src/main.tsx
import { DevEditor } from '@hone-ui/editor';

createRoot(document.getElementById('root')!).render(
  <ThemeProvider theme={theme}>
    <App />
    <DevEditor />
  </ThemeProvider>,
);

Set up in Next.js

withSx starts a loopback editor server on 127.0.0.1 under next dev. The port comes from your project folder, so it stays the same between runs, and falls back to a free port when taken. withSx passes the port to the browser bundle. The page calls that server directly, so the editor works with Turbopack and webpack and adds no rewrite or route to your app. next build adds nothing.

next.config.ts
import { withSx } from '@hone-ui/sx/next';

export default withSx({});

DevEditor is a client component. Render it from a client file, such as the one that holds your ThemeProvider.

app/providers.tsx
'use client';

import { DevEditor } from '@hone-ui/editor';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider theme={theme}>
      {children}
      <DevEditor />
    </ThemeProvider>
  );
}

Use the editor

  1. Start the dev server and press Alt+Shift+E. Edit mode turns on.
  2. Hover a component to outline it. Click to select it and open the panel.
  3. Change a control. Selects and switches save after 150 ms. Text and number fields update the page as you type, after a 150 ms pause, and one typing session is one undo step. Enter, leaving the field or 600 ms without typing ends the session. An arrow-key step on a number field saves after 150 ms.

While edit mode is on, a small pill at the bottom of the page reads "Editing, Esc to exit".

While edit mode is on, the page ignores clicks and other pointer input, so a button or link under the cursor does nothing. Leave edit mode to use the page again.

Ctrl+Z undoes the last save and Ctrl+Shift+Z or Ctrl+Y redoes it. They work wherever focus is, even with nothing selected, except inside a textarea or a field in your own app, which keeps its native undo. Edit mode keeps one history for every element. Undo reverses the newest edit anywhere and selects the element it changed. Redo stays until you make a new edit. An edit that moves lines keeps the history of other elements in the file, because the editor tracks where their locations went. The history ends when you leave edit mode or when the file changes outside the panel.

To edit a container whose children fill it, press Alt+ArrowUp to select the parent component and Alt+ArrowDown to select the first child. The path of stamped components, such as Card › CardContent, shows in the panel header, and a click on a name selects that component.

The panel

The header stays in view while the body scrolls. It shows the component name, the path of ancestors, the source file and line (with a button that copies them), undo and redo buttons, a save status that reads Saving, Saved or Error, a collapse button and a close button. Collapse shrinks the panel to its header and stays that way for the session. Below 900 px of viewport width the panel docks to the bottom of the window as a sheet, up to 45% of its height, so it never covers the left of the page. An error message has a dismiss button.

Every control is 28 px tall and shares one border, hover and focus ring. A dot beside a label marks a prop you changed in this session. When a prop is unset or equal to the component's default, the control shows the default in muted text. The reset button on each row removes the prop from your source, so the component falls back to its default. Turning a switch off also removes the prop, but the row stays and shows the off state until you reset it.

A value the editor cannot write, such as an expression, shows as a dimmed field with a lock and the tooltip "Set in code, edit the source to change it".

What the panel edits

  • Properties. Boolean props get a switch, string unions a select, numbers and strings a field. A prop written as an expression, such as onClick={save}, shows read-only with a lock. The Add button opens a searchable list of the props that are not set yet, with the component's own props first and plumbing such as className and href under Advanced. Typing filters, the arrow keys move, Enter or a click picks one, and Escape cancels. Picking a prop adds a row that shows the default and writes nothing until you change the value. A text field you empty removes the prop, and the editor never writes an empty string.
  • Styles. Each key of a static sx object gets a control. Rows carry readable names such as Padding X, and the Add list shows the key too, as in "Padding X (px)", grouped as Spacing, Color, Size, Border, Layout, Typography, States and Other. The file still holds the short keys. Spacing keys and borderRadius take numbers, color keys pick a palette path or take free text, and a responsive object shows one field per breakpoint. An icon inside a value makes it responsive. A nested selector such as '&:hover' shows as an indented group with its own Add button, and States in the Add list starts a hover block. Reset removes a key. An sx that is not an object literal, and any spread, computed key or non-literal value, shows read-only.
  • Local components. A wrapper you wrote yourself shows its entry read-only. The editor edits props on @hone-ui/react components.

Each edit rewrites the exact range of one attribute. The rest of the file stays byte for byte the same. If the file changed under the panel, the save fails with a 409, the panel reloads the element, shows a message and clears its history.

Turn off stamping

The editor needs the data-hn-source attribute that the sx plugin adds in dev. Set editor: false to stop stamping.

vite.config.ts
sx({ editor: false });

Security

The endpoint answers only connections that come from this machine. It checks the address of the TCP connection, which a client on your network cannot forge, so a dev server that listens on all interfaces (vite --host, or next dev, which does by default) still refuses every other device with a 403.

A dev server inside Docker with published ports gets a 403 as well, because its connections arrive from the bridge address, not from loopback. Run the dev server on the host to use the editor. A 403 for a file that does have stamped components usually means a stale cache, and deleting .next fixes it.

In Vite the endpoint lives on the dev server's own port, so it must match Host and Origin and sees only same-origin requests. In Next it lives on a separate loopback port, bound to 127.0.0.1. It answers CORS only for a page whose origin is localhost, 127.0.0.1 or [::1] on any port.

Both endpoints reject a Host that is not localhost, 127.0.0.1 or [::1], a foreign Origin, and any request without the x-hone-ui-editor header. Any other local process, or any page you open on a loopback port, can still reach the endpoint, so treat dev-server ports as trusted. It writes only files under your project root that the plugin stamped, never creates or deletes a file, and never runs in a production build.

Check your production build

The repo's CI builds both playgrounds and fails if the output holds data-hn-source or __hone/editor. To check your own app, build it and search the output.

vite build
grep -rl "data-hn-source\|__hone/editor" dist

No match means a clean build.

On this page