Structural Grid (Exposed Grid / Rail Layout) design system for modern SaaS landing pages. Use when building dark-themed marketing sites, landing pages, or SaaS product pages inspired by Linear, Vercel, Resend, and Planetscale. Provides CSS foundations, section patterns, component recipes, and responsive border logic for the visible-grid aesthetic.
Install
npx skillscat add nabinkhair42/structural-grid-skill Install via the SkillsCat registry.
Structural Grid Design System
You are implementing a Structural Grid (also called "Exposed Grid" or "Rail Layout") design pattern. This is the modern SaaS design pattern used by Linear, Vercel, Resend, Profound, and Planetscale — where the underlying page grid is promoted to a first-class visual element.
Core Principles
- Visible structure — Vertical rail lines and horizontal dividers are decorative elements, not hidden scaffolding
- Content lives inside the grid — Components blend into the rail structure rather than floating over it
- Dashed internal, solid external — Rail lines and section dividers are solid; internal grid cell dividers are dashed
- Alternating visual rhythm — Sections alternate between default and dot-pattern backgrounds for depth
- Minimal containers — No rounded-xl bordered cards floating inside sections. Content sits directly within the grid
- Consistent letter-spacing — Use
tracking-wideon all section labels and inline labels. Never mixtracking-widestandtracking-wider - Every card hovers — All grid cells get
transition-colors hover:bg-white/[0.02]for interactive feedback
CSS Foundation
Add these to your global CSS. All measurements derive from a single --rail-offset variable.
/* Vertical rail lines */
.page-rails {
--rail-offset: max(1rem, calc(50% - 36rem)); /* = max-w-6xl centered */
position: relative;
overflow-x: clip; /* clip, NOT hidden — hidden breaks position:sticky */
}
.page-rails::before,
.page-rails::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: var(--border);
pointer-events: none;
z-index: 1;
}
.page-rails::before { left: var(--rail-offset); }
.page-rails::after { right: var(--rail-offset); }
/* Content bounded to rail edges */
.rail-bounded {
margin-left: var(--rail-offset);
margin-right: var(--rail-offset);
}
/* Horizontal section divider between rails */
.section-divider {
position: relative;
height: 1px;
z-index: 2;
}
.section-divider::before {
content: '';
position: absolute;
left: var(--rail-offset, max(1rem, calc(50% - 36rem)));
right: var(--rail-offset, max(1rem, calc(50% - 36rem)));
height: 1px;
background: var(--border);
}
/* Subtle dot pattern for section backgrounds */
.dot-pattern {
background-image: radial-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px);
background-size: 24px 24px;
}
/* Custom scrollbar — matches dark themes */
* {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
}
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.2); }Critical: overflow-x
Always use overflow-x: clip on .page-rails, NEVER overflow-x: hidden.hidden creates a new scroll container which breaks position: sticky on any descendant.clip clips overflow visually without affecting scroll/sticky behavior.
Smooth scroll with sticky navbar offset
When using a sticky navbar with anchor links, add to html:
html {
scroll-behavior: smooth;
scroll-padding-top: 5rem; /* clears the sticky navbar height */
}Adjusting rail width
Change 36rem to match your desired max content width:
32rem= 1024px = Tailwindmax-w-5xl36rem= 1152px = Tailwindmax-w-6xl(recommended default)40rem= 1280px = Tailwindmax-w-7xl
Page Structure
<Navbar />
<div className="page-rails flex flex-col">
<Hero />
<div className="section-divider" aria-hidden="true" />
<SectionA />
<div className="section-divider" aria-hidden="true" />
<SectionB />
<div className="section-divider" aria-hidden="true" />
<Cta />
</div>
<Footer />Every section is separated by a section-divider. The rails run the full height of .page-rails. Navbar and Footer sit outside .page-rails.
Section IDs
Always add id attributes to sections that need anchor links or nav tracking:
<section id="features">
<section id="showcase">
<section id="faq">Section Patterns
1. Text Header (reusable across sections)
<div className="mx-auto w-full max-w-6xl px-6">
<div className="pb-6 pt-16">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Section Label
</p>
<h2 className="mt-3 text-2xl font-bold tracking-tight sm:text-3xl">
Section Title
</h2>
<p className="mt-2 max-w-md text-base text-muted-foreground">
Section description text here.
</p>
</div>
</div>2. Grid with Dashed Internal Dividers
Use rail-bounded to align the grid edges with the rails. Apply border-t border-border to connect the grid's top edge with the section divider above. Use dashed borders between cells.
Responsive border logic for a 3-column grid (1 col mobile, 2 col sm, 3 col lg):
<div className="rail-bounded border-t border-border">
<div className="grid sm:grid-cols-2 lg:grid-cols-3">
{items.map((item, i) => (
<div
key={item.id}
className={`group px-6 py-8 transition-colors hover:bg-white/[0.02]
${i % 3 !== 0 ? "lg:border-l lg:border-dashed lg:border-border" : ""}
${i % 2 !== 0 ? "sm:max-lg:border-l sm:max-lg:border-dashed sm:max-lg:border-border" : ""}
${i >= 3 ? "lg:border-t lg:border-dashed lg:border-border" : ""}
${i >= 2 ? "sm:max-lg:border-t sm:max-lg:border-dashed sm:max-lg:border-border" : ""}
${i >= 1 ? "max-sm:border-t max-sm:border-dashed max-sm:border-border" : ""}
`}
>
{/* cell content */}
</div>
))}
</div>
</div>Border logic rules:
border-l(left) = applied to every cell that is NOT the first in its row at that breakpointborder-t(top) = applied to every cell that is NOT in the first row at that breakpoint- Use
sm:max-lg:prefix for tablet-only borders that differ from desktop - Use
max-sm:prefix for mobile-only borders - All internal borders are
border-dashed border-border - All grid cells include
group transition-colors hover:bg-white/[0.02]for hover feedback
3. Side-by-Side Layout with Full-Height Dashed Divider
For layouts like text + interactive content, use items-stretch so the dashed divider spans the full section height.
<section id="section-name" className="relative">
<div className="dot-pattern absolute inset-0" aria-hidden="true" />
<div className="relative mx-auto grid w-full max-w-6xl items-stretch gap-0 px-6 lg:grid-cols-[1fr_1.6fr]">
<div className="py-16 lg:py-24">
<div className="lg:sticky lg:top-24">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Label</p>
<h2 className="mt-3 text-2xl font-bold tracking-tight sm:text-3xl">Title</h2>
<p className="mt-4 max-w-sm text-base leading-relaxed text-muted-foreground">Description.</p>
</div>
</div>
<div className="pb-16 lg:border-l lg:border-dashed lg:border-border lg:py-24 lg:pl-8">
{/* tall content */}
</div>
</div>
</section>Sticky text requirements:
- Parent
.page-railsmust useoverflow-x: clip(nothidden) items-stretchon the grid makes both columns match the taller column's height- Apply padding to children, not the grid itself
4. Hero Section
<section className="relative flex flex-col items-center px-4 pb-0 pt-24 text-center sm:pt-32">
<div className="pointer-events-none absolute inset-0 z-0 mx-auto hidden w-full max-w-5xl px-4 sm:block" aria-hidden="true">
<div className="absolute left-4 top-0 bottom-0 w-px border-l border-dashed border-border" />
<div className="absolute right-4 top-0 bottom-0 w-px border-r border-dashed border-border" />
</div>
<div className="relative z-10 mb-6 inline-flex items-center gap-2 rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-1.5">
<span className="size-1.5 rounded-full bg-white/40 animate-pulse" />
<span className="text-[13px] text-white/60">Badge Text</span>
</div>
<h1 className="relative z-10 max-w-2xl text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl">
Main headline<br />
<span className="text-muted-foreground">secondary line</span>
</h1>
<p className="relative z-10 mx-auto mt-5 max-w-lg text-base leading-relaxed text-muted-foreground sm:text-lg">
Subtitle description
</p>
<div className="relative z-10 mt-8 flex flex-col items-center gap-3 sm:flex-row">
<Link href="/pricing" className="inline-flex h-10 items-center gap-2 rounded-lg bg-foreground px-5 text-sm font-medium text-background transition-opacity hover:opacity-80">
Primary CTA
</Link>
<Link href="/#features" className="inline-flex h-10 items-center gap-2 rounded-lg border border-white/[0.1] px-5 text-sm font-medium text-foreground transition-colors hover:bg-white/[0.04]">
Secondary CTA
</Link>
</div>
<div className="relative hidden w-full self-stretch sm:block" aria-hidden="true">
<div className="absolute left-0 right-0 border-t border-dashed border-border"
style={{ marginLeft: "calc(var(--rail-offset) - 1rem)", marginRight: "calc(var(--rail-offset) - 1rem)" }} />
</div>
<ProductMockup />
</section>5. CTA Section (Bottom)
<section>
<div className="mx-auto flex w-full max-w-6xl flex-col items-center px-6 py-20 text-center sm:py-28">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">Call to action headline</h2>
<p className="mt-4 max-w-md text-base text-muted-foreground sm:text-lg">Supporting text.</p>
<div className="mt-8 flex flex-col items-center gap-3 sm:flex-row">
<Link href="/pricing" className="inline-flex h-11 w-44 items-center justify-center gap-2 rounded-lg bg-foreground text-sm font-medium text-background transition-opacity hover:opacity-80">Primary CTA</Link>
<Link href="/pricing" className="inline-flex h-11 w-44 items-center justify-center gap-2 rounded-lg border border-white/[0.1] text-sm font-medium text-foreground transition-colors hover:bg-white/[0.04]">Secondary CTA</Link>
</div>
</div>
</section>Component Recipes
Reusable Button Component
const base = "inline-flex items-center justify-center gap-2 rounded-lg font-medium disabled:pointer-events-none disabled:opacity-50";
const variants = {
primary: "bg-foreground text-background shadow-[inset_0_1px_0_rgba(255,255,255,0.25),inset_0_-1px_0_rgba(0,0,0,0.15),0_0_0_1px_rgba(255,255,255,0.2),0_4px_12px_rgba(0,0,0,0.5),0_0_32px_rgba(255,255,255,0.1)] transition-opacity hover:opacity-80",
outline: "border border-white/[0.1] text-foreground transition-colors hover:bg-white/[0.04]",
ghost: "text-muted-foreground transition-colors hover:text-foreground",
} as const;
const sizes = {
sm: "h-9 px-3 text-xs",
md: "h-10 px-5 text-sm",
lg: "h-11 px-6 text-sm",
icon: "size-9 text-sm",
} as const;Reusable Input Component
<input className="h-10 w-full rounded-lg border border-border bg-white/[0.03] px-3 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground/50 focus:border-primary/40 focus:ring-1 focus:ring-primary/20" />Icon Container (Feature Cards)
<div className="mb-4 inline-flex size-10 items-center justify-center rounded-xl border border-white/[0.08] bg-white/[0.03] text-white/60 transition-colors group-hover:text-white/90">
<FeatureIcon size={18} stroke={1.5} />
</div>Responsive Tables
<div className="rail-bounded overflow-x-auto border-t border-border">
<div className="min-w-[600px]">
<div className="grid grid-cols-4">{/* table content */}</div>
</div>
</div>IntersectionObserver Animation Pattern
const observer = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) { setIsVisible(true); observer.disconnect(); } },
{ threshold: 0.3 }
);Staggered entrance:
style={{
opacity: isVisible ? 1 : 0,
transform: isVisible ? "translateY(0)" : "translateY(8px)",
transition: `all 0.4s ease ${0.3 + i * 0.1}s`,
}}Design Tokens Reference
| Element | Solid/Dashed | CSS |
|---|---|---|
| Vertical rails | Solid | background: var(--border) |
| Section dividers | Solid | background: var(--border) |
| Internal grid dividers | Dashed | border-dashed border-border |
| Hero guide lines | Dashed | border-dashed border-border |
| Dot pattern | N/A | radial-gradient with 4% white opacity |
| Card hover | N/A | hover:bg-white/[0.02] |
| Section label | N/A | text-xs font-medium uppercase tracking-wide |
| Button primary | N/A | bg-foreground text-background + layered shadow |
| Button outline | N/A | border border-white/[0.1] hover:bg-white/[0.04] |
| Input field | N/A | border-border bg-white/[0.03] focus:border-primary/40 |
Common Pitfalls
overflow: hiddenbreaks sticky — Always useoverflow-x: clipon the rails container- Grid borders extending past rails — Use
.rail-bounded(margin-based) instead ofmx-auto max-w-6xl - Orphaned grid items on mobile — Plan item counts around your column counts
- Border-left on single-column mobile — Use
sm:max-lg:border-lfor tablet-only left borders - Section padding on grid parents — Apply padding to grid children, not the container
- Rails not reaching page bottom — Ensure
.page-railswraps all content - Missing
border-borderon dashed dividers — Always include the color class - Anchor links behind sticky navbar — Add
scroll-padding-top: 5remtohtml - Inconsistent tracking classes — Standardize on
tracking-wide - Missing hover states on grid cards — Every grid cell needs
hover:bg-white/[0.02] - Both color classes applied at once — Use a conditional, never both simultaneously
- 4-column tables on mobile — Always wrap in
overflow-x-autowithmin-w-[600px] - Dashed lines can't reach rails — Use
calc(var(--rail-offset) - Xrem)margins - Auth route groups — Use Next.js
(auth)route group with sharedlayout.tsx - Component organization — UI primitives in
components/ui/, page-specific incomponents/{section}/