EmilyJS

A lightweight JavaScript layer for server-rendered templates. Add interactive behaviour with HTML attributes — no build step, no framework required.

What EmilyJS is

EmilyJS adds reactivity to existing HTML without a JavaScript framework. You define local state in a emily-state attribute, then use simple directive attributes to show or hide elements, update text, bind form values, and respond to clicks. The rest is handled automatically.

It is designed for the same environments emilyCSS targets: CMS templates, Drupal themes, static HTML, and any server-rendered page where loading React or Vue would be disproportionate.

Use it when:

  • You need a toggle, accordion, tab switcher, or modal on a server-rendered page.
  • Installing a full JavaScript framework would be more work than it solves.
  • You need accessible ARIA attributes managed automatically.
  • You want form binding without writing event listeners by hand.

Install

Drop the script into your page. EmilyJS boots automatically when the document loads and mounts all components it finds.

<!-- From a local copy or CDN (when available) -->
<script src="/emily.js"></script>

<!-- Or from your project if installed via npm -->
<script src="node_modules/emilyjs/emily.js"></script>

EmilyJS uses no eval() and no new Function(). It is safe to use under strict Content Security Policies.

Quick example — toggle

Wrap any HTML in a container with emily-state. Everything inside that container shares the state object you define. The button here toggles open between true and false; the panel shows or hides accordingly. EmilyJS also sets aria-expanded and aria-controls automatically.

<div emily-state='{"open": false}'>
  <button emily-click="open = !open">
    Toggle panel
  </button>

  <div emily-show="open">
    <p>This panel is controlled by state.</p>
  </div>
</div>

<!-- EmilyJS automatically adds:
  aria-expanded="false" on the button (updated on every click)
  aria-controls="[generated-id]" linking button to panel -->

How state works

Each emily-state element is an independent component. It holds its own state — sibling and nested components do not share or overwrite each other's values. You can have as many components on a page as you need.

<!-- These two components have separate state -->

<div emily-state='{"open": false}'>
  <button emily-click="open = !open">Section A</button>
  <div emily-show="open">Content A</div>
</div>

<div emily-state='{"open": false}'>
  <button emily-click="open = !open">Section B</button>
  <div emily-show="open">Content B</div>
</div>

<!-- Opening Section A does not affect Section B -->

Directive reference

emily-state

Defines a component boundary and its starting state. The value must be valid JSON. All other directives inside this element read from and write to this state object.

<!-- Single key -->
<div emily-state='{"open": false}'>
  …
</div>

<!-- Multiple keys -->
<div emily-state='{"tab": 1, "count": 0, "name": ""}'>
  …
</div>
emily-click

Runs an expression when the element is clicked. Supported expressions: toggle a boolean, assign a value, increment or decrement a number. If you put this on a div or span instead of a button, EmilyJS automatically adds role="button" and tabindex="0" so keyboard users can reach it.

<!-- Toggle a boolean -->
<button emily-click="open = !open">Toggle</button>

<!-- Set to a specific value -->
<button emily-click="tab = 2">Go to tab 2</button>

<!-- Increment / decrement -->
<button emily-click="count++">Add</button>
<button emily-click="count--">Remove</button>

<!-- Set to a string -->
<button emily-click="mode = 'dark'">Dark mode</button>
emily-show

Shows or hides an element based on a state expression. When hidden, EmilyJS sets the hidden attribute — the element stays in the DOM and remains accessible to screen readers who explicitly navigate hidden content.

<!-- Show when key is true -->
<div emily-show="open">Visible when open is true</div>

<!-- Show when key is false -->
<div emily-show="!open">Visible when open is false</div>

<!-- Show when key equals a value -->
<div emily-show="tab === 2">Only visible on tab 2</div>
emily-text

Sets the text content of an element to the value of a state key. Updates automatically whenever the state changes.

<div emily-state='{"count": 0}'>
  <p>
    Items selected: <span emily-text="count"></span>
  </p>
  <button emily-click="count++">Add item</button>
</div>
emily-bind

