Guides

Building Components

Components are reusable UI blocks — buttons, cards, headers, footers — that you define once and use across pages. In Meno, a component is **one .astro file** under src/components/. That single file holds everything the block needs: its markup, its prop definitions, its styles, and its JavaScript. There is no separate JSON, .js, or .css trio anymore — markup lives in the body, styles in an inline <style>, and behavior in an inline <script>.

You can build and edit components two ways that stay in sync: visually in the Meno editor, or by hand-editing the .astro file directly. This guide covers the file format so you can read and write it confidently.

A component is one file

Create a component by adding a .astro file under src/components/. The filename (PascalCase) is the component's name, and the path can be nested into subfolders to keep things organized:

src/components/ui/Button.astro
src/components/ui/Card.astro
src/components/section/Hero.astro
src/components/form/Input.astro

Subfolders like ui/, section/, and form/ are purely for organization — the component name comes from the filename, so src/components/ui/Button.astro is used as <Button />.

Anatomy

Every component file has the same shape: a frontmatter block between --- fences, then the body markup, then optional <style> and <script> blocks. Here is a complete small component:

---
import { resolveProps, style } from 'meno-astro';

const { text, variant, class: className } = resolveProps(Astro, {
  text: { type: "string", default: "Badge" },
  variant: { type: "select", default: "solid", options: ["solid", "outline"] }
});

const __meno = { category: "ui" };
---
<span class={style({
    base: {
      display: "inline-flex",
      padding: "4px 10px",
      borderRadius: "6px",
      fontSize: "13px",
      color: {
        _mapping: true,
        prop: "variant",
        values: { solid: "var(--bg)", outline: "var(--text)" }
      },
      backgroundColor: {
        _mapping: true,
        prop: "variant",
        values: { solid: "var(--text)", outline: "transparent" }
      }
    }
  })}>{text}</span>

The three parts of the frontmatter are:

  • Imports — runtime helpers (resolveProps, style, …) from meno-astro, runtime components (Link, Embed, …) from meno-astro/components, and other local components you reference.

  • **A single resolveProps(Astro, {…}) call** — the authoritative definition of the component's props, destructured into local names.

  • **An optional const __meno = {…}** — editor metadata (category, acceptsStyles, libraries). Omit it entirely when empty.

Then the body markup uses those locals, and <style> / <script> blocks follow if needed.

The prop block: resolveProps(Astro, {…})

A component declares its props exactly once, as the object argument to resolveProps. There is no separate interface Props and no __meno_props block — this one literal is the single source of truth. The Meno editor reads it to build the properties panel, and the codec reads it back on every save.

Each prop is a name mapped to a definition with a type and (usually) a default:

const { text, size, align, class: className } = resolveProps(Astro, {
  text: { type: "rich-text", default: "Heading" },
  size: {
    type: "select",
    default: "1",
    options: ["1", "2", "3", "4", "5", "6"]
  },
  align: {
    type: "select",
    default: "left",
    options: ["left", "center", "right"]
  }
});

resolveProps merges Astro.props over each definition's default at runtime, so an instance only needs to set the props it wants to change. It also infers each local's TypeScript type from the definition — number, boolean, a link object, a union of select options, or a string fallback — so you never hand-write types.

The destructure on the left binds the prop names for use in the body. Two rules always apply:

  • **Always keep class: className in the destructure.** This binds the wrapper styles a parent instance can pass down, so every component can be styled in context.

  • Emit the call even when there are no props. A component with no interface still writes the call with just the class binding:

const { class: className } = resolveProps(Astro, {});

For the full list of prop types — string, number, boolean, select, link, file, rich-text — and their options, see Component Props. Note there is no image type; for image fields use file with accept: "image/*".

When you need the props object

If your component passes prop values to runtime helpers — style() mappings on a component root, when() conditionals, or a define:vars script — capture the resolved object first, then destructure from it:

const __props = resolveProps(Astro, {
  variant: { type: "select", default: "primary", options: ["primary", "secondary"] },
  isOpen: { type: "boolean" }
});
const { variant, isOpen, class: className } = __props;

Now __props can be threaded into helpers (style({…}, __props, {…})) while the destructured names stay available for plain interpolation. Both forms are valid dialect; reach for __props only when a helper needs it.

