Guides

Interactive Styles

Interactive styles let an element change appearance when it is in a particular state — hovered, focused, pressed, or toggled by JavaScript. In meno-astro these are plain CSS, declared right alongside the base styles in the second style() argument, the meta object. There is no separate CSS file and no raw class="…" string: an interactive array on style() holds every state rule, and it round-trips through the editor exactly like the base styles do.

<button class={style({
    base: { backgroundColor: "var(--primary)", color: "var(--bg)" }
  }, undefined, {
    interactive: [
      { name: "on hover", postfix: ":hover", style: { base: { backgroundColor: "var(--primary-dark)" } } }
    ]
  })}>{text}</button>

The base styles say how the button looks at rest; the interactive rule says what changes on :hover. Each rule compiles to a real CSS selector at build time, so hover, focus, and active work without any JavaScript — and JS-driven states reuse the same mechanism.

Where interactive rules live

The interactive array sits in the meta object, which is the last argument of style(). The argument before it differs by node:

  • On a plain HTML node, pass undefined for the middle argument: style({…}, undefined, { interactive: [...] }).

  • On a component's structure root, pass __props so instance styles still merge, and add root: true: style({…}, __props, { interactive: [...], root: true }).

<!-- a plain node -->
<dt class={style({
    base: { cursor: "pointer", opacity: "0.6" }
  }, undefined, {
    interactive: [
      { name: "on hover", postfix: ":hover", style: { base: { opacity: "1" } } }
    ],
    label: "question"
  })} data-el="question"><slot /></dt>

The meta object can also carry a label (the friendly name shown in the editor's Structure tree) and genClass. The interactive key is what concerns us here. The visual interaction controls in the Properties panel write exactly these rule objects, so anything you add by hand appears in the editor and survives the next save.

How prefix and postfix compose

Every styled element gets a generated class. An interactive rule wraps that class into a fuller selector using two optional strings:

{prefix}.element-class{postfix}
  • postfix is appended after the element's class — use it for the element's own state: a pseudo-class (:hover, :focus, :active), a state class on the element itself (.is-open), or a descendant the element controls (.is-open [data-el='menu']).

  • prefix is prepended before the element's class — use it for an ancestor context: a state class on a parent (li.is-open , [data-el="cat-toggle"]:checked ~ label ) or a theme wrapper (.dark ).

A rule's style is itself a full responsive object — { base, tablet, mobile } (or a flat object) — so a state can change per breakpoint just like base styles.

Common patterns and the CSS they generate:

Use case

prefix

postfix

Generated selector

Hover

""

":hover"

.el:hover

Focus

""

":focus"

.el:focus

Active

""

":active"

.el:active

Element has a state class

""

".is-open"

.el.is-open

Control a descendant

""

".is-open [data-el='menu']"

.el.is-open [data-el='menu']

Parent has a state class

".is-open "

""

.is-open .el

Ancestor theme context

".dark "

""

.dark .el

When you use a prefix for an ancestor, keep the trailing space (".dark ", not ".dark"). Without it the selector becomes .dark.el — the same element carrying both classes — instead of .dark .el, which is a descendant of an element with .dark. A rule may set both prefix and postfix at once, and an empty string for either simply contributes nothing.

Targeting a child element

A common pattern is one element reacting to a state set on an ancestor. Put a data-el attribute on the child, then write the rule on the ancestor with a prefix (or postfix) that reaches the child by that attribute. From src/components/ui/NavDropdown.astro, the dropdown menu opens when its <li> root gains .is-open:

<li class={style({
    base: { listStyle: "none", position: "relative" },
    tablet: { marginRight: "16px" }
  }, __props, { root: true, label: "NavDropdown" })}>
  <button data-el="dropdown-toggle">{text}</button>
  <ul class={style({
      base: {
        position: "absolute",
        opacity: "0",
        visibility: "hidden",
        pointerEvents: "none",
        transition: "opacity 0.2s, visibility 0.2s"
      },
      tablet: { position: "relative", opacity: "0", visibility: "hidden" }
    }, __props, {
      interactive: [
        {
          name: "on hover (desktop)",
          prefix: "li:hover ",
          postfix: "",
          style: { base: { opacity: "1", visibility: "visible", pointerEvents: "auto" } }
        },
        {
          name: "on open (mobile tap)",
          prefix: "li.is-open ",
          postfix: "",
          style: { tablet: { opacity: "1", visibility: "visible", pointerEvents: "auto" } }
        }
      ],
      label: "DropdownMenu"
    })}><slot /></ul>
</li>

Two rules on the menu <ul>: li:hover opens it on desktop hover (pure CSS, no JS), and li.is-open opens it on tablet/mobile once a tap adds .is-open to the root. Note each rule scopes its change to the right breakpoint — the desktop rule writes base, the mobile rule writes only tablet.

You can also drive a descendant from the same element's state using postfix. A rule with postfix ".is-open [data-el='menu']" produces .el.is-open [data-el='menu'] — when the element gains .is-open, the matching [data-el='menu'] child inside it restyles.

The JavaScript + interactive-styles pattern

Pseudo-classes cover hover, focus, and active. For anything stateful — open/closed, active tab, selected — the convention is: JavaScript toggles a state class on the component root, and interactive styles react to it. The JS never sets styles directly; it only flips a class, and the CSS you declared does the rest. See JavaScript for how component scripts get el and props.

The accordion in src/components/ui/FaqItem.astro is the canonical shape. The answer is hidden by default and the open-state rules use a .is-open prefix:

<dd class={style({
    base: { display: "none" }
  }, __props, {
    interactive: [
      { name: "is open", prefix: ".is-open ", style: { base: { display: "block" } } }
    ],
    label: "answer"
  })} data-el="answer"><slot /></dd>

The script toggles .is-open on the root and otherwise does nothing visual:

<script define:vars={{ isOpen }}>
  const questionEl = el.querySelector('[data-el="question"]');
  questionEl?.addEventListener('click', () => {
    el.classList.toggle('is-open');
  });
</script>

When the root has .is-open, the rule .is-open .answer-class flips display from none to block. The same idea scales: a tab container can set .is-active on the selected button and panel, a dropdown toggles .is-open on its <li>, a dark theme adds .dark to a wrapper. In each case the JS does one thing — change a class — and the interactive rules carry every visual difference.

State classes also work without JavaScript when an ancestor's pure-CSS state can drive them. The docs sidebar in this site opens a category with a hidden checkbox: the rule prefix [data-el="cat-toggle"]:checked ~ label rotates a chevron and [data-el="cat-toggle"]:checked ~ expands the link list — no script at all.

Editor round-trip

The interaction controls in the Properties panel write these exact rule objects into the interactive array — same name, prefix, postfix, and responsive style. So the visual flow and the hand-edited file are one and the same: add a hover rule in the editor and it appears in style(); type an interactive rule into the .astro file and it shows up in the panel. Stay inside this shape and the round-trip is lossless.


For the styling fundamentals these rules build on, see Styling. For the scripts that toggle state classes, see JavaScript. To package an interactive pattern as a reusable component with its own root, props, and script, see Building Components.

Building the future of digital experiences, one website at a time

Product

Resources

Comparisons

© 2026 Meno. All rights reserved.