Sets one or more HTML attributes from state values. Pass a JSON object where each key is the attribute name and each value is the state key to read from. Useful for dynamic aria-label, data-*, or href values.

<div emily-state='{"label": "Close menu", "href": "/home"}'>

  <!-- Dynamic aria-label -->
  <button emily-bind='{"aria-label": "label"}'>×</button>

  <!-- Dynamic href -->
  <a emily-bind='{"href": "href"}'>Go home</a>

</div>
emily-modelemily-model.lazy

Two-way binding for inputs, selects, and textareas. The state key updates as the user types. Use emily-model.lazy to defer the update until the field loses focus (on blur) — useful for validation patterns that should not fire on every keystroke. Checkboxes bind to a boolean. Form reset restores all bound fields to their original mount values.

<div emily-state='{"email": "", "subscribed": false, "country": ""}'>

  <!-- Text input — updates state on every keystroke -->
  <input type="email" emily-model="email" />

  <!-- Text input — updates state only when the field loses focus -->
  <input type="text" emily-model.lazy="email" />

  <!-- Checkbox — binds to a boolean -->
  <input type="checkbox" emily-model="subscribed" />

  <!-- Select -->
  <select emily-model="country">
    <option value="gb">United Kingdom</option>
    <option value="us">United States</option>
  </select>

  <!-- Form reset restores all fields to their original values -->
  <button type="reset">Clear form</button>

</div>
emily-html

Sets the innerHTML of an element to the value of a state key. Use this when you need to inject HTML — for example a rich text value returned from an API. Only use it with content you trust; unlike emily-text, the value is not escaped.

<div emily-state='{"bio": "<strong>Hello</strong> world"}'>
  <!-- Renders innerHTML — only use with trusted content -->
  <div emily-html="bio"></div>
</div>
emily-disabledAccessibility

Syncs both the disabled attribute and aria-disabled from a state expression. When the expression is truthy, the element is disabled for both native browser behaviour and assistive technology — no need to manage them separately.

<div emily-state='{"saving": false}'>

  <button emily-click="saving = true" emily-disabled="saving">
    Save
  </button>

  <!-- When saving is true:
    disabled attribute is set (native browser behaviour)
    aria-disabled="true" is set (assistive technology) -->

</div>
emily-on:event

Binds any DOM event to an expression. More flexible than emily-click, which is a shorthand for emily-on:click. Use emily-on: when you need to respond to events other than clicks — focus, blur, change, input, keydown, and so on.

<div emily-state='{"dirty": false, "focused": false}'>

  <!-- Respond to any DOM event -->
  <input
    emily-on:focus="focused = true"
    emily-on:blur="focused = false"
    emily-on:input="dirty = true"
  />

  <p emily-show="dirty">You have unsaved changes.</p>

</div>
emily-submit

Binds a form submit event and prevents the browser default. Put it on the <form> element along with a state expression to run when the form is submitted — useful for toggling a loading state or marking a form as submitted without a page reload.

<div emily-state='{"submitted": false}'>

  <!-- Prevents page reload, runs expression on submit -->
  <form emily-submit="submitted = true">
    <input type="email" name="email" />
    <button type="submit" emily-disabled="submitted">
      Subscribe
    </button>
  </form>

  <p emily-show="submitted">Thanks — you're on the list.</p>

</div>
emily-class

Toggles CSS classes on or off based on state. Pass a JSON object where each key is the class name to add and each value is the state expression that controls it. Static classes already on the element are not touched.

<div emily-state='{"open": false, "active": true}'>

  <!-- Adds 'menu-open' class when open is true.
       The existing 'menu base' classes are not removed. -->
  <nav class="menu base" emily-class='{"menu-open": "open"}'>
    …
  </nav>

</div>
emily-style

Sets inline styles or CSS custom properties from state values. Useful when a value needs to drive a visual property that has no fixed utility class — for example a dynamic width, a runtime theme colour, or a CSS variable used by emilyCSS component patterns.

<div emily-state='{"progress": 40, "themeColour": "#DB2777"}'>

  <!-- Sets a CSS custom property from state -->
  <div
    class="progress-bar"
    emily-style='{"--progress-width": "progress"}'
  ></div>

