SearchCtrlK

  • ↑↓navigate
  • ↵open
  • escclose

Can’t find it? Contact us

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.

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.

Terminal
npm add @sdxc/icons @sdxc/ui remix

The 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

LayerImport fromWhat it gives you
Patterns@sdxc/ui/landingWhole bands: LandingHero, ProofStrip, FeatureBand, PricingBand, TestimonialBand, FaqBand, ClosingCta
Primitives@sdxc/uiThe parts bands are made of: LandingSection, LandingHeader, Split, StatStrip, LogoCloud, Feature, CtaGroup
Foundations@sdxc/ui/stylesTokens 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.

resources/components/landing.tsx
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:

resources/components/landing/hero-shot.tsx
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:

resources/components/landing/proof.tsx
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 beside media. Children that are a <ul> render as checked claims.

  • "grid" lays Feature cards in columns, two on a tablet and all of them on a wide band.

  • "steps" numbers StepList.Item children for a "how it works" band.

  • "stage" lists TabbedStage.Item children down a rail beside a sticky stage that shows the selected one's media.

  • "timeline" sets StepList.Item children on a vertical rule, each with a meta line and optional media.

resources/components/landing/features.tsx
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:

resources/components/landing/faq.tsx
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.

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.

resources/components/landing/header.tsx
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.

  • Rails and Crosshair: 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:

  • LogoCloud takes layout="tiles", "ruled" or "marquee".

  • Feature.Grid takes variant="list" for claims without cards, and a Feature takes span={2} to widen it.

  • LandingHeader takes layout="split", tone="statement", a subtitle or an action.

  • TestimonialBand takes a featured quote and layout="carousel".

  • AccentText takes variant="sheen", "pill" or "chip".

  • LandingHero takes a release pin and an entrance.

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:

resources/css/marketing.css
: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:

resources/components/landing/pricing-teaser.tsx
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:

resources/components/landing.test.tsx
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/ui catalogue — 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.

Documentation

Can’t find it? Contact us