Inverted CTA panel
A dark rounded panel inside a light page.
Live preview
Source
This exact file renders the preview above.
import { ArrowRight } from 'lucide-react'
import { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
/**
* Inverted CTA panel
*
* A dark panel inside a light page — the strongest emphasis available without
* a gradient or a glow. The panel keeps its own padding and radius so it can
* sit between two ordinary sections without touching either.
*/
export default function InvertedPanelCTA() {
return (
<section className="border-b border-line bg-canvas py-section">
<Container>
<div className="relative overflow-hidden rounded-2xl bg-surface-inverse px-6 py-14 text-ink-inverse sm:px-12 lg:px-16">
<div className="grid-paper absolute inset-0 opacity-30" aria-hidden="true" />
<div className="relative grid gap-8 lg:grid-cols-[1.4fr_1fr] lg:items-center">
<div>
<h2 className="display-type text-2xl leading-tight font-semibold sm:text-3xl lg:text-4xl">
Ten complete products, assembled from the components you just read about.
</h2>
<p className="mt-4 max-w-lg text-md opacity-80">
Every route resolves, every starter has a 404 path, and none of them duplicate a
component.
</p>
</div>
<div className="flex flex-wrap gap-3 lg:justify-end">
<ButtonLink
href="/starters"
variant="secondary"
size="lg"
trailingIcon={<ArrowRight className="size-4" />}
>
Browse starters
</ButtonLink>
<ButtonLink
href="/patterns"
variant="ghost"
size="lg"
className="text-current hover:bg-white/10"
>
See page patterns
</ButtonLink>
</div>
</div>
</div>
</Container>
</section>
)
}
components/blocks/sections/cta/inverted-panel.tsx
import { ArrowRight } from 'lucide-react'
import { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
/**
* Inverted CTA panel
*
* A dark panel inside a light page — the strongest emphasis available without
* a gradient or a glow. The panel keeps its own padding and radius so it can
* sit between two ordinary sections without touching either.
*/
export default function InvertedPanelCTA() {
return (
<section className="border-b border-line bg-canvas py-section">
<Container>
<div className="relative overflow-hidden rounded-2xl bg-surface-inverse px-6 py-14 text-ink-inverse sm:px-12 lg:px-16">
<div className="grid-paper absolute inset-0 opacity-30" aria-hidden="true" />
<div className="relative grid gap-8 lg:grid-cols-[1.4fr_1fr] lg:items-center">
<div>
<h2 className="display-type text-2xl leading-tight font-semibold sm:text-3xl lg:text-4xl">
Ten complete products, assembled from the components you just read about.
</h2>
<p className="mt-4 max-w-lg text-md opacity-80">
Every route resolves, every starter has a 404 path, and none of them duplicate a
component.
</p>
</div>
<div className="flex flex-wrap gap-3 lg:justify-end">
<ButtonLink
href="/starters"
variant="secondary"
size="lg"
trailingIcon={<ArrowRight className="size-4" />}
>
Browse starters
</ButtonLink>
<ButtonLink
href="/patterns"
variant="ghost"
size="lg"
className="text-current hover:bg-white/10"
>
See page patterns
</ButtonLink>
</div>
</div>
</div>
</Container>
</section>
)
}
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 strongest emphasis available without a gradient or a glow. The panel keeps its own padding and radius so it can sit between two ordinary sections without touching either.
- One inverted panel per page.
- Keep the panel inside the container, not full-bleed, or it reads as a footer.
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.
- Rounded inverted panel
- Grid-paper layer
- Two actions
Accessibility
- Decorative layer
- The pattern is aria-hidden.
- Contrast
- Foreground uses ink-inverse so contrast holds across palettes.
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 sectionsCentered call to action
One heading, one supporting line, two actions of unequal weight.
starterFeatured3 variantsInverted statistics panel
A dark band of four derived figures, used as punctuation between light sections.
starter3 variantsInverted pattern hero
A dark hero with a drawn grid and a radial mask instead of a gradient.
starter3 variants