SaaS hero
Announcement pill, centred headline, paired actions and a three-item objection-handling row.
Live preview
Source
This exact file renders the preview above.
import { ArrowRight, Check } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
/**
* SaaS header
*
* Announcement pill, headline, subhead, paired actions and three
* objection-handling proof points. The proof row is the part that earns its
* place — "no credit card" answers the question the primary button raises.
*/
const proof = ['No credit card required', '14-day trial', 'Cancel anytime']
export default function SaasHeader() {
return (
<header className="border-b border-line bg-canvas py-section">
<Container className="text-center">
<Badge tone="accent" appearance="soft" className="mx-auto">
Foundry 1.0 is available
</Badge>
<h1 className="display-type mx-auto mt-6 max-w-3xl text-3xl leading-tight font-semibold text-ink-strong sm:text-4xl lg:text-5xl">
Ship your interface in an afternoon, not a quarter
</h1>
<p className="mx-auto mt-5 max-w-2xl text-md text-ink-muted sm:text-lg">
Every primitive, section and page pattern your product needs — accessible, themeable and
server-rendered by default.
</p>
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
<ButtonLink
href="/starters/saas/preview/register"
size="lg"
trailingIcon={<ArrowRight className="size-4" />}
>
Start free trial
</ButtonLink>
<ButtonLink href="/patterns" size="lg" variant="outline">
See it assembled
</ButtonLink>
</div>
<ul className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-2">
{proof.map((item) => (
<li key={item} className="flex items-center gap-1.5 text-sm text-ink-muted">
<Check className="size-4 shrink-0 text-success" aria-hidden="true" />
{item}
</li>
))}
</ul>
</Container>
</header>
)
}
components/blocks/headers/saas.tsx
import { ArrowRight, Check } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
/**
* SaaS header
*
* Announcement pill, headline, subhead, paired actions and three
* objection-handling proof points. The proof row is the part that earns its
* place — "no credit card" answers the question the primary button raises.
*/
const proof = ['No credit card required', '14-day trial', 'Cancel anytime']
export default function SaasHeader() {
return (
<header className="border-b border-line bg-canvas py-section">
<Container className="text-center">
<Badge tone="accent" appearance="soft" className="mx-auto">
Foundry 1.0 is available
</Badge>
<h1 className="display-type mx-auto mt-6 max-w-3xl text-3xl leading-tight font-semibold text-ink-strong sm:text-4xl lg:text-5xl">
Ship your interface in an afternoon, not a quarter
</h1>
<p className="mx-auto mt-5 max-w-2xl text-md text-ink-muted sm:text-lg">
Every primitive, section and page pattern your product needs — accessible, themeable and
server-rendered by default.
</p>
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
<ButtonLink
href="/starters/saas/preview/register"
size="lg"
trailingIcon={<ArrowRight className="size-4" />}
>
Start free trial
</ButtonLink>
<ButtonLink href="/patterns" size="lg" variant="outline">
See it assembled
</ButtonLink>
</div>
<ul className="mt-8 flex flex-wrap items-center justify-center gap-x-6 gap-y-2">
{proof.map((item) => (
<li key={item} className="flex items-center gap-1.5 text-sm text-ink-muted">
<Check className="size-4 shrink-0 text-success" aria-hidden="true" />
{item}
</li>
))}
</ul>
</Container>
</header>
)
}
components/ui/badge.tsx
import type { HTMLAttributes, ReactNode } from 'react'
import { variants } from '@/lib/variants'
/**
* Badge
*
* A compact, non-interactive label. Tones map to the status token trios, and
* because a badge is often the only signal in a dense table, the `dot` option
* exists to add a second, colour-independent cue alongside the text.
*/
const badgeVariants = variants('inline-flex items-center gap-1.5 whitespace-nowrap font-medium', {
variants: {
tone: {
neutral: 'bg-surface-sunken text-ink-muted border-line',
accent: 'bg-accent-soft text-accent-soft-ink border-accent-line',
success: 'bg-success-soft text-success border-success-line',
warning: 'bg-warning-soft text-warning border-warning-line',
danger: 'bg-danger-soft text-danger border-danger-line',
info: 'bg-info-soft text-info border-info-line',
inverse: 'bg-surface-inverse text-ink-inverse border-transparent',
},
appearance: {
soft: 'border',
outline: 'border bg-transparent',
solid: 'border border-transparent',
},
size: {
sm: 'h-4.5 rounded-sm px-1.5 text-2xs',
md: 'h-5.5 rounded-sm px-2 text-xs',
},
},
defaultVariants: { tone: 'neutral', appearance: 'soft', size: 'md' },
compound: [
{ appearance: 'solid', tone: 'accent', class: 'bg-accent text-accent-ink' },
{ appearance: 'solid', tone: 'success', class: 'bg-success text-white' },
{ appearance: 'solid', tone: 'warning', class: 'bg-warning text-white' },
{ appearance: 'solid', tone: 'danger', class: 'bg-danger text-white' },
{ appearance: 'solid', tone: 'info', class: 'bg-info text-white' },
{ appearance: 'solid', tone: 'neutral', class: 'bg-ink text-ink-inverse' },
{ appearance: 'outline', tone: 'neutral', class: 'text-ink-muted' },
],
})
export type BadgeTone = 'neutral' | 'accent' | 'success' | 'warning' | 'danger' | 'info' | 'inverse'
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
tone?: BadgeTone
appearance?: 'soft' | 'outline' | 'solid'
size?: 'sm' | 'md'
/** Adds a leading dot so the badge does not rely on hue alone. */
dot?: boolean
icon?: ReactNode
}
export function Badge({
tone = 'neutral',
appearance = 'soft',
size = 'md',
dot = false,
icon,
className,
children,
...props
}: BadgeProps) {
return (
<span className={badgeVariants({ tone, appearance, size, className })} {...props}>
{dot ? (
<span className="size-1.5 shrink-0 rounded-full bg-current" aria-hidden="true" />
) : null}
{icon}
{children}
</span>
)
}
components/ui/button.tsx
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import Link from 'next/link'
import { cn } from '@/lib/cn'
import { variants } from '@/lib/variants'
import { Spinner } from './spinner'
/**
* Button
*
* The whole action surface of Foundry in one component. It is intentionally a
* *shared* component (no `'use client'`): rendered from a Server Component it
* ships zero JavaScript, and it upgrades to a client island automatically when
* a Client Component imports it.
*
* Height, padding and radius resolve from density and radius tokens, so a
* button restyles itself when the density or palette axis changes. Focus is
* handled once, globally, by the `:focus-visible` rule in `globals.css`.
*/
const buttonVariants = variants(
cn(
'relative inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium',
'transition-colors duration-150 ease-standard select-none',
'disabled:pointer-events-none disabled:opacity-50',
'aria-disabled:pointer-events-none aria-disabled:opacity-50',
),
{
variants: {
variant: {
primary: 'bg-accent text-accent-ink hover:bg-accent-hover active:bg-accent-active',
secondary: 'bg-surface-inverse text-ink-inverse hover:opacity-90 active:opacity-80',
outline: 'border border-line-strong bg-surface text-ink hover:bg-surface-sunken',
ghost: 'text-ink hover:bg-surface-sunken active:bg-surface-sunken',
destructive: 'bg-danger text-white hover:opacity-90 active:opacity-80',
success: 'bg-success text-white hover:opacity-90 active:opacity-80',
warning: 'bg-warning text-white hover:opacity-90 active:opacity-80',
link: 'text-accent underline underline-offset-4 hover:text-accent-hover',
soft: 'bg-accent-soft text-accent-soft-ink border border-accent-line hover:brightness-[0.97]',
},
size: {
sm: 'h-control-sm px-3 text-xs rounded-md',
md: 'h-control px-[var(--density-control-padding-x)] text-sm rounded-md',
lg: 'h-control-lg px-5 text-base rounded-md',
icon: 'h-control w-control p-0 rounded-md',
'icon-sm': 'h-control-sm w-control-sm p-0 rounded-sm',
},
block: { true: 'w-full', false: '' },
},
defaultVariants: { variant: 'primary', size: 'md', block: false },
compound: [
{ variant: 'link', size: 'sm', class: 'h-auto px-0' },
{ variant: 'link', size: 'md', class: 'h-auto px-0' },
{ variant: 'link', size: 'lg', class: 'h-auto px-0' },
],
},
)
export type ButtonVariant =
| 'primary'
| 'secondary'
| 'outline'
| 'ghost'
| 'destructive'
| 'success'
| 'warning'
| 'link'
| 'soft'
export type ButtonSize = 'sm' | 'md' | 'lg' | 'icon' | 'icon-sm'
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant
size?: ButtonSize
block?: boolean
/** Swaps content for a spinner while preserving the button's measured width. */
loading?: boolean
/** Announced by assistive tech while `loading` is true. */
loadingLabel?: string
leadingIcon?: ReactNode
trailingIcon?: ReactNode
}
export function Button({
variant = 'primary',
size = 'md',
block = false,
loading = false,
loadingLabel = 'Working',
leadingIcon,
trailingIcon,
className,
children,
disabled,
type = 'button',
...props
}: ButtonProps) {
return (
<button
type={type}
className={buttonVariants({ variant, size, block, className })}
disabled={disabled ?? loading}
aria-busy={loading || undefined}
{...props}
>
{loading ? (
<>
{/* Label stays in the DOM but hidden so the control never collapses
to spinner width halfway through an interaction. */}
<span className="invisible flex items-center gap-2" aria-hidden="true">
{leadingIcon}
{children}
{trailingIcon}
</span>
<span className="absolute inset-0 flex items-center justify-center">
<Spinner size="sm" />
<span className="sr-only">{loadingLabel}</span>
</span>
</>
) : (
<>
{leadingIcon}
{children}
{trailingIcon}
</>
)}
</button>
)
}
export interface ButtonLinkProps {
href: string
variant?: ButtonVariant
size?: ButtonSize
block?: boolean
className?: string
children?: ReactNode
leadingIcon?: ReactNode
trailingIcon?: ReactNode
'aria-label'?: string
'aria-current'?: 'page' | 'step' | 'true' | undefined
target?: string
rel?: string
prefetch?: boolean
}
/** Anchor styled as a button, for when the action is really navigation. */
export function ButtonLink({
href,
variant = 'primary',
size = 'md',
block = false,
className,
children,
leadingIcon,
trailingIcon,
...props
}: ButtonLinkProps) {
return (
<Link href={href} className={buttonVariants({ variant, size, block, className })} {...props}>
{leadingIcon}
{children}
{trailingIcon}
</Link>
)
}
export { buttonVariants }
Demo source — adapt to your project. Foundry is not published as a package.
Usage
The conventional SaaS hero, done carefully. The proof row is the part that earns its place: "no credit card required" answers the question the primary button raises.
- Three proof points, maximum. A fourth reads as anxiety.
- Actions stack vertically below `sm` with the primary first.
Variants and states
Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.
- Announcement pill
- Dual action
- Proof row
Accessibility
- Icons
- Tick icons are decorative; the text carries the meaning.
- Reading order
- Badge, heading, subhead, actions, proof — visual and DOM order match.
Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.
Related
All navigationSaaS landing page
Eleven blocks in the order a subscription product needs: claim, proof, mechanism, evidence, price, objections, action.
intermediateFeatured4 variantsHero with statistics band
A claim above a four-figure band, with every figure read from the live catalogue.
starterFeatured3 variantsSaaS starter
A twelve-route subscription product: marketing site, authentication, an in-product dashboard and account management, all from the same component set.
advancedFeatured4 variants