Web Component Implementation
This document describes the <vn-resize-box> custom element architecture — its lifecycle, observed attributes, public API, and internal DOM structure.
1. Overview
VanillaResizeBoxElement is a standard Custom Element v1 registered as <vn-resize-box>. It wraps arbitrary light-DOM content inside a resizable container with a bottom-right drag handle. The component does not use Shadow DOM, which means host-application CSS applies normally to slotted content.
2. Registration
if (!window.customElements.get('vn-resize-box')) {
window.customElements.define('vn-resize-box', VanillaResizeBoxElement);
}
window.VanillaResizeBoxElement = VanillaResizeBoxElement;
The element is registered once. Subsequent script includes are harmless. The constructor is also exposed on window for programmatic use.
3. Observed Attributes
static get observedAttributes() {
return [
'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
];
}
| Attribute | Type | Default | Description |
|---|---|---|---|
width |
CSS length | — | Initial width. A bare number is px; any CSS length is accepted (600, 600px, 100%, 60vw) |
height |
CSS length | — | Initial height. Same syntax as width |
min-width |
px | 100 |
Minimum width. Bare number or explicit px only |
min-height |
px | 100 |
Minimum height. Bare number or explicit px only |
max-width |
CSS length | — | Maximum width. Same syntax as width |
max-height |
CSS length | — | Maximum height. Same syntax as width |
width, height, max-width and max-height are written straight into the inline style, so any CSS length works. The two min-* attributes are different: they feed the drag-clamping arithmetic, which needs an absolute pixel number, so only a bare number or an explicit px value is honoured — a relative value (50%) is rejected and the default applies.
When min-width or min-height is not explicitly set, the component falls back to VanillaResizeBoxElement.DEFAULT_MIN_WIDTH (100) and VanillaResizeBoxElement.DEFAULT_MIN_HEIGHT (100) respectively.
When max-width or max-height is not set, the component computes parent-relative bounds at drag-start to prevent the box from exceeding its parent container.
4. Lifecycle Callbacks
4.1 connectedCallback()
- Injects the component stylesheet into
<head>once (shared across all instances). - Builds internal markup: wraps existing light-DOM children in a
.vn-resize-boxdiv and appends a.vn-resize-handleelement. AMutationObserveron the host then adopts any direct child added after connect into the same wrapper (inserted before the handle) — frameworks that build DOM imperatively (e.g. Angular) attach the element to the document before rendering its children, and without adoption those late children would land outside the sized wrapper. - Applies initial dimensions from attributes to inline styles.
4.2 disconnectedCallback()
Runs the same end-of-drag cleanup as a pointer release (_endDrag(false)): releases the pointer capture, cancels a queued move frame, and clears the page-wide drag state (body user-select, the --dragging class). It fires no event — the element is leaving the document. Removing a box mid-drag must not leave the page unselectable.
4.3 attributeChangedCallback(name, oldValue, newValue)
Routes each observed attribute to the narrowest possible update. It deliberately does not re-run the connect-time pass, because that pass rewrites the authored width/height over the inline style — which is exactly where a dragged size lives.
| Attribute changed | Effect |
|---|---|
width / height |
Rewrites only that axis (_applySizeAttribute). An explicit change is the author overruling the current size; removing the attribute clears the inline style |
min-* / max-* |
Re-applies the constraint styles only (_applyConstraints), never the size. Skipped while a drag is in flight — bounds are frozen for the gesture and restored when it ends |
The size is therefore stable across every attribute change that is not a width/height change, and across a disconnect → reconnect cycle: connectedCallback applies an authored size only when the attribute is actually present.
4.4 The dimension pipeline
Three separate writers, so that no update does more than it was asked to:
| Method | Writes | Called by |
|---|---|---|
_applyInitialDimensions() |
authored width/height if present, then the constraints |
connectedCallback |
_applySizeAttribute(name) |
one axis, symmetric — an absent attribute clears the inline style | attributeChangedCallback |
_applyConstraints() |
minWidth / minHeight / maxWidth / maxHeight |
the two above, attributeChangedCallback, and the end of a drag (to restore the attribute bounds over the frozen ones) |
5. Internal DOM Structure
After connectedCallback(), the element's light DOM looks like:
<vn-resize-box aria-label="Resizable orders grid">
<div class="vn-resize-box">
<!-- original children moved here -->
<div class="vn-resize-handle" role="separator"
aria-label="Resizable orders grid — resize handle"></div>
</div>
</vn-resize-box>
.vn-resize-boxis the structural wrapper. It is not the sized element: CSS fixes it atwidth: 100%; height: 100%of the custom element. Thewidth/height/minWidth/minHeight/maxWidth/maxHeightinline styles are written onto the<vn-resize-box>custom element itself, which is the flex/grid item the host lays out..vn-resize-handleis the 24×24px corner handle that receives the pointer input. See §9 for its accessibility semantics.- Children appended to
<vn-resize-box>after connect are moved into.vn-resize-boxautomatically (before the handle), so the structure above holds regardless of when the host renders its content.
6. Public API
6.1 Properties
| Property | Type | Access | Description |
|---|---|---|---|
width |
string | null |
read-only | The width attribute, verbatim — width="600" reads "600", not "600px" |
height |
string | null |
read-only | The height attribute, verbatim |
maxWidth |
string | null |
read-only | The max-width attribute, verbatim |
maxHeight |
string | null |
read-only | The max-height attribute, verbatim |
minWidth |
number | read-only | The minimum width actually enforced, in px — resolved, so a rejected min-width reads back as the default |
minHeight |
number | read-only | The minimum height actually enforced, in px |
currentWidth |
number | read-only | Current rendered width in px |
currentHeight |
number | read-only | Current rendered height in px |
The first four are raw attribute reflections and the next two are resolved values, on purpose. width answers "what did the author write"; minWidth answers "what bound will a drag actually respect" — the question a number-typed minimum is useful for. None of the six has a setter: attributes are the configuration channel and resize() is the write path for size.
6.2 Methods
| Method | Signature | Description |
|---|---|---|
resize |
resize(width?, height?) |
Programmatically set dimensions (see §6.3) |
6.3 resize() semantics
resize() is not a synthetic drag, and differs from one in three stated ways:
- Clamping — numeric arguments are clamped to the
min-*/max-*attributes only. The parent-relative ceiling a drag enforces is not applied: the host may be sizing before its own layout has settled, so the parent rect would be a bound measured against a page that is about to change. Aresize()can therefore produce a size the user could not have dragged to. - Flex neutralisation — it sets
flex: 0 0 auto, exactly as_onDowndoes. Without it the flex algorithm overrides the inline size and the call renders no visible change inside a flex container. - Events — it fires a single
vn-resize-endand novn-resize-start/vn-resize-move. Hosts persisting onvn-resize-end(the recommended pattern) therefore see programmatic changes as well as dragged ones. Avoiding a feedback loop is the host's responsibility; the detail already carries the resulting size.
CSS-string arguments ("100%", "60vw") are applied as-is with no clamping — the clamp is pixel arithmetic and has no meaning for a relative length.
6.4 Static Constants
| Name | Value | Description |
|---|---|---|
DEFAULT_MIN_WIDTH |
100 |
Fallback minimum width when min-width attribute is absent |
DEFAULT_MIN_HEIGHT |
100 |
Fallback minimum height when min-height attribute is absent |
Three more constants are module-private, deliberately: they are part of the component's feel rather than a configuration surface, and nothing has asked for them to be tunable.
| Name | Value | Description |
|---|---|---|
HANDLE_BOUND_MARGIN |
12 |
Gap kept between the box and its parent's far edge when the parent supplies the ceiling |
HANDLE_LABEL_SUFFIX |
" — resize handle" |
Appended to the host's aria-label to name the handle |
HANDLE_DEFAULT_LABEL |
"Resize" |
Handle name when the host supplies no aria-label |
7. Events
| Event | Bubbles | Detail | When |
|---|---|---|---|
vn-resize-start |
yes | { width, height } — the size when the drag began |
User presses the handle |
vn-resize-move |
yes | { width, height } |
Each frame during a drag |
vn-resize-end |
yes | { width, height } |
User releases the handle, or resize() is called |
All three carry the same detail shape, so a listener can read the size without a getBoundingClientRect() of its own.
8. Stylesheet Auto-Injection
On first connectedCallback(), the component inserts a <link> tag into <head>:
<link id="vn-resize-box-css" rel="stylesheet" href="{baseUrl}vanilla-resize-box.css">
The base URL is resolved at script parse time from document.currentScript.src. Multiple <vn-resize-box> elements share a single stylesheet link.
9. Accessibility
9.1 Handle semantics
| Value | |
|---|---|
role |
separator |
tabindex |
not set — the handle is not focusable |
aria-label |
derived from the host's, else "Resize" |
aria-hidden |
not set |
role="separator" describes what a resize grip is — a window splitter. It is not the focusable-separator pattern: resizing is pointer-driven, so the handle takes no tabindex. A focusable handle would be a tab stop that answers to no key, and (since the handle is the box's last child) one sitting between the page and the box's own content. role="slider" was rejected for a different reason: a slider is single-valued and this handle drives two axes at once, so aria-valuenow could only ever describe one of them honestly. For the same reason the handle carries no aria-valuenow / aria-valuemin / aria-valuemax and no aria-orientation — a two-axis separator has neither a single value nor a single orientation.
The stylesheet accordingly ships no :focus-visible rule for the handle; the triangle is a pointer affordance.
9.2 Labelling
The handle used to carry aria-hidden="true", while hosts put aria-label="Resizable … grid" on the <vn-resize-box> — an element with no role, where assistive technology generally ignores the label outright. The component now consumes that label instead: the host's aria-label, when present, is copied onto the handle and suffixed, so aria-label="Resizable orders grid" on the host yields "Resizable orders grid — resize handle" on the handle. With no host label the handle falls back to "Resize".
Hosts therefore need no markup change — the label they already write now lands somewhere it is observable.
Two limits worth knowing:
- The suffix is hard-coded English. The component ships no i18n mechanism and inventing one here was out of scope. A host needing another language sets
aria-labelon the box; the suffix is then the only untranslated fragment. - The name is derived once, when the internal markup is built. Changing the host's
aria-labelafterwards does not re-derive it; set the handle'saria-labeldirectly if you need it to change at runtime.
9.3 No keyboard path
The handle has no keydown listener and the component observes no key. A host that needs a keyboard-operable size drives resize() from its own control, which fires vn-resize-end exactly as a drag does.
10. No Shadow DOM
The component deliberately avoids Shadow DOM. This means:
- Host application CSS styles (borders, backgrounds, fonts) apply to content inside the box without extra
::part()or::slotted()selectors. - Theme CSS from the host (e.g., Vanilla-Grid themes) works transparently when
<vn-grid>is placed inside<vn-resize-box>. - The
.vn-resize-boxand.vn-resize-handleclasses can be targeted by external CSS for customization.