← All posts

The landing page is almost done — now the small stuff. What do you build it with?

On almost any landing page, what remains after the mockup is the small stuff: a burger menu, an FAQ block, a monthly/annual pricing toggle, a reviews carousel, a contact form with its thank-you and error states, a cookie banner. Taken alone, each is a twenty-to-forty-minute job.

The logic in each of them is trivial. The time goes into state: the menu has open/closed, the form has sending/success/error, the banner has shown-before-or-not. In bare DOM code that state is never written down anywhere — it lives in the presence of a class, in a flag inside a closure, in a localStorage entry. With one interactive element that’s fine. With five or seven that start to overlap, keeping them consistent by hand is exactly the work that makes people reopen the script six months later with some caution.

Micra takes over that part. State is kept in one place — a plain object — instead of being scattered across classes, closures and localStorage, and the DOM updates itself when it changes: you add data-* attributes to the markup and a small state to the JS, and the library holds them in sync.

The hookup:

<script src="https://cdn.jsdelivr.net/npm/micra.js@2/dist/micra.min.js"></script>

The tag isn’t repeated below — the examples assume it’s already on the page.

What actually needs wiring up

Write out every interactive piece of a typical landing page and the kinds of state turn out to be surprisingly few. Almost everything reduces to five:

  • On or off — the burger menu, the FAQ accordion, “show more”, a popup, the cookie banner. One flag.
  • One of several — tabs, the monthly/annual toggle, a portfolio filter, the active menu item. A selected value.
  • A position in a list — the reviews carousel, a gallery, onboarding steps. An index.
  • A server request — the contact form, a newsletter signup, a promo-code check. Sending / success / error flags.
  • A remembered choice — cookie consent, currency, language, dark theme. The same pair of flags plus localStorage.

The point of this breakdown is practical: it tells you the shape of the state before the first line of code is written. “On or off” is a single boolean, and if a component ends up with two booleans about the same thing, something has gone wrong. “One of several” is one selected value, not a flag per option — a set of flags admits meaningless combinations like “both tabs are active”, while a single value can’t hold them by construction. A position is a number. A request is three flags with a known lifecycle. Desync bugs almost always begin with state shaped wider than the thing it models, which opens up combinations nobody meant to support.

The widgets differ; the state underneath them repeats. What follows is working code, one example per kind — swap in your own copy.

The hamburger menu

The most common case. Without a library, open/closed usually lives in a class on the element or in a flag inside a closure, and almost every menu also gets “close on outside click” and “close on Escape” — two more places that can drift apart.

The drift looks mundane. Opening goes through classList.toggle, aria-expanded is set on a separate line, and then the Escape handler closes the menu with a single classList.remove — and the attribute keeps its old value, because nobody copied the second line into that handler. You can’t catch this by looking: the menu appears closed while a screen reader considers it open. Even a small menu already has three synchronization points — the class, the attribute, the flag — and every new way to close it multiplies them.

<nav data-component="navbar">
  <button
    class="burger"
    @click="toggle"
    data-bind="aria-expanded:open ? 'true' : 'false'"
    aria-label="Menu"
  >

  </button>

  <ul class="menu" data-show="open">
    <li><a href="/">Home</a></li>
    <li><a href="/services">Services</a></li>
    <li><a href="/contacts">Contact</a></li>
  </ul>
</nav>

<script>
  Micra.define("navbar", {
    state: { open: false },
    toggle() {
      this.state.open = !this.state.open;
    },
    close() {
      this.state.open = false;
    },

    onCreate() {
      this._outside = (e) => {
        if (!this.$el.contains(e.target)) this.close();
      };
      this._esc = (e) => {
        if (e.key === "Escape") this.close();
      };
      document.addEventListener("click", this._outside);
      document.addEventListener("keydown", this._esc);
    },
    onDestroy() {
      document.removeEventListener("click", this._outside);
      document.removeEventListener("keydown", this._esc);
    },
  });
  Micra.start();
</script>

The state is one flag, open. The button and the list are both subscribed to it: this.state.open changes, the menu shows or hides, and aria-expanded is set along the way. The three synchronization points of the manual version collapse into a single write to state.

data-show hides the list via display; the element stays in the DOM. For a menu that’s exactly right — there’s no reason to recreate a list of links on every open. In data-bind, the value for aria-expanded is computed down to a string with a ternary, and that’s not decoration: a boolean in data-bind means “add or remove the attribute”, and a missing aria-expanded tells a screen reader “this element doesn’t expand at all”, which is false. ARIA attributes need the literal strings 'true' and 'false' — hence the ternary.

