Build a landing page
Assemble a landing page from @sdxc/ui's landing patterns, give it your brand through tokens, and drop to the primitives when a band needs its own shape.
Last updated
After reading this guide, you will know
- How to stack a landing from hero, proof, feature, FAQ and closing bands
- How to give the bands your brand through the landing tokens
- How to build a band of your own from the landing primitives
A landing page is a stack of full-width bands: a hero, a strip of proof, a few feature bands,
the questions people ask, and a closing call to action. @sdxc/ui ships each of
those as a pattern, plus the primitives they are built from, so a page is mostly choosing
bands and passing them copy. The library ships no copy of its own: every string on the page
is a prop or a child you pass.
npm add @sdxc/icons @sdxc/ui remixThe bands use the same stylesheets as every other @sdxc/ui component, imported once from the
document layout. Build the interface with remix/component
sets those up; this guide starts from a page that already renders @sdxc/ui components.
Three layers
| Layer | Import from | What it gives you |
|---|---|---|
| Patterns | @sdxc/ui/landing | Whole bands: LandingHero, ProofStrip, FeatureBand, PricingBand, TestimonialBand, FaqBand, ClosingCta |
| Primitives | @sdxc/ui | The parts bands are made of: LandingSection, LandingHeader, Split, StatStrip, LogoCloud, Feature, CtaGroup |
| Foundations | @sdxc/ui/styles | Tokens and style factories: landingCtaChrome(), landingBandTone(), and the --ui-landing-* variables |
Reach for a pattern first. When none fits, build the band from primitives, as the last section shows.
Stack the page
The hero renders the page's <h1>, every band after it titles at <h2>, and the cards inside a
band at <h3>. Each band sets its own level, so the outline stays sequential with no level or
scope placed by hand.
import { Button, LinkButton } from "@sdxc/ui";
import { ClosingCta, LandingHero } from "@sdxc/ui/landing";
import { landingCtaChrome } from "@sdxc/ui/styles";
import { Faq } from "~/resources/components/landing/faq";
import { Features } from "~/resources/components/landing/features";
import { HeroShot } from "~/resources/components/landing/hero-shot";
import { Proof } from "~/resources/components/landing/proof";
export function Landing() {
return () => (
<main>
<LandingHero
align="center"
eyebrow="Status pages are here"
eyebrowHref="/changelog"
title="Know it is down before your users do"
description={
<>
Checks from six regions, alerts that only fire when{" "}
<strong>two regions agree</strong>, and a status page your
customers trust.
</>
}
actions={
<>
<Button color="brand" mix={[landingCtaChrome()]}>
Start free
</Button>
<LinkButton
href="/pricing"
variant="surface"
mix={[landingCtaChrome()]}
>
See pricing
</LinkButton>
</>
}
note="Five monitors free, no card required."
media={<HeroShot />}
/>
<Proof />
<Features />
<Faq />
<ClosingCta
inset
title="Start watching your endpoints today"
description="Five monitors and a status page, free for as long as you need them."
actions={
<>
<Button color="brand" mix={[landingCtaChrome()]}>
Create an account
</Button>
<LinkButton
href="/contact"
variant="surface"
mix={[landingCtaChrome()]}
>
Talk to us
</LinkButton>
</>
}
/>
</main>
);
}Render <Landing /> from the page's controller inside your document layout, like any other
page. Nothing on it hydrates: the FAQ is <details>, and tabbed bands switch with radio
inputs and CSS. Actions take the 40px landing size from the CtaGroup the hero puts them
in, and landingCtaChrome() gives both the brand's corner radius and a tactile edge; the
surface variant is a secondary that still reads as a button beside a solid primary.
Here is that page, live. Switch between Desktop and Phone to see each band rearrange itself for the width it has:
The assembled landing: a centered hero over a staged status page, the proof band, three feature bands, the questions, and an inset closing call to action.
The hero
LandingHero stacks the eyebrow, title, lead, actions and a note line in a set rhythm, then
places media under the copy at the full measure. That suits a text-led hero; for a
product-led one, where the media is taller than the copy, mediaPlacement="end" puts it
beside the copy and steps the headline down one size. "background" lays it behind the
statement instead. The eyebrow is a quiet pill, and eyebrowHref turns it into a link to the
announcement it names.
Frame the product with ProductFrame so it reads as the product rather than as a loose
image. stage="panel" sets the frame on a tinted panel, the way a product shot is staged:
import { ProductFrame } from "@sdxc/ui/landing";
export function HeroShot() {
return () => (
<ProductFrame stage="panel" lights title="Status" url="status.example.com">
<img
src="/images/monitors.png"
alt="Monitors dashboard with six regions passing"
/>
</ProductFrame>
);
}A rendered component works as well as an image, and stays sharp at every width.
ProductFrame variant="device" draws a phone instead, shadow="tinted" lifts a frame off a
brand band, and CodeStage variant="slab" frames a code sample when the product is a tool.
Proof
ProofStrip is the ruled band right after the hero. Give it a LogoCloud, a StatStrip, or
both:
import { LogoCloud, StatStrip, ProofStrip } from "@sdxc/ui/landing";
export function Proof() {
return () => (
<ProofStrip>
<LogoCloud layout="spread" label="Trusted by on-call teams at" muted>
<img src="/logos/northwind.svg" alt="Northwind" />
<img src="/logos/voltage.svg" alt="Voltage" />
<img src="/logos/layerline.svg" alt="Layerline" />
<img src="/logos/fernway.svg" alt="Fernway" />
<img src="/logos/quadrant.svg" alt="Quadrant" />
</LogoCloud>
<StatStrip>
<StatStrip.Item label="Checks run every day">4.2M</StatStrip.Item>
<StatStrip.Item label="Probe regions">6</StatStrip.Item>
<StatStrip.Item label="Median alert latency">38s</StatStrip.Item>
</StatStrip>
</ProofStrip>
);
}LogoCloud sizes every mark to one height, large enough to recognize, and muted draws them
in one ink (an image goes grayscale), so no customer's logo outshouts the others.
layout="spread" runs the marks edge to edge across the measure, and layout="grid" sets them
in ruled cells. StatStrip puts every figure on one row once the band is wide enough, and on a
phone gives an odd last figure the whole row.
The proof band: five muted wordmarks spread across the measure over three figures.
Feature bands
FeatureBand is the band a landing repeats. Its layout decides the body:
"split"puts claims besidemedia. Children that are a<ul>render as checked claims."grid"laysFeaturecards incolumns, two on a tablet and all of them on a wide band."steps"numbersStepList.Itemchildren for a "how it works" band."stage"listsTabbedStage.Itemchildren down a rail beside a sticky stage that shows the selected one's media."timeline"setsStepList.Itemchildren on a vertical rule, each with ametaline and optionalmedia.
import { BellRingIcon, GaugeIcon, GlobeIcon } from "@sdxc/icons";
import { Feature, FeatureBand, ProductFrame, StepList } from "@sdxc/ui/landing";
export function Features() {
return () => (
<>
<FeatureBand
layout="split"
eyebrow="Incidents"
title="Incidents write their own record"
description={
<>
Every state change lands on the timeline as it happens, so the{" "}
<strong>post-mortem is already written</strong> when the page
clears.
</>
}
media={
<ProductFrame lights title="Incident #482">
<img src="/images/incident.png" alt="Incident timeline" />
</ProductFrame>
}
>
<ul>
<li>Status changes posted as they happen</li>
<li>Responders and acknowledgements recorded</li>
<li>One link to share with customers</li>
</ul>
</FeatureBand>
<FeatureBand
layout="grid"
tone="brand"
inset
eyebrow="On-call"
title="Built for the 3 a.m. page"
columns={3}
>
<Feature>
<Feature.Icon>
<GlobeIcon />
</Feature.Icon>
<Feature.Title>Six probe regions</Feature.Title>
<Feature.Description>
Every check runs from <strong>three continents</strong>, so a
local outage never hides behind a healthy one.
</Feature.Description>
</Feature>
<Feature>
<Feature.Icon>
<BellRingIcon />
</Feature.Icon>
<Feature.Title>Alerts that agree</Feature.Title>
<Feature.Description>
A page fires only when <strong>two regions</strong> see the
failure, which keeps flaky networks off your phone.
</Feature.Description>
</Feature>
<Feature>
<Feature.Icon>
<GaugeIcon />
</Feature.Icon>
<Feature.Title>Latency per region</Feature.Title>
<Feature.Description>
Response time is charted per region, so you see{" "}
<strong>slow before down</strong>.
</Feature.Description>
</Feature>
</FeatureBand>
<FeatureBand layout="steps" eyebrow="Setup" title="Live in three steps">
<StepList.Item title="Add a monitor">
Paste the URL you want watched and pick the regions that check it.
</StepList.Item>
<StepList.Item title="Pick who gets paged">
Route alerts to a rotation, a channel, or a single phone.
</StepList.Item>
<StepList.Item title="Share the status page">
Customers subscribe and hear about incidents before they ask.
</StepList.Item>
</FeatureBand>
</>
);
}Alternate tone between bands ("plain", "tinted", "grid", "brand") so a reader can
tell where one ends and the next begins, and add inset to float a toned band as a rounded
panel inside the page. Leave "inverse" for the closing band, which uses it by default. A
<strong> in a description draws in the heading ink at the copy's weight, which gives a reader
scanning the band a second level without turning anything bold.
The three feature bands: checked claims beside an incident timeline, an inset brand band of three feature cards, and three numbered steps.
Questions
FaqBand takes one Accordion, or two for a pair of columns that stack into one list on a
narrow screen:
import { ChevronDownIcon } from "@sdxc/icons";
import { Accordion } from "@sdxc/ui";
import { FaqBand } from "@sdxc/ui/landing";
const QUESTIONS = [
[
"How often do checks run?",
"Every thirty seconds on paid plans and every three minutes on the free one. Each check runs from every region you pick, so one slow network never decides the result on its own.",
],
[
"Can I use my own domain?",
"Yes. Point a CNAME at your status page and the certificate is issued for you within a few minutes. Subscribers keep their emails when you switch domains.",
],
[
"Is there a free plan?",
"Five monitors and one status page, free for as long as you need them. No card is required, and upgrading keeps every check's history.",
],
] as const;
export function Faq() {
return () => (
<FaqBand eyebrow="FAQ" title="Questions, answered">
<Accordion>
{QUESTIONS.map(([question, answer]) => (
<Accordion.Item key={question} name="faq">
<Accordion.Trigger>
{question}
<ChevronDownIcon data-slot="icon" aria-hidden="true" />
</Accordion.Trigger>
<Accordion.Content>
<p>{answer}</p>
</Accordion.Content>
</Accordion.Item>
))}
</Accordion>
</FaqBand>
);
}A shared name makes the items exclusive, so opening one question closes the one before.
The questions band, with three questions that open one at a time.
Header and footer
SiteHeader is the sticky bar above the hero, and SiteFooter closes the page. Both take
their links as data, so the header can draw each one twice: in the bar on a wide screen, and
in a full-screen phone sheet behind the menu button. A group with columns opens a mega
panel on a wide screen and a disclosure in the sheet. The cta stays in the bar at every
width and repeats full width at the foot of the sheet. Panels and the sheet open through the
Popover API and <dialog>, so they work before any script loads.
import { SiteHeader } from "@sdxc/ui/landing";
export function Header() {
return () => (
<SiteHeader
brand={
<a href="/" aria-label="Beacon home">
<BeaconLogo />
</a>
}
items={[
{
label: "Product",
columns: [
{
title: "Monitor",
links: [{ label: "Uptime", href: "/uptime" }],
},
],
},
{ label: "Pricing", href: "/pricing" },
]}
actions={[{ label: "Log in", href: "/login" }]}
cta={{ label: "Start free", href: "/signup" }}
/>
);
}The bar starts transparent and fills once the page scrolls under it; variant="floating"
holds it off the edges as a rounded bar. On a one-page site, where the links are anchors on the
same page, pass <DialogDismiss /> from @sdxc/ui through sheet: it closes the sheet when
a link is followed or the backdrop is pressed. It is a client island, so the browser entry
imports @sdxc/ui/dialog-dismiss for it. SiteFooter takes columns the same way, plus newsletter, social, legal and an
optional full-width wordmark, and picks a column count that never leaves a short last row.
Motion and more bands
Motion is opt-in and honors prefers-reduced-motion. entrance({ order }) from
@sdxc/ui/animations staggers the hero's parts on load, and viewReveal() fades a band in as it
scrolls into view. Marquee scrolls a logo row, RotatingText cycles a word in a headline,
and MotionLoop wraps any longer loop. Each looping piece carries a pause toggle and holds still
for a visitor who asks for less motion.
The rest of the patterns cover bands most landings reach for sooner or later:
ChangelogStrip: the latest releases on a ruled line, dated.SponsorWall: sponsor tiers in ruled grids, then an avatar cloud of individual backers.AuthorBand: a portrait beside the author's statement, with socials and an intro video.Curriculum: course parts with expandable lessons, totals and a "Free" badge.SegmentedStage: a pill switcher over hero media, so one stage shows several views.RailsandCrosshair: frame the content column with hairline rails and "+" marks.LogoSentence: customer marks set inline in one line of prose.FlowDiagram: an animated run, a trigger and then each step passing along a rail, for a pipeline or a scheduled check shown in the product's own syntax.
So the bands on one page don't all share a shape, most of them come in more than one:
LogoCloudtakeslayout="tiles","ruled"or"marquee".Feature.Gridtakesvariant="list"for claims without cards, and aFeaturetakesspan={2}to widen it.LandingHeadertakeslayout="split",tone="statement", asubtitleor anaction.TestimonialBandtakes afeaturedquote andlayout="carousel".AccentTexttakesvariant="sheen","pill"or"chip".LandingHerotakes areleasepin and anentrance.
Your brand
The bands read their measure, spacing, type and CTA shape from --ui-landing-* variables
that @sdxc/ui/theme.css declares. Override them after the theme, in the same stylesheet as
your palette:
:root {
--ui-landing-measure: 76rem;
--ui-landing-cta-radius: 9999px;
--ui-landing-display-max: 6rem;
--ui-landing-lead-weight: 500;
--ui-font-display: "Inter Display", var(--ui-font-sans);
}That gives a wider column, pill-shaped calls to action, a larger top step for the hero
headline, heavier leads, and a display face for headlines. Sizes and band spacing are fluid in
the width of the band, so a phone gets the tight end of each without a breakpoint of yours.
Colors stay on your palette's brand and neutral scales, so the landing and the app share
them. Landing foundations lists every token.
A band of your own
When no pattern fits, build the band from the primitives the patterns use. LandingSection
paints the band and centers its column; LandingHeader gives it a title; Split, Bento
or anything else fills it:
import { CtaGroup, LinkButton } from "@sdxc/ui";
import {
CheckList,
CheckRow,
CodeStage,
LandingHeader,
LandingSection,
Split,
} from "@sdxc/ui/landing";
import { viewReveal } from "@sdxc/ui/animations";
export function PricingTeaser() {
return () => (
<LandingSection tone="tinted" ruled mix={[viewReveal()]}>
<LandingHeader
eyebrow="Pricing"
description="One price per monitor, billed monthly."
>
Pay for what you watch
</LandingHeader>
<Split lean="copy">
<Split.Copy>
<CheckList>
<CheckRow>Free for five monitors</CheckRow>
<CheckRow>No card required</CheckRow>
</CheckList>
<CtaGroup>
<LinkButton href="/pricing" color="brand">
Compare plans
</LinkButton>
</CtaGroup>
</Split.Copy>
<Split.Media>
<CodeStage variant="terminal" title="~">
$ beacon monitors add https://api.example.com
</CodeStage>
</Split.Media>
</Split>
</LandingSection>
);
}viewReveal() from @sdxc/ui/animations brings a band in as it scrolls into view, and drops
the movement for a visitor who asks for reduced motion. It works on any band, patterns included,
through their mix prop.
The pricing band: a tinted section with two checked claims and a link beside a terminal.
Test the outline
The page renders on the server, so a test renders it to a string and reads the headings.
It holds the outline to one <h1>, first, with every heading after it at most one level
deeper than the one before. A page nested under its own heading, such as a landing inside a
docs article, wraps itself in a HeadingScope at that article's level and every heading
shifts down with it:
import { renderToString } from "remix/component/server";
import { expect, test } from "vitest";
import { Landing } from "~/resources/components/landing";
test("opens with one h1 and never skips a heading level", async () => {
let html = await renderToString(<Landing />);
let levels = [...html.matchAll(/<h([1-6])\b/g)].map((match) => Number(match[1]));
expect(levels[0]).toBe(1);
expect(levels.filter((level) => level === 1)).toHaveLength(1);
for (let [index, level] of levels.slice(1).entries()) {
expect(level).toBeLessThanOrEqual((levels[index] ?? 1) + 1);
}
});Where to go next
The demo gallery — a SaaS, an app and a book landing built only from these bands, each with its own palette and type.
The Landing group in the
@sdxc/uicatalogue — a live demo and the full props of each band and primitive.Translate your app — replace the literal copy above with messages, since every string is already a prop.