Tutorial: landing page components
This tutorial builds the three components every landing page needs – a hero banner, a call-to-action band, and a teaser grid – as declared components. You write each one twice: a YAML manifest that declares its attributes, and a Jinja template in your theme that renders its markup. An author then composes the whole landing page in one markdown file, with tag syntax, and Accent validates every use against your declarations.
The tag syntax is the Markdoc language, which Accent implements natively in Rust. You do not need to know that to follow along – but it means the syntax you learn here is a documented language, not a house dialect.
By the end you will have:
- a
herocomponent that wraps a body and takes typed attributes, - a
ctacomponent that is self-closing and takes everything as attributes, - a
teaserscomponent that composes itsteaserchildren into a card grid, - one markdown file that uses all three, and validates in CI.
How this compares to the other approaches
Accent has three ways to build a landing page, and they compose rather than compete:
| Approach | Content lives in | Theme work | Best for |
|---|---|---|---|
| Components (this tutorial) | one markdown file, tag syntax | a manifest and template per component | reusable blocks any page can drop in, on any page template |
| Modular pages | one _-prefixed subdirectory per section | a modular template that loops page.modules | sections with rich, independent frontmatter |
| Structured sections | one file with ---section: name--- markers | a template that reads page.sections.<name> | a fixed layout with a few named regions |
Components are the only one of the three that needs no dedicated page
template: the landing page below renders through your ordinary default
template, because the components carry their own markup. One thing to check
in that template: most themes render page.title as an <h1>, and the hero
below renders its own. Give the landing page a template without the title
heading, or drop the <h1> from the hero and let the page title carry it –
two <h1> elements on one page is a heading-order defect that no validation
catches. That also means an
author can drop a cta into the middle of a docs page later – the components
you build here are not tied to the landing page.
Before you begin
You need a theme you can edit. Components live in your theme’s shortcodes/
directory, one manifest and one template per component:
themes/mytheme/shortcodes/
├── hero.yaml the declaration
├── hero.html.jinja the markup
├── cta.yaml
├── cta.html.jinja
├── teasers.yaml
├── teasers.html.jinja
├── teaser.yaml
└── teaser.html.jinja
The dev server hot-reloads both files, so keep accent serve running and
refresh as you go. If you have not read
Declared components yet, skim it first – this
tutorial applies what that page defines.
The examples reuse the button component the default theme ships. If your
theme does not have one, the reference shows
its manifest and template.
The page you will write
This is the finished landing page, so you know where the tutorial is going. One file, no custom template, three components:
--- title: Home template: default --- {% hero title="Ship your site in minutes" kicker="Accent CMS" image="/landing/hero.webp" %} One binary, markdown in, HTML out. No build chain to babysit. {% button variant="primary" href="/docs/getting-started" %}Get started{% /button %} {% /hero %} {% teasers heading="Why teams pick Accent" %} {% teaser title="One binary" href="/docs/getting-started" %} Install a single executable. Serve, build, and validate with the same tool. {% /teaser %} {% teaser title="Markdown first" href="/docs/markdown-guide" %} Your content is plain files in git, rendered through a fast native pipeline. {% /teaser %} {% teaser title="Themes you own" href="/docs/theming" %} Jinja templates and plain CSS. No lock-in, no runtime JavaScript required. {% /teaser %} {% /teasers %} {% cta title="Ready to try it?" text="Free to build and to serve locally. Pay when you go to production." label="Download" href="/download" /%}
Everything an author can get wrong here – a misspelled attribute, a missing
required one, a value outside an enum – is reported by accent validate
before it reaches a reader.
Step 1: the hero
A hero is a block component: it wraps a body (the supporting copy) and takes its headline and imagery as attributes.
Declare it
# themes/mytheme/shortcodes/hero.yaml name: hero description: A full-width banner for the top of a landing page. params: - name: title type: string required: true description: The headline. - name: kicker type: string required: false description: A short line rendered above the headline. - name: image type: media required: false description: A shared media path for the banner image, such as /media/landing/hero.webp. - name: align type: enum values: [left, center] required: false description: How the text is aligned. Defaults to center. body: markdown
Three declarations doing real work:
required: trueontitlemeans a hero without a headline fails validation instead of rendering an empty<h1>.type: enumonaligncloses the value set.accent validatereportsalign="middle"as an error naming the allowed values. Aserveorbuildrun logs the same finding as a warning and still renders the page, so run the validator in CI if you want the value set enforced rather than reported.type: mediarecords that the value is a media path rather than free text. In the template it arrives as a string, which you pass tomedia().
Write the image value as the path under your shared media directory:
media("/landing/hero.webp") emits /media/landing/hero.webp. The shipped
card and image components read their media attributes the same way.
media() also accepts a value that already starts with /media/, and
accent validate points such a value out.
Render it
{#- themes/mytheme/shortcodes/hero.html.jinja
The landing page banner. `attrs` holds the declared attributes, escaped;
`body` is the tag's inner markdown, with any nested components already
expanded, and is marked safe so it is not escaped again.
-#}
{%- set align = attrs.align | default("center") -%}
<section class="hero hero-{{ align }}">
{%- if attrs.image %}
<img class="hero-image" src="{{ media(attrs.image) }}" alt="">
{%- endif %}
<div class="hero-inner">
{%- if attrs.kicker %}
<p class="hero-kicker">{{ attrs.kicker }}</p>
{%- endif %}
<h1 class="hero-title">{{ attrs.title }}</h1>
<div class="hero-copy">{{ body }}</div>
</div>
</section>
{{ body }} sits straight inside the <div>, and the copy still renders
as markdown, because the manifest said body: markdown. What that buys you,
and the one rule it leaves you, is under
Let the body render as markdown below.
Component templates render through the same engine as your page templates, so
every filter and function is
available: media() resolves the image through the media pipeline, and
default supplies the fallback alignment. Autoescaping is on for component
templates – a title containing <script> reaches the page as text, while
body is content the expander produced and is marked safe, so it is not
escaped a second time.
A component also reads the same page context a page template does, so it can list and link beyond its own attributes:
| Variable | What it is |
|---|---|
site | site name, description, base URL, language |
page | the page the component sits on – page.url, page.title, page.language, and page.modules, each with its rendered content (its own body is not available here) |
pages | the menu-visible pages of the page’s language |
all_pages | every listable page, for a listing such as “five newest posts per tag” |
taxonomy | the tags of the page’s language |
theme, dev, accent | theme assets, dev flags, and the running Accent version |
A component’s own attrs, children and body win if a name clashes. With
these a recent-posts component can filter all_pages by tag, and a footer
band can read site.name – values you would otherwise repeat as an attribute
on every use. A page whose component lists others is re-rendered when any page
is added, removed, or changes what listings show of it (a tag, date, or
status), in both accent serve and an incremental accent build.
On a sub-path deployment, Accent adds the prefix to every root-relative
href, src and poster in a component’s output, as it does for a link in
markdown. So in a component the URLs in page, pages and all_pages are
root-relative, and so are the URLs url(), media() and the other URL
helpers return: write {{ post.url }} as it is. site.base_path still holds
the prefix, and absolute_url() still includes it. A root-relative URL in any
other attribute, such as action or srcset, gets no prefix, so build it
from site.base_path.
The image is decorative here, so its alt is empty. If your hero image
carries meaning, declare an alt parameter the way the teaser card does in
step 3.
Use it
{% hero title="Ship your site in minutes" kicker="Accent CMS" image="/landing/hero.webp" %} One binary, markdown in, HTML out. No build chain to babysit. {% button variant="primary" href="/docs/getting-started" %}Get started{% /button %} {% /hero %}
Components nest. The button inside the body is expanded first, so the
hero’s body already contains the rendered link by the time your hero template
runs. Keep the blank line before it, so the button sits in its own paragraph
rather than at the end of the previous one. The shipped button declares
inline: true, which is what lets you write it on one line, or in a
sentence, without a placement error from accent validate.
Let the body render as markdown
A component’s body is spliced into your page as markdown source, at the
spot where your template interpolates {{ body }}. Whether it then renders
depends on a CommonMark rule rather than on Accent:
A line that starts with an HTML tag opens a raw HTML block, and everything up to the next blank line is passed through literally.
That covers more than the block-level names: a complete tag alone on its line
opens a block too, <img> and <a> included. A body written flush inside a
<div> therefore reaches the page with its asterisks intact. The manifest is what prevents that: body: markdown tells
Accent the body is block content, and Accent splices it between blank lines
before your template runs. The raw HTML block closes, the body reaches the
markdown parser like any other content, and your template can put {{ body }}
wherever it likes:
<div class="hero-copy">{{ body }}</div>
Two things still bite, and each one is silent:
- Do not indent the tag that closes after
{{ body }}. Four spaces of indentation at the start of a line following a blank line is an indented code block, so an indented</div>prints as</div>. The templates in this tutorial keep their closing tags in column 0. - A label is not block content. Blank lines inside an
<a>or a<span>turn the label into a paragraph inside the link. Declarebody: inlinefor such a component and the body is spliced flush, on the same line as the tag, with the newline a block placement leaves after it trimmed – which is what the shippedbuttondoes. Flush is what keeps it inline: the tag and the label share a line, so no raw HTML block opens. <pre>and its kind cannot be opened up this way. A<pre>,<script>,<style>or<textarea>block runs to its own closing tag rather than to a blank line, so a component whose template wraps the body in one keeps a literal body whatever the manifest says. That is the right behaviour for preformatted text, and worth knowing before you declarebody: markdownon such a component.
accent validate --templates reports both: a body: inline body placed on a
line that starts with a block-level tag, and an indented line after a
body: markdown body.
The body arrives as paragraphs once it renders as markdown, so style the
paragraph rather than the container. A hero body that ends with a nested
button is two paragraphs, not one, which is why the stylesheet below spaces
them and only flattens the last.
Step 2: the call to action
A call to action has no free-form body – it is a title, a line of text, and a
button, all of which the theme should control the shape of. So declare
everything as attributes and omit body from the manifest, which makes the
component self-closing.
Declare it
# themes/mytheme/shortcodes/cta.yaml name: cta description: A call-to-action band with one button. params: - name: title type: string required: true description: The band's heading. - name: text type: string required: false description: One supporting sentence under the heading. - name: label type: string required: true description: The text on the button. - name: href type: reference required: true description: Where the button links to.
type: reference marks href as a link target rather than free text, the
same way the shipped button declares its href. Pass it through url() in
the template, as above: that keeps the slash literal instead of HTML-escaped.
If the site is served from a sub-path, Accent adds your deployment prefix to
the link.
Render it
{#- themes/mytheme/shortcodes/cta.html.jinja
A full-width band with a single action. Everything comes from `attrs`;
a self-closing component has no body.
-#}
<section class="cta">
<h2 class="cta-title">{{ attrs.title }}</h2>
{%- if attrs.text %}
<p class="cta-text">{{ attrs.text }}</p>
{%- endif %}
<a class="btn btn-primary" href="{{ url(attrs.href) }}">{{ attrs.label }}</a>
</section>
Use it
{% cta title="Ready to try it?" text="Free to build and to serve locally. Pay when you go to production." label="Download" href="/download" /%}
The /%} closes the tag in place. Leaving out label or href is a
missing-required-attribute error naming the attribute, because the manifest
said so.
Step 3: the teaser grid
Teasers are the interesting one: a grid of cards where the container owns
the layout and each card is a child tag. The container cannot recover a
card’s title from a rendered body string, so its template reads children –
the component’s immediate child tags, each with its own name, attrs, and
body – exactly the way the shipped tabs component reads its tab
children.
Declare both
The container wraps a body (the run of teaser children):
# themes/mytheme/shortcodes/teasers.yaml name: teasers description: A responsive grid of teaser cards. Its children are `teaser` components. params: - name: heading type: string required: false description: An optional heading rendered above the grid. body: markdown
Each card declares what a card needs:
# themes/mytheme/shortcodes/teaser.yaml name: teaser description: One card in a teaser grid. Belongs inside `teasers`. params: - name: title type: string required: true description: The card's heading, which links to `href`. - name: href type: reference required: true description: Where the card links to. - name: image type: media required: false description: A shared media path for the card image. - name: alt type: string required: false description: Alternative text for the image. Defaults to empty, for decorative images. body: markdown
Render both
The container composes its children:
{#- themes/mytheme/shortcodes/teasers.html.jinja
Reads `children` instead of `body`: each card's attributes are data here,
which a rendered body string could not give back.
-#}
<section class="teasers">
{%- if attrs.heading %}
<h2 class="teasers-heading">{{ attrs.heading }}</h2>
{%- endif %}
<div class="teaser-grid">
{%- for c in children if c.name == "teaser" %}
<article class="teaser">
{%- if c.attrs.image %}
<img class="teaser-image" src="{{ media(c.attrs.image) }}" alt="{{ c.attrs.alt | default("") }}">
{%- endif %}
<h3 class="teaser-title"><a href="{{ url(c.attrs.href) }}">{{ c.attrs.title }}</a></h3>
<div class="teaser-text">{{ c.body }}</div>
</article>
{%- endfor %}
</div>
</section>
A child’s body follows the same rule as a parent’s: the teaser manifest says
body: markdown, so {{ c.body }} arrives between blank lines and the card
text renders as markdown inside <div class="teaser-text">.
A child’s attrs are the same resolved, escaped values the parent’s own
attrs are, so reading a child is no more privileged than reading an
attribute.
Because this template renders children and never interpolates body,
anything in the container that is not a teaser – an intro sentence between
the cards, a stray button – is dropped from the output without a warning.
A container that should carry prose as well as cards has to interpolate
{{ body }} too.
The child still gets a template of its own, for the case where an author
writes a teaser outside a teasers. Emitting the body unchanged is the
honest fallback, and it is exactly what the shipped tab component does:
{#- themes/mytheme/shortcodes/teaser.html.jinja
A `teaser` is normally read by its `teasers` parent. On its own, it is
just its body.
-#}
{{ body }}
Use it
{% teasers heading="Why teams pick Accent" %} {% teaser title="One binary" href="/docs/getting-started" %} Install a single executable. Serve, build, and validate with the same tool. {% /teaser %} {% teaser title="Markdown first" href="/docs/markdown-guide" %} Your content is plain files in git, rendered through a fast native pipeline. {% /teaser %} {% teaser title="Themes you own" href="/docs/theming" %} Jinja templates and plain CSS. No lock-in, no runtime JavaScript required. {% /teaser %} {% /teasers %}
Adding a fourth card is one more line of content and zero theme changes – that is the split working: the author owns the cards, the theme owns the grid.
Style the components
The templates emit classed, semantic markup, so styling is ordinary CSS in your theme’s stylesheet. A minimal, framework-free starting point:
/* Hero */ .hero { padding: 4rem 1.5rem; } .hero-center { text-align: center; } .hero-image { max-width: 100%; margin-bottom: 2rem; } .hero-kicker { text-transform: uppercase; letter-spacing: 0.08em; font-size: 0.875rem; color: #6b7280; } .hero-title { font-size: 2.5rem; margin: 0.25rem 0 1rem; } .hero-copy { font-size: 1.125rem; max-width: 40rem; margin: 0 auto; } .hero-copy p { margin: 0 0 1rem; } .hero-copy p:last-child { margin-bottom: 0; } .hero-left { text-align: left; } .hero-left .hero-copy { margin-inline: 0; } /* Call to action */ .cta { text-align: center; padding: 3rem 1.5rem; margin: 3rem 0; background: #f4f4f5; border-radius: 0.5rem; } .cta-title { margin: 0 0 0.5rem; } .cta-text { margin: 0 0 1.5rem; color: #52525b; } /* Teaser grid */ .teasers { margin: 3rem 0; } .teasers-heading { text-align: center; margin-bottom: 2rem; } .teaser-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); gap: 1.5rem; } .teaser { border: 1px solid #e4e4e7; border-radius: 0.5rem; padding: 1.5rem; } .teaser-image { width: 100%; border-radius: 0.25rem; margin-bottom: 1rem; } .teaser-title { margin: 0 0 0.5rem; } .teaser-title a { text-decoration: none; } .teaser-text p { margin: 0; }
The auto-fit grid means three cards make three columns on a wide screen and
one column on a phone, with no per-count classes.
Validate the page
Run the validator the way CI does:
accent validate --config config.yaml
Every mistake an author can make against your manifests is reported with the page and the reason: an unknown component name, an undeclared attribute, a missing required one, an enum value outside the declared set. The same findings appear in dev-server logs and in the admin panel, and a tag Accent does not recognize is left literally in the page – visibly wrong rather than silently absent. The full list of what gets reported is in the reference.
Where to go next
- Pull site facts into the copy. A component body can reference
variables:
Welcome to {% $site.name %}.stays correct when the site is renamed. - Share a block across landing pages. If the same CTA appears on several
pages, move it into a partial and include it
with
{% partial file="shared/cta.md" /%}– editing the partial re-renders every page that uses it. - Know the portability trade.
hero,cta, andteasersare your theme’s own components. Content that uses them depends on a theme that declares them, so a theme swap needs the new theme to bring the same names – see where a component can come from for how declarations from the built-in set, your theme, and plugins interact. - Sectioned frontmatter instead? When a landing page’s sections need rich per-section frontmatter – their own dates, authors, or custom fields – reach for modular pages and keep these components for the blocks inside each section.