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.