Editor metadata: __meno

The const __meno = {…} object carries the component's non-prop metadata. It has three optional keys:

  • category — groups the component in the editor's Components panel (e.g. "ui", "layout", "section").

  • acceptsStylestrue when the component's root should accept wrapper styles passed by a parent instance.

  • libraries — third-party libraries the component depends on.

Include only the keys you use, and omit the whole const __meno line when all are empty:

const __meno = { category: "ui", acceptsStyles: true };

Using props in the body

Inside the body, prop locals behave like ordinary values. A whole-string template is just a bare interpolation:

<span>{text}</span>

For HTML attributes, interpolate the same way — a bare expression, or a backtick literal when the value is mixed with static text:

<img src={imageSrc} alt={imageAlt} />
<a href={`/team/${slug}`}>{name}</a>

Props can also drive styles. A _mapping value inside style({…}) picks a CSS value based on the current prop:

backgroundColor: {
  _mapping: true,
  prop: "variant",
  values: { primary: "var(--text)", secondary: "var(--bg)" }
}

And props can gate whole subtrees with a conditional — {cond && ( … )} renders its block only when the condition is truthy:

{isFeatured && (
  <span class={style({ base: { color: "var(--accent)" } })}>Featured</span>
)}

See Styling for the full style-mapping mechanics and Interactive Styles for hover and state styles.

Slots

A slot is a placeholder where a page author injects custom content into your component — a "content hole." Drop a <slot /> where injected children should appear:

---
import { resolveProps, style } from 'meno-astro';

const { class: className } = resolveProps(Astro, {});
---
<div class={style({ base: { padding: "32px", backgroundColor: "var(--bg-light)" } })}><slot /></div>

To show fallback content when no children are passed, put it between the tags:

<slot>Default content</slot>

A component can have one slot only. If you need several content areas, compose nested components — for example a Tabs component built from TabNav and TabContent, each with its own slot.

Inline styles and scripts

A component's CSS goes in an inline <style is:global> block at the end of the file. This is for the rare CSS that style() mappings and interactive styles can't express — most styling stays in style({…}) on the markup:

<style is:global>
h1 .custom-span {
  background: linear-gradient(90deg, #7e88d3 15%, #d0cfed 100%);
  background-clip: text;
  -webkit-background-clip: text;
  color: transparent;
}
</style>

A component's JavaScript goes in an inline <script> block. When the script needs the component's props, emit it as Astro's native <script define:vars={{ … }}> — listing the props injects them into the script as variables:

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

The script runs with el (the component's root element) and the named props in scope. A script that needs no props is a plain <script is:inline>. Keep scripts vanilla JavaScript — no React, no DOMContentLoaded. See JavaScript for the full interactivity patterns and Styling for the styling model.

Using the component on a page

To use a component, import it in the page (or parent component) frontmatter, then write it as a Capitalized tag with its props as JSX attributes:

---
import { BaseLayout } from 'meno-astro/components';
import Heading from '../components/ui/Heading.astro';
import Card from '../components/ui/Card.astro';
import { i18n } from 'meno-astro';

const meta = { title: "Home" };
---
<BaseLayout meta={meta}>
  <main>
    <Heading size={1} text={i18n({ _i18n: true, en: "Welcome", pl: "Witaj" })} />
    <Card>
      <p>Cards expose a slot, so children fill it.</p>
    </Card>
  </main>
</BaseLayout>

Each prop maps to its JSX attribute by type — strings as text="Hi", numbers as size={1}, booleans as isMarginTop={true}, link or object props as link={{ href: "/x", target: "_blank" }}, and i18n values as text={i18n({ _i18n: true, … })}. A component that exposes a slot takes children between its tags; a component with no slot takes only its props.

The import path is relative: '../components/ui/Card.astro' from a page, or './Card.astro' between sibling components. Every Capitalized tag in the body needs a matching local import in the frontmatter.


For the prop-type reference see Component Props; for placing components into routes see Creating Pages; and to understand how hand-edits stay in sync with the editor, see Hand-Editing.

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

Product

Resources

Comparisons

© 2026 Meno. All rights reserved.