Engineering
How We Cover Every Composer Component Without a Single getBoundingClientRect
Your overlay is the wrong size because it is inventing the size. The base owns width and height; the top layer only stretches.
In the RuleCMS composer, every dropped component gets a hover layer: a brand-tinted "Click To Modify" overlay that is exactly as wide and tall as the component underneath. Empty spacers, short labels, full-bleed images — the overlay has to match all of them, stay glued while the canvas scrolls, and never shift the layout.
The tempting fix is to measure. Read getBoundingClientRect, write pixel width and height, pin the overlay with position: fixed, and subscribe to resize. That works until the next reflow. Then you measure again. Then a font loads. Then a nested image finishes decoding. You have built a layout engine on top of the one the browser already has.
We do not measure. The overlay does not know how big the component is.
Core insight
The overlay is the wrong size when it invents the size. Give the base wrapper position: relative and let in-flow content size it. Stretch the top layer with position: absolute; top: 0; left: 0; width: 100%; height: 100%. Percentages resolve against that wrapper. The overlay only covers. It does not decide.
The Overlay Should Not Invent the Size
width: 100% and height: 100% are not a general promise that "this div will match the one underneath." They resolve against the containing block — the nearest ancestor whose position is not static. If that ancestor does not have a used height, height: 100% collapses. If the ancestor is the viewport, you have covered the page.
So the job splits in two. The base wrapper must already have a real box. The overlay must be taken out of flow so it cannot change that box, then told to fill it.
The Pattern
Each canvas item is wrapped by a relative parent that shrink-wraps the component. The overlay is a sibling inside that wrapper, not a portal and not a fixed layer:
<div style={{ position: "relative", display: "inline-block" }}>
<button
type="button"
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
zIndex: 1,
boxSizing: "border-box",
}}
>
Click To Modify
</button>
{children}
</div>Each declaration earns its place:
- Wrapper
position: relative— this is the containing block. Without it,100%looks at some outer box, or height collapses. - Wrapper sized by in-flow
children— the overlay is out of flow, so it cannot give the wrapper a height. The real component must. - Overlay
position: absolute— out of flow, so it does not inflate the wrapper. top: 0; left: 0; width: 100%; height: 100%— stretch to the wrapper's padding box.z-index: 1— we render the overlay before the component. Later siblings paint on top unless the overlay creates its own stacking level. A local1is enough. It only has to beat content inside this wrapper, not the app header.box-sizing: border-box— border and padding stay inside the100%box so the overlay does not spill past the component.
display: inline-block shrink-wraps the dropped component. A block wrapper is fine if you want the overlay as wide as the parent column. Empty or collapsed bases need a min size — ours is 50px — because height: 100% of an empty relative parent is 0, and a zero-height hit target is not clickable.
Composer chrome that must not change measured geometry lives on outline, not border. A 1px border on the wrapper changes the box the overlay is matching.
What We Refuse
position: fixed plus a huge z-index. The overlay sticks to the viewport. It drifts off the component the moment the canvas scrolls, and the z-index starts fighting the whole page instead of one stacking context.
width: 100%; height: 100% on a static parent. Percentage height only works when the containing block has a used height. A static block with height: auto does not give the overlay a real height.
Measuring with getBoundingClientRect. You then re-measure on resize, reflow, and font load. The browser already did that work when it laid out the component.
Leaving the overlay in normal flow. It becomes part of the wrapper's size. The "cover the base" contract is gone.
A different trap, and a different fix: putting z-index only on a nested child while an ancestor already has a stacking context (transform, filter, opacity below 1, or position plus z-index). The child cannot escape that ancestor. Dialogs and drawers in RuleCMS portal to document.body for that reason. That is page-level chrome. It is not how you cover one component.
When 100% Matches — and When It Does Not
In this setup, width: 100%; height: 100% matches the base. That is not a CSS guarantee. It holds when all three are true:
- the overlay is absolutely positioned
- the percentages resolve against the wrapper you intend — the nearest ancestor with
positionother thanstatic - that wrapper already has a used width and height from in-flow content, or from an explicit size / min size
Fail any one of those and you are back to inventing a size: pixels from JavaScript, or a layer that thinks the viewport is the component.
The Same Idea, Smaller Badge
Move, copy, and delete mode reuse the relative wrapper. They do not stretch to 100%. The handle is position: absolute; top: 0; left: 0 with a fixed 52×52 badge. Same containing block, same refusal to measure, different job: sit on the corner, not cover the whole component.
Hover on "Click To Modify" only changes opacity. The hit target is already the full box, which is why clicking anywhere on the component opens the drawer.