Routed contact cards
Four routes with explicit response expectations instead of one form.
Live preview
Source
This exact file renders the preview above.
import { ArrowRight, Briefcase, LifeBuoy, MessageSquare, Newspaper } from 'lucide-react'
import Link from 'next/link'
import { Container } from '@/components/ui/layout'
/**
* Routed contact cards
*
* Instead of one form for everything, four routes with explicit response
* expectations. Setting the expectation before the enquiry is what stops the
* follow-up email three days later asking whether anyone received it.
*/
const routes = [
{
icon: MessageSquare,
title: 'General enquiry',
description: 'Product questions, "does it do X", anything unclear.',
response: 'Two working days',
href: '/forms/detailed-contact',
},
{
icon: LifeBuoy,
title: 'Support',
description: 'Something is broken. Severity routing and reproduction fields.',
response: 'Four hours at high severity',
href: '/forms/support-request',
},
{
icon: Briefcase,
title: 'Sales',
description: 'Plans, procurement, security questionnaires.',
response: 'One working day',
href: '/forms/sales-inquiry',
},
{
icon: Newspaper,
title: 'Partnerships',
description: 'Integrations, reselling, content and community.',
response: 'Reviewed fortnightly',
href: '/forms/partnership',
},
]
export default function RoutedContactCards() {
return (
<section className="border-b border-line bg-canvas py-section">
<Container>
<div className="max-w-2xl">
<h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
Pick the right door.
</h2>
<p className="mt-3 text-md text-ink-muted">
Four routes, each with a stated response time.
</p>
</div>
<ul className="mt-10 grid gap-4 sm:grid-cols-2">
{routes.map((route) => {
const Icon = route.icon
return (
<li key={route.href}>
<Link
href={route.href}
className="group flex h-full gap-4 rounded-xl border border-line bg-surface p-6 transition-colors hover:border-accent"
>
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-accent-soft-ink">
<Icon className="size-5" aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-2">
<span className="text-md font-semibold text-ink-strong group-hover:text-accent">
{route.title}
</span>
<ArrowRight
className="size-4 shrink-0 text-ink-subtle transition-transform group-hover:translate-x-0.5"
aria-hidden="true"
/>
</span>
<span className="mt-1.5 block text-sm leading-relaxed text-ink-muted">
{route.description}
</span>
<span className="label-caps mt-3 block text-ink-subtle">
Response — {route.response}
</span>
</span>
</Link>
</li>
)
})}
</ul>
</Container>
</section>
)
}
components/blocks/sections/contact/routed-cards.tsx
import { ArrowRight, Briefcase, LifeBuoy, MessageSquare, Newspaper } from 'lucide-react'
import Link from 'next/link'
import { Container } from '@/components/ui/layout'
/**
* Routed contact cards
*
* Instead of one form for everything, four routes with explicit response
* expectations. Setting the expectation before the enquiry is what stops the
* follow-up email three days later asking whether anyone received it.
*/
const routes = [
{
icon: MessageSquare,
title: 'General enquiry',
description: 'Product questions, "does it do X", anything unclear.',
response: 'Two working days',
href: '/forms/detailed-contact',
},
{
icon: LifeBuoy,
title: 'Support',
description: 'Something is broken. Severity routing and reproduction fields.',
response: 'Four hours at high severity',
href: '/forms/support-request',
},
{
icon: Briefcase,
title: 'Sales',
description: 'Plans, procurement, security questionnaires.',
response: 'One working day',
href: '/forms/sales-inquiry',
},
{
icon: Newspaper,
title: 'Partnerships',
description: 'Integrations, reselling, content and community.',
response: 'Reviewed fortnightly',
href: '/forms/partnership',
},
]
export default function RoutedContactCards() {
return (
<section className="border-b border-line bg-canvas py-section">
<Container>
<div className="max-w-2xl">
<h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
Pick the right door.
</h2>
<p className="mt-3 text-md text-ink-muted">
Four routes, each with a stated response time.
</p>
</div>
<ul className="mt-10 grid gap-4 sm:grid-cols-2">
{routes.map((route) => {
const Icon = route.icon
return (
<li key={route.href}>
<Link
href={route.href}
className="group flex h-full gap-4 rounded-xl border border-line bg-surface p-6 transition-colors hover:border-accent"
>
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-accent-soft text-accent-soft-ink">
<Icon className="size-5" aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-2">
<span className="text-md font-semibold text-ink-strong group-hover:text-accent">
{route.title}
</span>
<ArrowRight
className="size-4 shrink-0 text-ink-subtle transition-transform group-hover:translate-x-0.5"
aria-hidden="true"
/>
</span>
<span className="mt-1.5 block text-sm leading-relaxed text-ink-muted">
{route.description}
</span>
<span className="label-caps mt-3 block text-ink-subtle">
Response — {route.response}
</span>
</span>
</Link>
</li>
)
})}
</ul>
</Container>
</section>
)
}
components/ui/layout.tsx
import type { ElementType, HTMLAttributes, ReactNode } from 'react'
import { cn } from '@/lib/cn'
/**
* Layout primitives
*
* Five zero-JavaScript building blocks that account for the majority of
* structure in the library. They exist so that spacing decisions are made once,
* against density tokens, instead of being re-typed as ad-hoc utilities in
* every section.
*/
export interface ContainerProps extends HTMLAttributes<HTMLDivElement> {
/** `prose` narrows to a comfortable reading measure. */
size?: 'prose' | 'narrow' | 'default' | 'wide' | 'full'
as?: ElementType
/** Removes the responsive horizontal gutter. */
bleed?: boolean
}
const containerSizes = {
prose: 'max-w-[var(--layout-prose-max)]',
narrow: 'max-w-3xl',
default: 'max-w-[var(--layout-content-max)]',
wide: 'max-w-[110rem]',
full: 'max-w-none',
} as const
export function Container({
size = 'default',
as: Tag = 'div',
bleed = false,
className,
children,
...props
}: ContainerProps) {
return (
<Tag
className={cn(
'mx-auto w-full',
containerSizes[size],
!bleed && 'px-4 sm:px-6 lg:px-8',
className,
)}
{...props}
>
{children}
</Tag>
)
}
export interface StackProps extends HTMLAttributes<HTMLDivElement> {
direction?: 'row' | 'column'
gap?: 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'
align?: 'start' | 'center' | 'end' | 'stretch' | 'baseline'
justify?: 'start' | 'center' | 'end' | 'between' | 'around'
wrap?: boolean
as?: ElementType
}
const gapMap = {
none: 'gap-0',
xs: 'gap-1',
sm: 'gap-2',
md: 'gap-gap',
lg: 'gap-stack',
xl: 'gap-8',
} as const
const alignMap = {
start: 'items-start',
center: 'items-center',
end: 'items-end',
stretch: 'items-stretch',
baseline: 'items-baseline',
} as const
const justifyMap = {
start: 'justify-start',
center: 'justify-center',
end: 'justify-end',
between: 'justify-between',
around: 'justify-around',
} as const
export function Stack({
direction = 'column',
gap = 'md',
align,
justify,
wrap = false,
as: Tag = 'div',
className,
children,
...props
}: StackProps) {
return (
<Tag
className={cn(
'flex',
direction === 'column' ? 'flex-col' : 'flex-row',
gapMap[gap],
align && alignMap[align],
justify && justifyMap[justify],
wrap && 'flex-wrap',
className,
)}
{...props}
>
{children}
</Tag>
)
}
export interface GridProps extends HTMLAttributes<HTMLDivElement> {
/** Column count at the largest breakpoint; smaller breakpoints step down. */
cols?: 1 | 2 | 3 | 4 | 5 | 6
gap?: StackProps['gap']
as?: ElementType
/** Auto-fit tracks with a minimum width instead of a fixed column count. */
minItemWidth?: string
}
const colMap = {
1: 'grid-cols-1',
2: 'grid-cols-1 sm:grid-cols-2',
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
5: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-5',
6: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6',
} as const
export function Grid({
cols = 3,
gap = 'lg',
as: Tag = 'div',
minItemWidth,
className,
style,
children,
...props
}: GridProps) {
return (
<Tag
className={cn('grid', minItemWidth ? undefined : colMap[cols], gapMap[gap], className)}
style={
minItemWidth
? {
...style,
gridTemplateColumns: `repeat(auto-fit, minmax(min(${minItemWidth}, 100%), 1fr))`,
}
: style
}
{...props}
>
{children}
</Tag>
)
}
export interface DividerProps extends HTMLAttributes<HTMLDivElement> {
orientation?: 'horizontal' | 'vertical'
/** Renders a centred text label interrupting the rule. */
label?: ReactNode
weight?: 'subtle' | 'default' | 'strong'
}
const dividerWeight = {
subtle: 'border-line-subtle',
default: 'border-line',
strong: 'border-line-strong',
} as const
export function Divider({
orientation = 'horizontal',
label,
weight = 'default',
className,
...props
}: DividerProps) {
if (orientation === 'vertical') {
return (
<div
role="separator"
aria-orientation="vertical"
className={cn('h-full w-px self-stretch border-l', dividerWeight[weight], className)}
{...props}
/>
)
}
if (label) {
return (
<div className={cn('flex items-center gap-3', className)} {...props}>
<span className={cn('h-px flex-1 border-t', dividerWeight[weight])} role="separator" />
<span className="label-caps text-ink-subtle">{label}</span>
<span className={cn('h-px flex-1 border-t', dividerWeight[weight])} aria-hidden="true" />
</div>
)
}
return (
<div
role="separator"
className={cn('w-full border-t', dividerWeight[weight], className)}
{...props}
/>
)
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
Setting the expectation before the enquiry is what stops the follow-up email three days later asking whether anyone received it.
- Every route needs a stated response time, or the promise is implied and unbounded.
- Route to forms that genuinely exist and are genuinely different.
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.
- Four routes
- Stated response time
- Whole-card links
Accessibility
- Whole-card links
- Each route is one focus stop.
- Expectations in text
- Response times are readable text, not a tooltip.
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 sectionsFAQ with an escape hatch
Four common questions and a prominent route to a human.
starter3 variantsSplit contact section
Contact details on one side, the library's own contact form on the other.
starterFeatured3 variantsSupport request
Severity, area, reproduction steps and attachments — with the response time shown before submitting.
intermediateFeatured4 variants