</div>

<!-- The CSS variable can then drive a width or other property:
  .progress-bar::after { width: var(--progress-width); } -->
emily-trapAccessibility

Traps keyboard focus inside a container while the trap expression is truthy. When the trap activates, focus moves to the first focusable element inside the container and Tab cycles through them in order. When the trap deactivates, focus returns automatically to the element that triggered it — typically the button that opened the dialog.

Use this for modals, drawers, and any overlay pattern where a keyboard user should not be able to Tab outside the open panel.

<div emily-state='{"dialogOpen": false}'>

  <!-- Button opens the dialog -->
  <button emily-click="dialogOpen = !dialogOpen">
    Open dialog
  </button>

  <!-- Focus is trapped inside this container while dialogOpen is true.
       When dialogOpen becomes false, focus returns to the button above. -->
  <div
    role="dialog"
    aria-modal="true"
    aria-labelledby="dialog-title"
    emily-show="dialogOpen"
    emily-trap="dialogOpen"
  >
    <h2 id="dialog-title">Confirm action</h2>
    <p>Are you sure you want to continue?</p>
    <button emily-click="dialogOpen = false">Confirm</button>
    <button emily-click="dialogOpen = false">Cancel</button>
  </div>

</div>
emily-announceAccessibility

Announces a state change to screen readers without moving focus. When the named state key changes, EmilyJS injects the new value into a visually hidden aria-live region. Screen readers pick this up and read it aloud. Use it for status messages, search result counts, form validation feedback, and any change a sighted user would notice but a screen reader user might miss.

<div emily-state='{"status": "Ready", "count": 0}'>

  <button emily-click="count++">
    Add item
  </button>

  <!-- Announces the value of 'count' to screen readers whenever it changes.
       The element itself is visually present but the announcement
       goes into a hidden aria-live region. -->
  <span emily-announce="count" aria-hidden="true">
    <span emily-text="count"></span> items in basket
  </span>

</div>

Automatic accessibility

EmilyJS handles a set of accessibility patterns so you do not have to add them manually:

aria-expanded + aria-controls

When a click trigger toggles an emily-show target, EmilyJS sets aria-expanded on the trigger and aria-controls pointing at the panel. This tells screen readers whether the controlled section is open or closed.

role and tabindex on non-button triggers

If you put emily-click on a div or span, EmilyJS adds role="button" and tabindex="0" automatically. Enter and Space both trigger the action.

Focus trap with return

emily-trap keeps keyboard focus inside open overlays and returns it to the trigger when the overlay closes — the WCAG 2.1 AA pattern for modal dialogs.

Live region announcements

emily-announce creates one shared aria-live="polite" region for the page. State changes are announced to screen readers without interrupting what they are currently reading.

Working with dynamic content

If you inject HTML into the page after the initial load — via AJAX, fetch, or a CMS live preview — call emily.refresh() to mount any new components EmilyJS has not seen yet. Already-mounted components are not double-bound.

// After injecting new HTML into the page:
document.getElementById('container').innerHTML = fetchedHtml

// Tell EmilyJS to mount any new components
window.emily.refresh(document.getElementById('container'))

// Already-mounted components are skipped automatically

Expression syntax

EmilyJS uses a small safe expression parser instead of eval(). The supported operations cover most interactive UI patterns:

ExpressionWhat it doesExample
keyRead — truthy/falsy checkopen
!keyRead — negated check!open
key === valueRead — strict equalitytab === 2
key !== valueRead — strict inequalitytab !== 0
key = !keyWrite — toggle booleanopen = !open
key = valueWrite — assign valuemode = 'dark'
key++Write — increment numbercount++
key--Write — decrement numbercount--

EmilyJS v0.2.0

EmilyJS covers the most common interactive patterns but does not support loops, conditional rendering of lists, or server communication. For those use cases, a full JavaScript framework is the right choice. EmilyJS is intentionally narrow — it solves the gap between "pure HTML" and "full SPA" without adding framework overhead.

Next step

Pair EmilyJS with emilyCSS accessibility utilities for a complete accessible component pattern.