You don't always need node_modules, bundlers, a compiler, transpilers, a virtual DOM, hydration, reactivity, config files, or a dev server.
A zero-dependency, zero-build component boilerplate in vanilla JavaScript.
Classic scripts only — no bundler, no npm install, works when index.html
is opened straight from the filesystem (file://).
Open index.html in a browser. For auto-reload during development:
npx live-serverindex.html entry page; loads scripts in dependency order
jsconfig.json enables type checking + autocomplete in VS Code
src/
styles.css global styles (design tokens + page styles)
core.js the micro-framework: Component, mount(), $(), $$()
components/ one file per component
app.js root App component (header, hero, live example, footer)
counter.js minimal Counter component (template + script hook)
word-cycle.js animated word rotator (self-scheduling timer)
main.js entry point: mounts App into #app
Because these are classic scripts, load order in index.html matters:
core.js first, then component files, then main.js last.
Component — a class is nothing more than a static template (static,
trusted HTML only — dynamic values are set in script via textContent)
and a script(root) method:
class Greeting extends Component {
static template = `<p data-ref="text"></p>`
/** @param {string} [name] - Who to greet. Defaults to "world". */
constructor(name) {
super()
this.name = name
}
/** @param {DocumentFragment} root */
script(root) {
$(root, '[data-ref="text"]').textContent = `Hello, ${this.name ?? 'world'}`
}
}That's the whole contract — Component itself holds no other data or
methods. Any config a component needs (words to cycle through, a name to
greet) is just a field the subclass's own constructor assigns to this;
there's no built-in props object. There's also no state or re-render
mechanism — mutable data (a counter, a cycle index) lives in a plain closure
variable inside script, and updates happen by mutating the rendered DOM
directly in event handlers or timers, exactly as you'd write it with no
framework at all (see counter.js, word-cycle.js).
Composition — mount children inside script using placeholder elements:
script(root) {
mount($(root, '[data-ref="counter"]'), new Counter())
mount($(root, '[data-ref="cycle"]'), new WordCycle({ words: ['bundlers', 'hydration'] }))
}Helpers — mount(element, component) clones the component's template,
runs its script, appends the result into element, and returns the
component; $(root, selector) is querySelector that throws if the
element is missing instead of returning null; $$(root, selector) is
querySelectorAll that returns a plain array instead of a NodeList.
- Create
src/components/<name>.jswith aclass <Name> extends Component. - If it needs config, give it a constructor that assigns fields to
this(document params with@paramso call sites get autocomplete). - Add
<script src="./src/components/<name>.js"></script>toindex.htmlbeforemain.js.
jsconfig.json turns on checkJs, so VS Code type-checks the JSDoc
annotations and provides autocomplete (e.g. constructor config keys in
new WordCycle({ ... })). To check from the command line:
npx -p typescript tsc -p jsconfig.json- Every top-level
class/function/constis a global shared across all scripts — keep names unique. - Templates are parsed once per class and cached on the class itself; they
cannot interpolate per-instance values — set those in
scriptviatextContentinstead. - There's no re-render step:
scriptruns once per mount, so dynamic updates must mutate the DOM directly from event handlers/timers, keeping any mutable data in closure variables (ascounter.jsandword-cycle.jsdo). - There's no unmount/
destroy()either.mount()only ever appends — removing a component is a matter of holding onto a DOM reference (the host element, or a node returned fromscript) and calling.remove()on it yourself, same as with no framework. - The landing page displays
counter.js's source verbatim insideApp's template — when editing one, keep the other in sync. Backticks and${in that displayed snippet are escaped (\`,\${) because it lives inside a template literal.