The document listeners are the one manual part, and they’re manual out of necessity: a click outside the menu happens outside the component, so there’s nothing to hang @click on. The handlers are stored in this._outside and this._esc not for tidiness — removeEventListener only removes a listener given the same function reference, and an anonymous arrow could never be removed later. Micra will clean up its own @click when the component is destroyed; it knows nothing about listeners you added to document, so those need a paired onDestroy. Forgetting that pair is the classic leak: the component is gone from the page, but its handler keeps firing on every click.

The FAQ accordion

A questions-and-answers block is on nearly every landing page. The manual version adds a handler to each heading and keeps track, by hand, that only one item stays open — state bookkeeping that’s easy to get wrong once there are many items.

While the items are independent, the manual version is tolerable. The requirement “only one open” changes the problem: a set of independent toggles becomes shared state, and each item’s handler has to know about the others in order to close them. On bare DOM that’s a loop over the sibling blocks inside every click, and the truth about who is currently open is stored in the DOM itself — in the panels’ classes: to learn it, the code queries elements. The moment one code path closes a panel without removing the class from its heading, the queries start lying.

<div data-component="faq">
  <template data-each="items" data-key="id">
    <div class="faq-item">
      <button
        @click="toggle"
        data-bind="data-id:item.id, aria-expanded:expanded(item.id)"
      >
        <span data-text="item.q"></span>
        <span data-text="item.id === openId ? '–' : '+'"></span>
      </button>
      <div
        class="answer"
        data-show="item.id === openId"
        data-text="item.a"
      ></div>
    </div>
  </template>
</div>

<script>
  Micra.define("faq", {
    state: {
      openId: 1,
      items: [
        {
          id: 1,
          q: "How much does it cost?",
          a: "Depends on scope: a basic landing page starts from a set figure; we quote the exact price after the brief.",
        },
        {
          id: 2,
          q: "What's the timeline?",
          a: "The usual range is one to two weeks from the approved mockup.",
        },
        {
          id: 3,
          q: "Are revisions included?",
          a: "Two rounds of revisions are included; beyond that, it's hourly.",
        },
      ],
    },
    expanded(id) {
      return this.state.openId === id ? "true" : "false";
    },
    toggle(e) {
      const id = Number(e.currentTarget.dataset.id);
      this.state.openId = this.state.openId === id ? 0 : id; // a second click closes it
    },
  });
  Micra.start();
</script>

The item’s markup is written once, inside <template data-each>. The questions themselves live in items as data, so adding a fourth item is a new object in the array, not a copied block of markup where an attribute might get lost along the way. data-key="id" tells the library how to tell items apart between re-renders: change one, and one gets touched — the DOM of the rest survives the render untouched.

All the truth is in a single openId. Each panel compares its own id against it; the “+/–” marker and aria-expanded read from the same place. Opening one item closes the rest automatically — there is simply no second flag to keep in sync: the invariant “at most one open” holds by construction, because two ids don’t fit into one number. The initial openId: 1 opens the first question right after load; set it to 0 if you want them all closed.

Two details in the handler are worth spelling out. The Number() around dataset.id is mandatory: dataset always returns strings, the comparison against openId is strict, and without the cast "1" === 1 quietly yields false. The zero in openId = ... ? 0 : id is a sentinel for “nothing open”: no item has id: 0, so every comparison returns false and every panel is closed. And expanded(id) returns a string rather than a boolean for the same reason as in the menu: aria-expanded has to be present with the value 'false', not vanish from the markup.

Pricing toggle: monthly or annual

The monthly/annual switch is a place where manual code goes wrong in a familiar way: the price sits in two or three spots in the markup, and one of them doesn’t get updated on toggle.

There’s a second, quieter way to get it wrong — put a price field in state and recompute it inside the toggle handler. That technically works, but the component now carries a second truth: price has to be updated every time annual or monthly changes, and a human enforces that “every time”. With a single handler everything lines up; add a second path that changes annual — a promo code, restoring a saved choice — and the recompute there is yours to remember.

<div data-component="pricing">
  <button
    class="switch"
    role="switch"
    @click="toggle"
    data-bind="aria-checked:annual ? 'true' : 'false'"
  >
    Pay yearly — 20% cheaper
  </button>

  <p class="price">
    $<span data-text="price()"></span>/mo
    <span class="hint" data-show="annual">when paying for a full year upfront</span>
  </p>
</div>

<script>
  Micra.define("pricing", {
    state: { annual: false, monthly: 1490 },
    toggle() {
      this.state.annual = !this.state.annual;
    },
    price() {
      return this.state.annual
        ? Math.round(this.state.monthly * 0.8) // −20% for annual billing
        : this.state.monthly;
    },
  });
  Micra.start();
</script>

One flag, annual. The price doesn’t sit in the markup as a finished string — price() computes it, and the annual-billing hint hangs off the same flag. One boolean flips, and both places on screen update themselves; there is no second copy of the price to forget.

