Skip to content

CSS contract

The class names, data-hc-* attributes and CSS variables on this page are public API. Changing one is a breaking change under semver, exactly as renaming a method would be.

Anything not listed here is internal, whatever it looks like in the stylesheet.

ts
import '@headless-canvas/ui/styles.css'

The stylesheet is a separate file rather than injected at runtime. Runtime injection produces a flash of unstyled controls during SSR hydration, breaks under a strict CSP, and leaves dead CSS in the bundle for anyone replacing the default UI outright.

Variables you set

VariableDefaultApplies to
--hc-accent#3b82f6Selection border, handle border, focus ring
--hc-handle-size10pxHandle edge length on screen
--hc-handle-border-width1.5pxHandle border
--hc-selection-border-width1.5pxSelection box border
--hc-rotate-distance26pxOffset of the rotate handle above the box
--hc-guide-color#f43f5eAlignment guides

Declared on .hc-container, so setting them anywhere that cascades to it works.

Variables the library writes

VariableValue
--hc-zoomThe current zoom factor

Read-only. Written to .hc-overlay on every frame the camera changes, so it is available to the overlay and everything inside it — which is exactly the scope that needs it. Divide by it for any length there; see Counter-scaling.

Classes

ClassElement
.hc-containerThe element passed as container. role="application", focusable
.hc-canvasThe canvas. aria-hidden="true"
.hc-overlayThe single element carrying the viewport transform. pointer-events: none
.hc-selectionThe selection box
.hc-handleA resize or rotate handle. A <button> with role="button"
.hc-brushThe marquee rectangle
.hc-guideAn alignment guide during a snap
.hc-a11y-listThe visually hidden shape list
.hc-a11y-liveThe aria-live region
.hc-text-dialogThe text editing <dialog>. Style ::backdrop here
.hc-text-formThe form inside it
.hc-text-labelLabel wrapping the field
.hc-text-inputThe <textarea>
.hc-text-actionsThe button row
.hc-text-buttonA dialog button

Data attributes

State is expressed as data attributes rather than class names, so conditional styling needs no JavaScript to keep classes in sync. Attributes that would always be present are deliberately absent — a redundant attribute is noise in every selector that has to skip it.

AttributeOnValues
data-hc-tool.hc-containerThe active tool id: select, hand, draw, or your own
data-hc-state.hc-containeridle pointing dragging brushing resizing rotating panning editing
data-hc-selection.hc-selectionsingle multiple
data-hc-locked.hc-selectionPresent when the selection contains a locked shape
data-hc-handle.hc-handlenw n ne e se s sw w rotate
data-hc-guide.hc-guidex y
data-hc-text-action.hc-text-buttonsave cancel

Recipes

css
/* Circular handles with a filled rotate handle */
.hc-handle { border-radius: 50%; }
.hc-handle[data-hc-handle='rotate'] {
  background: var(--hc-accent);
  border-color: #fff;
}

/* Cursors driven entirely by the tool state */
.hc-container[data-hc-tool='hand'] { cursor: grab; }
.hc-container[data-hc-state='panning'] { cursor: grabbing; }

/* Fade the controls while dragging so they do not obscure the shape */
.hc-container[data-hc-state='dragging'] .hc-selection { opacity: 0.5; }

/* A locked selection reads differently */
.hc-selection[data-hc-locked] {
  border-style: dotted;
  border-color: #9ca3af;
}

/* Larger hit area than the visible handle, without changing how it looks */
.hc-handle::before {
  content: '';
  position: absolute;
  inset: calc(-6px / var(--hc-zoom));
}

/* The editing dialog. It is not inside the overlay, so no counter-scaling. */
.hc-text-dialog::backdrop { background: rgb(0 0 0 / 60%); }
.hc-text-button[data-hc-text-action='save'] { background: rebeccapurple; }
.hc-container[data-hc-state='editing'] .hc-selection { opacity: 0.4; }

/* Dark theme */
@media (prefers-color-scheme: dark) {
  .hc-container { --hc-accent: #60a5fa; }
  .hc-handle { background: #1f2937; }
}

What the stylesheet already handles

  • Counter-scaling. Every length in it is divided by --hc-zoom
  • Focus rings. :focus-visible on the container and on each handle
  • Reduced motion. Transitions on the selection box and handles are disabled under prefers-reduced-motion: reduce
  • Screen-reader visibility. The hidden list's buttons become visible when focused, because a focused control nobody can see is its own failure

If you replace the stylesheet outright, all four are yours to reproduce.

Released under the MIT License.