Vanilla Resize Box
A lightweight, zero-dependency, resizable container web component with a visible bottom-right drag handle. Built with vanilla JavaScript — no frameworks needed.
Features
- Drag-to-resize: Corner handle for intuitive resizing
- One input path: Built on Pointer Events — mouse, touch and pen behave identically, and a gesture the system takes over ends cleanly
- RTL-aware: Under
dir="rtl"the handle moves to the bottom-left corner and the geometry mirrors - Configurable bounds: Optional min/max width and height constraints
- Parent-aware clamping: Automatically limits resize to parent container boundaries
- Custom events: Emits
vn-resize-start,vn-resize-move, andvn-resize-endevents - Auto CSS injection: Stylesheet is automatically injected into
<head>— no manual<link>tags needed - Zero dependencies: Pure JavaScript, no frameworks required
Installation
Direct File Include
<script src="path/to/vanilla-resize-box/vanilla-resize-box.js"></script>
The component automatically injects its CSS (vanilla-resize-box.css) into <head>. No manual <link> tag is required.
Usage
Basic Usage
<vn-resize-box width="600" height="400">
<p>Any content goes here</p>
</vn-resize-box>
With Constraints
<vn-resize-box
width="800"
height="500"
min-width="300"
min-height="200"
max-width="1200"
max-height="800">
<div id="myContent">Resizable content</div>
</vn-resize-box>
Wrapping a Grid Component
<vn-resize-box min-width="360" min-height="220">
<vn-grid id="usersGrid" theme="default">
<vn-grid-column field="Name" header="Name" type="string"></vn-grid-column>
<vn-grid-column field="Email" header="Email" type="string"></vn-grid-column>
</vn-grid>
</vn-resize-box>
No Initial Size (Fills Parent)
When width and height are omitted, the box fills its parent container:
<div style="width: 100%; height: 80vh;">
<vn-resize-box>
<p>Fills parent, then can be resized</p>
</vn-resize-box>
</div>
Attributes
All attributes are optional.
| Attribute | Type | Default | Description |
|---|---|---|---|
width |
CSS length | — | Initial width. A bare number is px; any CSS length works (600, 600px, 100%, 60vw). If omitted, the container fills its parent's width |
height |
CSS length | — | Initial height. Same syntax as width. If omitted, the container fills its parent's height |
min-width |
px | 100 |
Minimum width. Bare number or explicit px only — a relative value is rejected and the default applies |
min-height |
px | 100 |
Minimum height. Same rule as min-width |
max-width |
CSS length | — | Maximum width. Same syntax as width. If omitted, limited by parent bounds |
max-height |
CSS length | — | Maximum height. Same syntax as width. If omitted, limited by parent bounds |
Removing any of the sizing attributes clears the inline style it wrote, so the dimension falls back to whatever CSS says.
Properties
All are read-only; attributes are the configuration channel and resize() is the write path for size.
| Property | Type | Description |
|---|---|---|
width |
string | null |
The width attribute, verbatim — width="600" reads back "600", not "600px" |
height |
string | null |
The height attribute, verbatim |
maxWidth |
string | null |
The max-width attribute, verbatim |
maxHeight |
string | null |
The max-height attribute, verbatim |
minWidth |
number | The minimum width actually enforced, in px. Resolved, not raw: a rejected min-width reads back as the default (100) |
minHeight |
number | The minimum height actually enforced, in px |
currentWidth |
number | Current rendered width in pixels |
currentHeight |
number | Current rendered height in pixels |
Methods
| Method | Signature | Description |
|---|---|---|
resize |
resize(width?, height?) |
Programmatically resize the box. Numbers are clamped to the min-*/max-* attributes; CSS strings are applied as-is |
const box = document.querySelector('vn-resize-box');
box.resize(500, 300);
resize() deliberately differs from a drag in three ways:
- it clamps to the
min-*/max-*attributes only — the parent-relative ceiling a drag enforces is not applied, because the host may be sizing before its own layout has settled; - it sets
flex: 0 0 auto, so the size survives inside a flex container instead of being overridden by the flex algorithm; - it fires a single
vn-resize-endand novn-resize-start/vn-resize-move, so hosts that persist onvn-resize-endalso see programmatic changes. Avoiding a feedback loop is the host's responsibility.
Events
| Event | Detail | Description |
|---|---|---|
vn-resize-start |
{ width, height } |
Fired when the user begins dragging the handle, carrying the size at press time |
vn-resize-move |
{ width, height } |
Fired on every frame during drag |
vn-resize-end |
{ width, height } |
Fired when the user releases the handle — and by resize() |
document.querySelector('vn-resize-box').addEventListener('vn-resize-end', (e) => {
console.log('New size:', e.detail.width, 'x', e.detail.height);
});
Accessibility
Resizing is pointer-only. The drag handle carries role="separator" and an accessible name so assistive technology announces the resizable boundary, but it is deliberately not focusable and answers to no key — it adds no tab stop to the page, in particular none between the page and the content the box wraps. A host that needs a keyboard-operable size drives resize() from its own control.
Labelling. Put aria-label on the <vn-resize-box> and the handle takes it with a suffix:
<vn-resize-box aria-label="Resizable orders grid">…</vn-resize-box>
<!-- handle: aria-label="Resizable orders grid — resize handle" -->
With no host label the handle falls back to "Resize". The suffix is hard-coded English (the component ships no i18n mechanism) and the name is derived once, when the internal markup is built — set the handle's aria-label directly if you need another language or a runtime change.
The handle carries no aria-valuenow / aria-orientation: it drives two axes at once, so neither could be stated honestly.
Right-to-Left
The component follows the inline direction. Under dir="rtl" — set on <html>, on the box, or on any ancestor — the handle sits in the bottom-left corner, dragging left grows the box, and the parent-relative ceiling is measured from the parent's right edge. There is no attribute to switch this on.
The mirrored cursor, corner radius and triangle are [dir="rtl"] CSS rules, so set the dir attribute rather than only the CSS direction property: with direction alone the box still resizes correctly, but the handle keeps its LTR visual.
Vertical writing modes are not supported — only the inline axis mirrors.
CSS Customization
The component uses light DOM, so you can override styles with normal CSS selectors:
/* Change handle appearance */
.vn-resize-handle::before {
background: linear-gradient(135deg, transparent 50%, rgba(0, 120, 215, 0.3) 50%);
}
/* Add a border to the container */
.vn-resize-box {
border: 1px solid #ccc;
border-radius: 8px;
padding: 6px;
}
CSS Classes
| Class | Element | Description |
|---|---|---|
.vn-resize-box |
Container div | The outer resizable container |
.vn-resize-handle |
Handle div | The bottom-right drag handle |
.vn-resize-box--dragging |
Container div | Added during active drag |
Architecture
The component uses light DOM (no Shadow DOM) so that existing app and theme CSS continues to apply to slotted content. It auto-injects a single shared <link> stylesheet into <head> on first use.
Children may be added at any time: content present at connect is wrapped into the internal .vn-resize-box structural div, and children appended after connect are adopted into it automatically (frameworks like Angular attach the element to the document before rendering its children).
File Structure
vanilla-resize-box/
├── vanilla-resize-box.js # Web component definition
├── vanilla-resize-box.css # Base structural styles
├── vanilla-resize-box.d.ts # Hand-authored TypeScript declarations
└── README.md # This file
Browser Support
Works in all modern browsers that support Custom Elements v1 (Chrome, Firefox, Safari, Edge).
Changelog
Version 1.4.0
Keyboard resizing removed — the handle is pointer-only.
Breaking:
keyboard-stepandkeyboard-step-largeare gone, along with the arrow key /Shift+arrow /Home/Endresize path added in 1.3.0. The handle no longer listens forkeydown, and the component observes neither attribute. Hosts that set either attribute can drop it — it is now inert. A host needing a keyboard-operable size drivesresize()from its own control, which firesvn-resize-endexactly as a drag does.- The handle is no longer focusable. It keeps
role="separator"and the accessible name derived from the host'saria-label, so assistive technology still announces the resizable boundary, but it carries notabindexand the stylesheet ships no.vn-resize-handle:focus-visiblerule. The handle is the box's last child, so this also removes a tab stop that used to sit between the page and the box's own content. vn-resize-start/vn-resize-move/vn-resize-endnow fire for drags andresize()only. The 300 ms keypress-burst window that grouped a run of arrow presses into one interaction is gone with the keys that needed it; the event contract for a drag is unchanged.
Version 1.3.1
- Maintenance (
vanilla-resize-box): documentation-only change. The five documents indocs/vanilla-resize-box/are nowNN--prefixed in reading order —00-index.md,01-integration-guide.md,02-web-component-implementation.md,03-drag-resize-implementation.md,04-css-architecture.md— so the folder lists the host-facing guide first and styling last;00-index.md's Documents table was reordered to match. No component source, public API, or styling changed.
Version 1.3.0
Pointer Events, keyboard accessibility and RTL, plus a batch of correctness fixes.
New:
- Keyboard resizing. The drag handle is now a focusable control
(
role="separator",tabindex="0") instead of anaria-hiddendiv. Arrow keys resize bykeyboard-step(new attribute, default10),Shift+arrow bykeyboard-step-large(new attribute, default50),Homeshrinks to the minimum andEndgrows to the ceiling a drag would enforce. A run of keypresses is one interaction:vn-resize-starton the first key, onevn-resize-moveper step,vn-resize-end300ms after the last. The handle takes its accessible name from the host'saria-label("… — resize handle"), so hosts that already labelled the box need no markup change — their label now lands on an element that has a role.:focus-visibledraws a focus ring. - RTL support. Under
dir="rtl"the handle moves to the bottom-left corner (logical insets), the horizontal drag and arrow-key deltas invert, the cursor/radius/triangle mirror, and the parent-relative ceiling is measured from the parent's right edge. Vertical writing modes are not supported. - Pointer Events. The mouse + touch dual path, the document-level move/up
listeners and the
window.blursafety release are replaced by a singlepointerdown+setPointerCapture()path with all listeners on the handle. Alostpointercapturebackstop ends the drag when the capture goes away for any reasonpointercanceldoes not cover — the window losing focus mid-drag is the casewindow.blurused to catch. Pen input works for free..vn-resize-handlegainstouch-action: none, which the model requires. - Six reflecting properties —
width,height,maxWidth,maxHeight(raw attribute strings) andminWidth,minHeight(the px bound actually enforced). They were declared invanilla-resize-box.d.tssince 1.1.0 with nothing behind them.
Fixed:
- A host that styled
<vn-resize-box>grew the box on every drag. The element had nobox-sizing, so the border-box width the drag measures was written back as a content-box width and the host's padding/border was added twice per drag (+60px on a zero-delta drag of apadding: 10px; border: 5pxbox). The element is nowborder-box. Note: a host that visually compensated for the inflation will shift. - Any attribute change discarded the size the user had just dragged to.
attributeChangedCallbackre-ran the whole initial pass; it now routes each attribute to the narrowest update, and a reconnect no longer overwrites a dragged size either. - Removing a sizing attribute had no effect — the inline style it wrote stayed. Removal now clears it and hands the dimension back to CSS.
- A cancelled touch gesture left the drag stuck.
touchcancelwas never handled, sodocument.bodykeptuser-select: noneand the--draggingclass stayed on, page-wide.pointercancelnow ends the drag like a release. - A right- or middle-click on the handle started a drag and suppressed the platform context menu. Non-primary buttons are ignored.
min-width="50%"was silently read as50px. Themin-*attributes now apply the same unit discipline asmax-*: a relative value is rejected and the default applies.- A second pointer landing on the handle can no longer hijack or end a drag in flight.
Changed (resize()):
- It now sets
flex: 0 0 auto, so a programmatic resize renders inside a flex container instead of being silently overridden. - It now fires
vn-resize-end(only that one), so hosts persisting on that event see programmatic changes. Avoiding a feedback loop is the host's job. - It still does not apply the parent-relative ceiling a drag enforces — deliberate, and now documented.
vn-resize-startcarries{ width, height }like its two siblings (.d.tschanged fromCustomEvent<undefined>).
Docs:
- Corrected four places where
docs/vanilla-resize-box/contradicted the code: the performance note denied the rAF coalescing that the same document describes; the DOM-structure note named the inner.vn-resize-boxdiv as the element that receives the sizing inline styles (it is the custom element); all three attribute tables typedwidth/height/max-*as pixel numbers when any CSS length is accepted; and this README's file list omitted the shippedvanilla-resize-box.d.ts. - Documented behaviour that was previously implicit: the single-content-child
contract, the 12px parent-bound margin and its rationale, and the
drag-versus-
resize()asymmetry. - Every sample hosting a
<vn-resize-box>gained a keyboard-resize section, andgithub-repos-react's JSX typings gained the twokeyboard-step*attributes.
Version 1.2.3
- Maintenance (
vanilla-resize-box): updated the changelog's 1.2.1 entry to match the sample apps' new names, as part of a repo-wide rename of every sample folder fromsample-frontend-Nto a descriptive<domain>-<framework>name. No code or behaviour change.
Version 1.2.2
- Fixed: the bottom-right resize handle was effectively unclickable when a
content child rendered positioned, z-indexed elements in the same corner
(e.g.
vanilla-grid's custom scrollbar tracks atz-index: 10beat the handle'sz-index: 1in hit-testing). Content children are now stacking- isolated (isolation: isolateon.vn-resize-box > :not(.vn-resize-handle)), so the handle always wins the corner. The handle hotspot also grew from 20×20px to 24×24px (the triangle visual scales with it). CSS-only change — no API, attribute, or event changes.
Version 1.2.1
- Maintenance (
vanilla-grid,vanilla-grid-toolbar,vanilla-resize-box,people-cities-jsthroughnorthwind-orders-angular,build.js,docs/,tests/): repo-wide folder restructure — the threevanilla-*component folders moved undersrc/, andpeople-cities-jsthroughnorthwind-orders-angularmoved undersamples/. All internal script paths, test references, and documentation links were updated accordingly. No code or public API change.
Version 1.2.0
- Adopts children appended after connect: previously only children
present at connect time were wrapped into the internal
.vn-resize-boxstructural div; aMutationObservernow moves any direct child added later into that box automatically (keeping the drag handle last). Needed for frameworks — e.g. Angular — that attach<vn-resize-box>to the document before rendering its children.
Version 1.1.2
- Corrected the
_resolveMaxJSDoc (comment only — no behavior change): an explicitpxvalue — not only a bare number — yields a numeric drag clamp, while percentages / other relative units and an absent attribute returnInfinity.
Version 1.1.1
- Fixed: removing a
<vn-resize-box>element mid-drag no longer leavesdocument.body.style.userSelectand the--draggingclass stuck page-wide —disconnectedCallbacknow runs the same end-of-drag cleanup as a normal pointer-up (_endDrag()). vn-resize-movedispatch and the corresponding style writes are now coalesced to at most one per animation frame instead of once per raw pointer/touch move event, reducing listener churn (e.g. grids re-layouting on resize) under high-rate mice.
Version 1.1.0
- Added hand-authored TypeScript declarations (
vanilla-resize-box.d.ts) covering the element's attributes,currentWidth/currentHeightproperties, theresize()method, and thevn-resize-*events. - Maintenance (generic tuning & fixing): minor
vanilla-resize-box.jsrenaming/refactor and avanilla-resize-box.csscleanup carried in alongside the grid refactors.
Version 1.0.0
- Initial release of the
<vn-resize-box>custom element: drag-to-resize container using light DOM. - Constraint attributes:
width,height,min-width,min-height(default100),max-width,max-height, with clamping to parent bounds when max values are omitted. - Read-only
currentWidth/currentHeightproperties and a programmaticresize(width?, height?)method that clamps to the configured min/max. - Drag lifecycle events:
vn-resize-start,vn-resize-move({ width, height }per frame), andvn-resize-end({ width, height }). - Tuning & fixing pass over the initial drag/clamp behaviour and base stylesheet.
License
MIT