price() is a method, not a state field, and that’s a general Micra rule: state holds only source data, and everything derived — counters, totals, filtered lists — is computed by methods on every re-render. A computed value has nowhere to go stale: nobody stores it. What remains in state is exactly two independent facts — monthly and annual — and neither can be derived from the other. The Math.round isn’t a nicety: without it, an annual price computed from a non-round tariff shows cents. And aria-checked on the role="switch" button is assembled into a string with the same ternary as in the menu, for the same reason.

A reviews carousel often gets its own library, even though all it needs is to remember the number of the current slide.

The manual version without a library is usually a pair of operations per click: hide the current slide, show the next. The two have to happen together, and the only thing holding them together is discipline — let one code path (say, an auto-advance timer added later) skip the first half, and two reviews are on screen at once. It’s the same story as the accordion: the position is stored not as a number but as “which block currently lacks display:none”, and reading it means reading the DOM.

<div data-component="reviews">
  <template data-each="items" data-key="id">
    <blockquote data-show="current === $index">
      <p data-text="item.text"></p>
      <footer data-text="item.author"></footer>
    </blockquote>
  </template>

  <div class="controls">
    <button @click="prev" aria-label="Previous">‹</button>
    <span data-text="(current + 1) + ' / ' + items.length"></span>
    <button @click="next" aria-label="Next">›</button>
  </div>
</div>

<script>
  Micra.define("reviews", {
    state: {
      current: 0,
      items: [
        {
          id: 1,
          text: "A short client review — a sentence or two, to the point.",
          author: "Name, company",
        },
        {
          id: 2,
          text: "A second review — about timelines or the result, no exaggeration.",
          author: "Name, company",
        },
        {
          id: 3,
          text: "A third. Put the client's real words here.",
          author: "Name, company",
        },
      ],
    },
    prev() {
      const n = this.state.items.length;
      this.state.current = (this.state.current - 1 + n) % n;
    },
    next() {
      const n = this.state.items.length;
      this.state.current = (this.state.current + 1) % n;
    },
  });
  Micra.start();
</script>

The position is one number, current. Inside data-each, every row has access to its ordinal, $index; the visible review is the one whose ordinal matches current. There’s no hiding the previous slide by hand — every block’s data-show reads the same current, so changing the number hides the old slide and shows the new one in the same update. The counter between the arrows is the same number plus one: people count slides from one, arrays from zero.

The (current - 1 + n) % n in prev() isn’t an accident of style: the % operator in JavaScript keeps the sign of the dividend, so -1 % 3 is -1, not 2. Adding n keeps the number non-negative before the remainder is taken — “back” from the first slide honestly lands on the last. And the slides are hidden with data-show rather than data-if on purpose: data-show only toggles display, the node stays in the DOM, and markup with images isn’t rebuilt on every swipe. data-if would remove the blockquote from the document entirely and insert it back — for slides that take turns being shown many times over, that’s wasted work. (The texts here are placeholders; put real ones in.)

The contact form

The landing page’s main form is where the small stuff costs the most: while the request is in flight the button has to be disabled and its label changed, and the response decides between a thank-you and an error. In bare JS that’s several manual disabled and textContent flips, and this is where things break most often.

The classic failure of such a form is the unhandled server response. Bare fetch counts HTTP 500 as a success: the promise resolves, the code takes the success branch and shows a thank-you for a request that went nowhere. The client is sure someone will call back. The runner-up is more modest — a button that one of the branches forgot to re-enable, and a form that never submits again after its first error.

<form data-component="contact" @submit.prevent="send">
  <input data-model="name" name="name" placeholder="Name" required />
  <input
    data-model="email"
    name="email"
    type="email"
    placeholder="Email"
    required
  />
  <textarea
    data-model="message"
    name="message"
    placeholder="Message"
    required
  ></textarea>

  <button
    type="submit"
    data-bind="disabled:sending"
    data-text="sending ? 'Sending…' : 'Send'"
  >
    Send
  </button>

  <p class="ok" data-if="sent">Thanks! We'll get back to you.</p>
  <p class="err" data-if="failed">
    Couldn't send. Email us at hello@studio.com.
  </p>
</form>

<script>
  Micra.define("contact", {
    state: {
      name: "",
      email: "",
      message: "",
      sending: false,
      sent: false,
      failed: false,
    },

    async send() {
      this.state.sending = true;
      this.state.failed = false;
      try {
        await this.fetch("https://api.example.com/contact", {
          method: "POST",
          body: {
            name: this.state.name,
            email: this.state.email,
            message: this.state.message,
          },
        });
        this.state.sent = true;
      } catch {
        this.state.failed = true;
      } finally {
        this.state.sending = false;
      }
    },
  });
  Micra.start();
