A paper flower being assembled layer by layer, with a gray-blue British shorthair cat watching from a sunlit windowsill

Elena: Progressive Web Components That Lead With HTML

The Gray Cat
The Gray Cat
0 views

If you have ever shipped a web component library, you know the awkward moment: the page loads, the markup is there, and then everything sits blank for a beat while the JavaScript boots, registers custom elements, and finally paints. Elena is built to erase that beat. It is a small library for building what its creator calls Progressive Web Components — components that render their HTML and CSS first, then progressively enhance with JavaScript for interactivity. The browser shows real content immediately, and the script catches up afterward.

Elena is not a React library, and that is precisely the point. It produces standard custom elements that drop cleanly into React, Next.js, Vue, Angular, or a bare HTML file. Built by Ariel Salminen after roughly a decade of building enterprise design systems with web components, it is aimed squarely at teams who need their components to behave identically across every framework — or none at all. The package is @elenajs/core, it weighs about 2.9 kB minified and compressed, and it carries zero runtime dependencies.

Why Lead With HTML

The conventional web component story is JavaScript-first: nothing renders until the custom element is defined and upgraded. That model creates a familiar list of headaches, and Elena was designed to dodge each one.

  • Accessibility friction. Heavy Shadow DOM usage can hide content from screen readers and complicate semantics. Elena uses Light DOM by default, so the content lives in the regular document tree where assistive technology and the rest of the platform can reach it.
  • Layout shift and flashes. When markup waits on JS, you get cumulative layout shift and flashes of unstyled or invisible content. Rendering HTML and CSS up front keeps the page stable.
  • SSR and React Server Components. Server-rendering Shadow-DOM-centric components is notoriously fiddly. Elena is SSR-friendly out of the box, with optional server utilities in @elenajs/ssr.
  • Third-party tooling. Analytics scripts, test runners, and other tools that read the DOM often cannot see into closed shadow trees. Light DOM keeps your components legible to them.

The throughline is honesty about what the web platform already does well. Elena adds a thin reactive layer — props, batched state updates, scoped styles, cross-framework attribute and event plumbing — on top of native Custom Elements, and otherwise gets out of the way.

What You Get In The Box

A quick tour of Elena's headline features:

  • Extremely lightweight at roughly 2.9 kB minified and compressed.
  • Progressive enhancement as the default rendering model: HTML and CSS first, hydration after.
  • Accessible by default thanks to a semantic, Light-DOM foundation with no Shadow DOM barriers.
  • Standards based, built entirely on native Custom Elements and web platform APIs.
  • Reactive updates where prop and state changes trigger efficient, batched re-renders.
  • Scoped styles handled at build time, no runtime CSS-in-JS gymnastics.
  • Zero runtime dependencies and zero lock-in — works with every major framework or none.

Installation

Install the core package from npm:

npm install @elenajs/core

Or with yarn:

yarn add @elenajs/core

The package ships TypeScript declarations, so editor autocompletion and type checking work without any extra setup.

Defining Your First Component

Elena is delivered as a mixin. You call Elena(HTMLElement), which returns a class you then extend. That keeps your component a genuine custom element — it just gains reactive props, batched rendering, and lifecycle conveniences along the way.

import { Elena } from "@elenajs/core";

class Stack extends Elena(HTMLElement) {
  static tagName = "my-stack";
  static props = ["direction"];

  direction = "column";
}

Stack.define();

A few things are happening here. static tagName declares the custom element's tag, so this becomes <my-stack> in markup. static props lists the reactive properties that stay synced with HTML attributes, meaning <my-stack direction="row"> and element.direction = "row" are two views of the same value. The instance field direction = "column" supplies the default. Finally, Stack.define() registers the element with the browser's custom element registry.

Because this is a real custom element, you can use it anywhere HTML is accepted:

<my-stack direction="row">
  <button>Save</button>
  <button>Cancel</button>
</my-stack>

If the JavaScript has not loaded yet, the buttons are still there, still clickable, still accessible. When Elena upgrades the element, it layers on whatever interactive behavior you defined — that is the progressive part.

Rendering and Reacting to State

