A blazingly fast, lightweight virtual DOM implementation powered by Bun. Drop-in replacement for happy-dom and jsdom in testing environments.
- Comprehensive DOM - Full DOM manipulation, CSS selectors, XPath, events with bubbling/capturing
- Network APIs - Fetch, XMLHttpRequest, WebSocket, Server-Sent Events, BroadcastChannel, MessageChannel, request interception
- Browser APIs - Storage, Timers, Canvas 2D, Observers (Mutation/Intersection/Resize/Performance), Clipboard, History, Cookies, File API, IndexedDB, Web Storage
- Web Components - Custom Elements and Shadow DOM
- Framework Agnostic - Works with Bun, Vitest, or any testing framework
- jsdom-compatible - Real
JSDOMclass with.serialize(),.reconfigure(),.fromURL(),.fromFile(),.fragment(),VirtualConsole,CookieJar,ResourceLoader - happy-dom-compatible - Drop-in for
GlobalRegistrator,window.happyDOMAPI, virtual consoles - Screenshot - Pure-JS PNG/JPEG/WebP rendering + optional
Bun.WebViewreal-browser screenshots
bun add -d very-happy-domOr with npm/pnpm:
npm install --save-dev very-happy-dom
pnpm add -D very-happy-domimport { Window } from 'very-happy-dom'
const window = new Window()
const document = window.document
document.body.innerHTML = '<h1>Hello World</h1>'
const heading = document.querySelector('h1')
console.log(heading?.textContent) // "Hello World"The simplest way — create a Window per test:
import { describe, expect, test } from 'bun:test'
import { Window } from 'very-happy-dom'
describe('MyComponent', () => {
test('renders correctly', () => {
const window = new Window()
const document = window.document
document.body.innerHTML = '<div class="container">Test</div>'
const element = document.querySelector('.container')
expect(element?.textContent).toBe('Test')
})
})For Testing Library, React, and other frameworks that expect browser globals (document, window, etc.), either use the one-line preload subpath or call GlobalRegistrator manually.
Easiest — the /register subpath:
# bunfig.toml
[test]
preload = ["very-happy-dom/register"]You can override the URL with VERY_HAPPY_DOM_URL or HAPPY_DOM_URL env vars.
Manual — drop-in for @happy-dom/global-registrator:
// happy-dom.ts (preload script)
import { GlobalRegistrator } from 'very-happy-dom'
GlobalRegistrator.register()# bunfig.toml
[test]
preload = ["./happy-dom.ts"]That's it. All browser globals are now available in your tests:
import { test, expect } from 'bun:test'
import { screen, render } from '@testing-library/react'
import { MyComponent } from './MyComponent'
test('renders correctly', () => {
render(<MyComponent />)
expect(screen.getByTestId('my-component')).toBeInTheDocument()
})One-line change — the GlobalRegistrator API is the same:
-import { GlobalRegistrator } from '@happy-dom/global-registrator'
+import { GlobalRegistrator } from 'very-happy-dom'
GlobalRegistrator.register()import { Browser } from 'very-happy-dom'
const browser = new Browser()
const context = browser.createContext()
const page = context.newPage()
page.goto('https://example.com')import { Window } from 'very-happy-dom'
const window = new Window()
window.interceptor.addInterceptor({
onRequest: (request) => {
if (request.url.includes('/api/')) {
return new Response(JSON.stringify({ mocked: true }))
}
return request
}
})import { Window } from 'very-happy-dom'
const window = new Window({
url: 'https://example.com',
width: 1920,
height: 1080,
settings: {
navigator: {
userAgent: 'MyCustomUserAgent/1.0'
},
device: {
prefersColorScheme: 'dark'
}
}
})const window = new Window()
const document = window.document
const button = document.createElement('button')
let clicked = false
button.addEventListener('click', () => {
clicked = true
})
button.click()
console.log(clicked) // trueconst window = new Window()
// localStorage
window.localStorage.setItem('key', 'value')
console.log(window.localStorage.getItem('key')) // "value"
// sessionStorage
window.sessionStorage.setItem('session', 'data')const window = new Window()
const document = window.document
// MutationObserver
const observer = new window.MutationObserver((mutations) => {
mutations.forEach((mutation) => {
console.log('DOM changed:', mutation.type)
})
})
observer.observe(document.body, {
childList: true,
attributes: true,
subtree: true
})
// IntersectionObserver
const io = new window.IntersectionObserver((entries) => {
entries.forEach((entry) => {
console.log('Visibility changed:', entry.isIntersecting)
})
})
// ResizeObserver
const ro = new window.ResizeObserver((entries) => {
entries.forEach((entry) => {
console.log('Size changed:', entry.contentRect)
})
})const window = new Window()
const document = window.document
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
ctx.fillStyle = 'red'
ctx.fillRect(0, 0, 100, 100)
ctx.strokeStyle = 'blue'
ctx.strokeRect(10, 10, 80, 80)
// Export canvas data
const dataUrl = canvas.toDataURL()
const blob = await canvas.toBlob()| Operation | very-happy-dom | happy-dom | jsdom | Faster by |
|---|---|---|---|---|
| Window Creation | 4.08 µs | 92.83 µs | 1.22 ms | 22.7x |
| createElement | 463.02 ns | 2.62 µs | 4.67 µs | 5.7x |
| createElement + setAttribute | 748.35 ns | 15.41 µs | 6.62 µs | 8.8x |
| innerHTML (medium) | 41.61 µs | 47.48 µs | 168.98 µs | 1.1x |
| innerHTML (large, 200 nodes) | 1.92 ms | 3.72 ms | 6.27 ms | 1.9x |
| querySelector by ID | 81.03 ns | n/a | 2.76 µs | 34.1x |
| querySelector by class | 242.20 ns | n/a | 3.52 µs | 14.5x |
| querySelectorAll (200 matches) | 66.44 µs | n/a | 66.55 µs | ~1x |
| querySelectorAll + iteration | 76.44 µs | n/a | 170.37 µs | 2.2x |
| appendChild (single) | 1.70 µs | 4.58 µs | 6.14 µs | 2.7x |
| appendChild (1000 children) | 852.90 µs | 1.54 ms | 4.45 ms | 1.8x |
| setAttribute | 124.66 ns | 2.64 µs | 1.43 µs | 11.5x |
| getAttribute | 2.18 ns | 28.85 ns | 194.98 ns | 13.2x |
| classList.add | 3.97 µs | 6.88 µs | 4.87 µs | 1.2x |
| addEventListener + dispatch | 2.67 µs | 5.43 µs | 3.65 µs | 1.4x |
| textContent set | 470.48 ns | 1.72 µs | 4.67 µs | 3.7x |
| cloneNode (deep) | 6.16 µs | 21.59 µs | 15.55 µs | 2.5x |
| style.setProperty | 490.62 ns | 4.20 µs | 4.64 µs | 8.6x |
| Build data table (50x5) | 519.30 µs | 754.42 µs | 2.89 ms | 1.5x |
| Update list items (100) | 454.98 µs | n/a | 2.41 ms | 5.3x |
Note: "Faster by" compares very-happy-dom to the next-fastest result. Benchmarks run on Apple M3 Pro with Bun 1.3.11. Run them yourself:
bun run benchOne-line change — the API is compatible:
// Before
import { Window } from 'happy-dom'
// After
import { Window } from 'very-happy-dom'very-happy-dom ships a jsdom-compatible JSDOM class — the exact idiom works unchanged:
// Before (jsdom)
import { JSDOM } from 'jsdom'
// After (very-happy-dom) — one-line change
import { JSDOM } from 'very-happy-dom'
// or: import { JSDOM } from 'very-happy-dom/jsdom'
const dom = new JSDOM('<!DOCTYPE html><html><body><h1>Hi</h1></body></html>', {
url: 'https://example.com/',
runScripts: 'dangerously',
})
const { window } = dom
const { document } = window
dom.serialize() // full HTML string
dom.reconfigure({ url: '...' }) // change URL mid-test
JSDOM.fragment('<p>x</p>') // DocumentFragment
await JSDOM.fromFile('./page.html') // parse a local file
await JSDOM.fromURL('https://x.test') // fetch + parseFull surface: JSDOM, VirtualConsole, CookieJar, ResourceLoader — each with the same method names and overloads as jsdom.
See the drop-in compatibility guide for the complete migration reference.
- Window - Main window/global object with all browser APIs
- Document - DOM document with querySelector, createElement, etc.
- Element - DOM elements with full manipulation API
- Browser - Browser instance for advanced scenarios
- BrowserContext - Isolated browser contexts
- BrowserPage - Individual pages with navigation
Click to expand full API list
- Document, Element, TextNode, CommentNode, DocumentFragment
- Attributes, ClassList (iterable,
toggle(x, force),replace), Style, dataset innerHTML,outerHTML(getter + setter),insertAdjacentHTMLdocument.readyStatelifecycle (loading → interactive → complete) +DOMContentLoaded+loaddocument.cookieread/write,document.titlelive getter/setter,document.parentWindowalias
querySelector/querySelectorAll,matches,closestgetElementById/getElementsByClassName/getElementsByTagName/getElementsByTagNameNS- Full CSS selectors — combinators, attribute selectors (quoted + unquoted),
:not,:is,:where,:has,:nth-child, etc. - XPath (
document.evaluate,XPathEvaluator,XPathResult)
addEventListener/removeEventListenerwith{ once, passive, capture, signal }- Bubbling, capturing,
stopPropagation,stopImmediatePropagation - Full event classes:
Event,CustomEvent,MouseEvent,KeyboardEvent,PointerEvent,TouchEvent,WheelEvent,InputEvent,FocusEvent,SubmitEvent,DragEvent,ClipboardEvent,AnimationEvent,TransitionEvent,CompositionEvent,ProgressEvent,MessageEvent,CloseEvent,StorageEvent,PopStateEvent,HashChangeEvent,ErrorEvent,MediaQueryListEvent - Focus model:
focus/blur+ bubblingfocusin/focusout,document.activeElementtracking
fetch(),Request,Response,Headers,FormData(withnew FormData(form)populating from a form element)XMLHttpRequestwith full event handlingWebSocket(backed by Bun's native)EventSource(Server-Sent Events) — realfetch+ stream parsingBroadcastChannel,MessageChannel,MessagePortnavigator.sendBeacon()- Request Interception via
RequestInterceptor
localStorage,sessionStorage(isolated per instance)document.cookie→CookieContainerindexedDB— in-memoryIDBFactory/IDBDatabase/IDBObjectStore/IDBTransactionnavigator.storagewithestimate()/persist()/persisted()
setTimeout/clearTimeout,setInterval/clearIntervalrequestAnimationFrame/cancelAnimationFramerequestIdleCallback/cancelIdleCallbackqueueMicrotask
MutationObserver(childList, attributes, characterData, subtree, oldValue, filters)IntersectionObserver,ResizeObserverPerformanceObserverwithsupportedEntryTypes
HTMLCanvasElement.getContext('2d'),toDataURL/toBlobCanvasRenderingContext2Dwith full drawing surface- Pure-JS rendering pipeline:
ScreenshotCapture,captureHtml,captureUrl,compareImages, WebP/PNG encoders - Optional
Bun.WebView-backed real-browser screenshots (useWebView: true)
customElements.define/.get/.whenDefined, lifecycle callbacks (connected/disconnected/adopted/attributeChanged)- Shadow DOM (open + closed), event retargeting, slot support
CSSStyleSheetwithreplaceSync()parsing declarations intocssRulesdocument.adoptedStyleSheetsCSS.supports(),CSS.escape()getComputedStyle()with per-tagdisplaydefaults + common computed fallbacks
- Constraint validation:
checkValidity,reportValidity,setCustomValidity,validity,validationMessage,willValidate form.submit(),form.requestSubmit(submitter?),form.reset()new FormData(form)populates from disabled/checkbox/radio/select/file fields
HTMLMediaElement.play()→ Promise,pause(),load(),canPlayType()currentTime,duration,paused,ended,volume,muted,playbackRate,readyState,networkState- Dispatches
play,playing,pause,timeupdate,volumechange,ratechange,loadstart,loadedmetadata HTMLImageElement.decode(),Element.animate()
JSDOMclass with.window,.serialize(),.reconfigure(),.nodeLocation(), staticfragment/fromURL/fromFileVirtualConsolewithon/off/emit/sendTo,jsdomErrorfor uncaught exceptionsCookieJar(tough-cookie-style callback + promise API)ResourceLoader(subclassable fetch interceptor)runScripts: 'outside-only' | 'dangerously'— opt-in inline-script execution
Windowwithurl,width,height,console,settingsoptionswindow.happyDOMwithclose/abort/waitUntilComplete/setURL/setViewportGlobalRegistrator.register/unregister/registersubpath for one-line preload
- Performance API +
PerformanceObserver navigator.permissions.query(),navigator.sendBeacon()- Clipboard API +
ClipboardItem - Geolocation API, Notification API
- History API (
pushState/replaceState/back/forward/go/state) - Location API (full
href/protocol/host/hostname/port/pathname/search/hash/origin+ assignment setters) - File API (
File,FileReader,FileList,Blob) URL,URLSearchParams,AbortController,AbortSignalTextEncoder,TextDecoder,ReadableStream,WritableStream,TransformStreamDOMParser,XMLSerializer,Range,Selection,NodeIterator,TreeWalker
bun test # Run all tests
bun test --coverage # Run with coverageWe welcome contributions! Please see CONTRIBUTING for details.
Please see our releases page for more information on what has changed recently.
For help, discussion about best practices, or any other conversation that would benefit from being searchable:
For casual chit-chat with others using this package:
Join the Stacks Discord Server
Very Happy DOM is free and open-source, but we'd love to receive a postcard from you! Send one to:
Stacks.js, 12665 Village Ln #2306, Playa Vista, CA 90094, United States 🌎
We showcase postcards from around the world on our website!
We would like to extend our thanks to the following sponsors for funding Stacks development. If you are interested in becoming a sponsor, please reach out to us.
The MIT License (MIT). Please see LICENSE for more information.
Made with 💙