</script>

The fields are tied to state with data-model — both ways: typing changes state, a state change updates the field. Submission is intercepted by @submit.prevent — the .prevent modifier is preventDefault() moved into the markup, so the page doesn’t reload.

Three flags cover the request’s lifecycle, and each has its assigned place in send(). sending turns on before the request and off in finally — whichever branch runs, the button is guaranteed to unlock; the form that never submits again becomes impossible by construction. failed is cleared at the start of every attempt: if the user retries after an error, the old message disappears immediately instead of sitting next to the sending label. sent turns on only in the success branch. The markup is left to read the flags: disabled and the button text hang off sending, the messages off sent and failed via data-if — until their state arrives, those paragraphs aren’t in the DOM at all.

this.fetch is a wrapper over ordinary fetch, and it’s chosen here for more than brevity: an object in body is serialized to JSON with the right header, and a response outside the 2xx range becomes an exception. That last part closes the main failure: catch catches both a dropped connection and a server 500, and there’s no way left to thank someone for a request that didn’t go through.

The endpoint can be anything: your own backend, Formspree, a serverless function. Leave a real action on the form and it degrades to a plain POST with JavaScript off. Email format and required fields are checked by the browser via required and type="email".

A cookie banner should appear once and then stay out of the way. Its entire logic is “have we shown it already”, remembered between visits.

In manual versions, localStorage tends to become both the storage and the working state at once: the code reads it in every place that decides “show or not”. Here the roles are separated. localStorage is only the memory between visits, and it’s read once, at mount; from then on the truth lives in state.visible like in any other component, and writing to storage is a side effect of making the choice.

<div
  data-component="cookie-banner"
  class="cookie-banner"
  data-show="visible"
  style="display:none"
>
  <p>We use cookies to make the site work better.</p>
  <button @click="accept">Accept</button>
  <button @click="decline">Decline</button>
</div>

<script>
  Micra.define("cookie-banner", {
    state: { visible: false },

    onCreate() {
      this.state.visible = !localStorage.getItem("cookie-choice");
    },
    accept() {
      this.choose("accepted");
    },
    decline() {
      this.choose("declined");
    },
    choose(value) {
      localStorage.setItem("cookie-choice", value);
      this.state.visible = false;
    },
  });
  Micra.start();
</script>

On startup the component looks into localStorage and shows itself only if no choice has been made yet. visible starts as false rather than as a read from storage: reading external sources is the job of onCreate, the mount hook, where the component is already bound to its element. The two buttons differ only in the value they store, so both funnel into choose(value) — the place where the choice is recorded and the banner hidden exists once in the code.

The inline display:none in the markup is flicker protection. HTML paints before the script loads and runs, and without that line the banner flashes for a moment for everyone — including people who made their choice a month ago. With it, the order is: the browser paints the page without the banner, Micra mounts the component, and data-show takes over display — showing the banner only to those who are due it.

This is the banner’s UI, not a consent manager. GDPR with cookie categories and reporting needs a dedicated tool; for “show a banner and remember the click” this is enough.

Why this stays maintainable

A single Micra.start() at the end lifts every component on the page. The difference from bare JS shows up not right away but in maintenance: the truth is kept in state, not in the DOM. You change data rather than classes and attributes, so you can’t end up with a class removed and a flag forgotten. Whoever opens the file in a year sees ordinary HTML with data-* attributes and a small state, not an excavation of someone else’s closures.

A couple of mechanical details worth knowing. Writes to state don’t re-render the DOM immediately — Micra collects them and applies one re-render in a microtask, so several back-to-back assignments in one handler, as in send(), cost a single update, and intermediate flag combinations never reach the screen. And Micra.start() appears after every snippet only so that each one is self-sufficient when copied; on a real page one call at the end is enough — it scans the document and lifts all the components, and calling it again is safe: instances that are already mounted are left alone.

Where Micra doesn’t fit

There are cases where it’s dead weight. If the page has exactly one interaction and it’s a class toggle, five lines of plain JS are more honest than any library. If the page is growing into a full application with routing and complex client state, this is where Micra deliberately ends: its reactivity is shallow, there is no router — that’s React, Vue or Svelte territory. And if some framework is already running on the page, it doesn’t need a second one.

Its place is any server-rendered HTML that needs a bit of state and interactivity: landing pages, brochure sites, forms, small admin screens. What produced that HTML — WordPress, Laravel, Rails, Django, or plain static files — makes no difference to Micra: it works with the markup in front of it, not the stack behind it.

Where to start

Take any example above, paste it into your HTML and adjust — this is working code, not pseudocode. Hook it up with the CDN tag from the top of the page or npm install micra.js; MIT license, no dependencies. The full list of directives, ready-made components and live demos are in the docs at micrajs.dev.