Layout & Spacing
Finly’s layout has one container width, seven breakpoints and a band rhythm that repeats on every page. Once you can see the rhythm, adding a section to an existing page is mostly a matter of deciding which band it belongs to.
The container
One class, defined in global.css:
.site-container {
@apply mx-auto max-w-6xl px-4;
}
max-w-6xl is 1152px, which is the design’s own container token. Everything on the site sits inside it except the bands that deliberately bleed to the viewport edge.
Breakpoints
Seven, declared explicitly in tailwind-theme.css — six of them mirror Tailwind’s defaults so they are visible rather than implicit, and one is new:
--breakpoint-xs: 400px;
--breakpoint-sm: 640px;
--breakpoint-md: 768px;
--breakpoint-lg: 1024px;
--breakpoint-xl: 1280px;
--breakpoint-2xl: 1536px;
xs exists for the handful of places where a card’s internals need to reflow before 640px.
The header’s own breakpoint is lg rather than md, and that choice was measured rather than reasoned: the rendered pill is 717px and the lockup 100px, so with a 24px minimum gap the row needs 841px. md gives the container only 736px, which does not overflow but lands visibly cramped. lg gives 992px.
The band rhythm
Every page is a stack of bands, and each band is one <section> carrying the site’s vertical rhythm. That is the Section primitive:
<Section spacing="lg">
<SectionHead eyebrow="Pricing" title="Three plans" />
<!-- … -->
</Section>
export const section = tv({
base: "relative isolate",
variants: {
spacing: {
none: "",
sm: "py-12 md:py-16",
md: "py-16 md:py-24",
lg: "py-24 md:py-32",
},
clip: { true: "overflow-clip" },
},
defaultVariants: { spacing: "md" },
});
It exists because every band repeated the same three decisions — the element, the .site-container wrapper and the py-* pair — and those drifting apart across a dozen sections is exactly the rhythm bug a design system is for.
Three details in it are load-bearing.
contained={false} is the full-bleed escape hatch, and it is not an edge case. A band whose art runs to the viewport edge — the home hero’s photograph — has to position its own container so the copy and the bleed can disagree. Those bands still get the element, the rhythm and the stacking context from here.
isolate is on the base deliberately. Bands layer background art under their copy with negative z-index, and without a stacking context of its own that art escapes behind the page background instead of staying behind its own section.
clip is overflow-clip, never overflow-hidden. A hidden box is a scroll container, which would freeze any timeline-* scroll-driven animation inside it. This is the single most common way to silently break motion in this theme, and it is why the variant exists at all rather than leaving callers to pick.
Section also destructures data-slot rather than leaving it in ...rest, which is a real fix rather than tidying: the spread lands after the literal attribute, so a caller passing its own data-slot used to emit the attribute twice — and an HTML parser keeps the first, silently killing the caller’s styling hook.
Section heads
Six bands draw the same eyebrow / 42px heading / lead / trailing-link ladder at identical offsets, so SectionHead owns it — including the reveal choreography, which cascades eyebrow, then heading characters, then lead sentences.
title and lead are props rather than slots, and that is not a style choice: SplitText and SentenceReveal split their text at build time, so the server needs the string itself, not rendered children.
The site chrome
The header is fixed and floating. A transparent bar carrying two things: the Finly lockup on the left and a frosted pill on the right holding the nav triggers, the theme toggle, “Log in” and “Book a demo”. Nothing sits behind them — page content scrolls visibly through the gap, which is the design rather than an omission.
It is deliberately not hide-on-scroll. The design draws one state, so a retracting bar would be behaviour nobody asked for.
The four nav triggers each open a mega panel through the MegaMenu primitive, which is the native Popover API — top layer, so nothing clips it, with native light-dismiss, Escape and focus return. Below lg the whole thing collapses into a Sheet (a native <dialog> pinned to an edge). Both the link row and the action pair are shared between the bar and the panel, so each lives in its own component rather than being written twice.
The footer is permanently dark. The design draws it at #101828 in a theme whose default canvas is warm olive, so it is an inverted surface rather than a themed one — it must look the same in both themes. The lazy way to pin that is not ten palette overrides; it is class="dark" on the <footer>, which re-points every semantic token inside the subtree in one class. See Colors & Theming for how the variant makes that work.
The footer’s active-link state is an exact match, deliberately diverging from the header’s prefix match. The header asks “am I in this section?” so a section link stays lit on its children; the footer’s rows are leaves, and prefix-matching a leaf list would mark two rows current on a nested page.
The page shell
BaseLayout owns <html>, <head> (via BaseHead) and <body>, plus the two pieces of global chrome. The body is a column:
<body class="flex min-h-[100lvh] flex-col">
<a href="#main" class="… sr-only focus:not-sr-only …">Skip to content</a>
{chrome && <Header />}
<main id="main" class:list={["grow", chrome && "pt-18 md:pt-22"]}>
<slot />
</main>
{chrome && <Footer />}
</body>
The column flex plus a growing <main> is the sticky footer, and it is not decoration. Without it, min-h-[100lvh] stretches the body but nothing pushes the footer down, so any page shorter than the viewport renders the olive canvas below the dark band — measured at 595px of it on a 1200px viewport. Every page today has a full-height hero, which hides the bug rather than fixing it.
The pt-18 md:pt-22 on <main> clears the fixed header. It is the bar’s height plus its float, matching the header’s own top-4 md:top-6. A full-bleed hero that wants the bar overlaid cancels it with a negative margin.
The skip link is the first focusable thing in the document, because the header puts a nav landmark ahead of the content on every page.
chrome is a prop, not a second layout. The three account screens are drawn with no header and no footer — a form with a mega menu over it invites the reader back out of the funnel they just entered — so they pass chrome={false}. Everything above the <body> is what a page shell actually is, and a parallel AuthLayout would be a second copy of all of it kept in step by hand. What the auth pages genuinely do not want is three lines of it. Note that with no header there is nothing to clear, so the padding drops too — keeping it would push a full-height auth screen 72px past the fold.
Adding a band
- Write it as a component under
src/components/<Feature>/. - Wrap it in
<Section spacing="…">, orcontained={false}if its art bleeds. - Open it with
<SectionHead>if it has a heading ladder. - Put its copy in the page’s typed config module, and its type in
src/config/types/. - If more than one page will draw it, put the component above the page folders and its prop type in
src/config/types/bands.ts— the component tree and the type tree mirror each other on purpose, so a shared band never names one page’s config type.