Skip to content

Styling the controls

This is Level 2 of three: keep the default controls, change how they look. No JavaScript involved.

Every control below is a line of CSS

The contract

The class names, the data-hc-* attributes and the documented CSS variables are public API. Changing any of them is a breaking change under semver, exactly like renaming a method would be. You can build on them without worrying that a patch release will move them.

That cuts both ways: undocumented internals are not part of the contract. If a selector is not on this page or in the CSS reference, do not target it.

Variables

Set these anywhere they will cascade — :root, the container, or a theme class.

VariableDefaultMeaning
--hc-accent#3b82f6Selection border and handle border colour
--hc-handle-size10pxHandle edge length on screen, at any zoom
--hc-handle-border-width1.5pxHandle border
--hc-selection-border-width1.5pxSelection box border
--hc-rotate-distance26pxGap between the box and the rotate handle
--hc-guide-color#f43f5eAlignment guides shown while snapping
css
.hc-container {
  --hc-accent: rebeccapurple;
  --hc-handle-size: 12px;
}

There is one more, and it goes the other way:

VariableWritten byMeaning
--hc-zoomThe library, every frameCurrent zoom factor

Read it, never set it. It is how sizes stay constant on screen while the whole overlay is scaled — see Counter-scaling below.

Elements and state

SelectorElement
.hc-containerThe element you passed as container
.hc-canvasThe canvas. aria-hidden; everything meaningful is in the overlay
.hc-overlayThe single element carrying the viewport transform
.hc-selectionThe selection box
.hc-handleA resize or rotate handle — a real <button>
.hc-brushThe marquee rectangle
.hc-guideAn alignment guide during a snap
.hc-a11y-list, .hc-a11y-liveVisually hidden; see Accessibility

State is expressed as data attributes rather than class names, so you can write conditional CSS without any JavaScript to keep classes in sync:

AttributeOnValues
data-hc-tool.hc-containerThe active tool id — select, hand, draw, or yours
data-hc-state.hc-containeridle, pointing, dragging, brushing, resizing, rotating, panning
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
css
/* Round handles, with the rotate one filled */
.hc-handle {
  border-radius: 50%;
}
.hc-handle[data-hc-handle='rotate'] {
  background: var(--hc-accent);
  border-color: #fff;
}

/* Grab cursor for the hand tool, closed while panning */
.hc-container[data-hc-tool='hand'] { cursor: grab; }
.hc-container[data-hc-state='panning'] { cursor: grabbing; }

/* Dim the box while it is being dragged */
.hc-container[data-hc-state='dragging'] .hc-selection { opacity: 0.6; }

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

Counter-scaling

The overlay is scaled as a whole — that is invariant 3, and it is why panning costs one style write no matter how many controls are visible. The consequence is that anything inside it would scale with the zoom, including a 10px handle.

The fix is arithmetic in CSS:

css
.hc-handle {
  width: calc(var(--hc-handle-size) / var(--hc-zoom));
  height: calc(var(--hc-handle-size) / var(--hc-zoom));
  border-width: calc(1.5px / var(--hc-zoom));
}

Any length you write for an element inside the overlay needs the same division — borders, padding, font sizes, offsets. If you skip it, the element will look right at 100% and wrong everywhere else.

The alternative — writing a corrected pixel size onto every handle from JavaScript each frame — is exactly what invariant 4 forbids, because it reintroduces the per-element work that invariant 3 removed.

Dark mode

Nothing in the stylesheet assumes a light background. Use whatever mechanism the rest of your application uses:

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

The canvas itself is transparent, so the container's background is the document surface.

Reduced motion

The stylesheet disables transitions on the selection box and handles under prefers-reduced-motion: reduce. If you add transitions of your own, honour it as well.

When CSS is not enough

If you need different markup — a toolbar attached to the selection, labelled handles, an edge midpoint that adds a point — CSS will not get you there. That is Level 3, and it does not mean giving up the interaction behaviour.

Released under the MIT License.