Components become interesting when they render their own structure and respond to change. Elena gives you a render() method and treats your declared props and instance state as reactive: mutate them, and Elena schedules an efficient batched re-render rather than thrashing the DOM on every assignment.

import { Elena } from "@elenajs/core";

class Counter extends Elena(HTMLElement) {
  static tagName = "my-counter";
  static props = ["label"];

  label = "Clicks";
  count = 0;

  render() {
    return `
      <button type="button">
        ${this.label}: ${this.count}
      </button>
    `;
  }

  connectedCallback() {
    super.connectedCallback();
    this.addEventListener("click", () => {
      this.count += 1;
    });
  }
}

Counter.define();

Incrementing this.count inside the click handler is enough to trigger a re-render. There is no separate setState call and no virtual DOM diffing layer to learn — you mutate the field, Elena batches the update, and the markup reflects the new value. Because the component leans on standard lifecycle callbacks like connectedCallback, anyone who has touched vanilla custom elements will feel immediately at home, and calling super.connectedCallback() keeps Elena's own setup intact.

Working Alongside React and Friends

Elena's biggest payoff shows up when the same component has to live in multiple frameworks. Because Elena emits plain custom elements, a React app consumes one exactly like any DOM element.

import "@elenajs/core";
import "./components/my-counter";

export function Toolbar() {
  return (
    <div>
      <my-counter label="Views" />
    </div>
  );
}

The element renders its HTML and CSS before React even hydrates the surrounding tree, which sidesteps a whole category of layout-shift and Server Component friction. The same <my-counter> works untouched in a Vue template, an Angular component, or a static HTML page. You write the component once and let the platform carry it everywhere — which is exactly what a cross-framework design system needs.

Scoped Styles and Slots

Styling encapsulation usually drags Shadow DOM along with it. Elena instead handles scoping at build time through its bundler tooling, so you keep Light DOM accessibility while still preventing your styles from leaking. Composition uses native primitives you already know — <template> and <slot> — rather than a bespoke abstraction.

import { Elena } from "@elenajs/core";

class Card extends Elena(HTMLElement) {
  static tagName = "my-card";

  render() {
    return `
      <article class="card">
        <header><slot name="title"></slot></header>
        <div class="body"><slot></slot></div>
      </article>
    `;
  }
}

Card.define();
<my-card>
  <span slot="title">Quarterly report</span>
  <p>Revenue is up across every region.</p>
</my-card>

Slotted content lives in the regular document, so screen readers, analytics, and your test suite all see it without special handling. For teams that genuinely want Shadow DOM, Elena supports it as an opt-in, including Declarative Shadow DOM for SSR scenarios — but it is a choice you make deliberately, not a default you inherit.

The Wider Toolkit

@elenajs/core is the runtime, but it sits inside a small monorepo under the @elenajs scope. The stable companions include @elenajs/bundler for building component libraries with scoped CSS, @elenajs/cli for scaffolding new components, and @elenajs/components as a set of demo elements to learn from. Two pieces — @elenajs/ssr for server-side rendering helpers and @elenajs/mcp — are still marked experimental, which is worth keeping in mind if your production setup leans on them.

That maturity caveat is fair to state plainly. Elena reached its 1.0.0 release in April 2026 after a fast pre-1.0 iteration cycle, and its adoption is still modest. It is a young project. Against established options like Lit, Stencil, or Microsoft's FAST, Elena trades community size for a sharply different philosophy: Light DOM first, progressive enhancement, accessibility and SSR as priorities, all in a footprint smaller than its peers.

Closing Thoughts

Elena is a focused answer to a real problem. If you build web components and have been frustrated by blank flashes, hidden content, layout shift, or the friction of making Shadow DOM cooperate with SSR and third-party tooling, its progressive model is a genuinely refreshing reframe — show the user real HTML now, enhance it with JavaScript when you can. For design system teams who need one set of components to behave the same in React, Next.js, Vue, Angular, and plain HTML, that combination of tiny size, zero dependencies, and Light-DOM accessibility is hard to ignore.

It is new, and a couple of its packages are still finding their footing, so go in with appropriate expectations. But the core idea is sound and the core library is stable. If progressive enhancement matters to you, @elenajs/core is well worth a look.