Portfolio Layout in Tailwind: Grid, Project Cards, About Section
Build a polished developer portfolio with Tailwind CSS — responsive project grid, animated cards, and an about section that doesn't look like every other dev site.
Why Most Portfolio Layouts Fall Apart
Let's be honest — the average dev portfolio is three centered <div>s, a hero section with a gradient nobody asked for, and a project grid that breaks at 768px. You've seen it a thousand times. Tailwind doesn't magically fix bad layout thinking, but it gives you the primitives to do it right without writing a single custom class.
The real problem is that most people reach for CSS Grid only when they want "equal columns" and then abandon it the moment they need anything asymmetric. That's backwards. Grid is exactly where asymmetry shines. A portfolio layout done properly will use grid for the macro structure, flexbox for alignment inside cards, and Tailwind's responsive prefixes to adapt — not collapse — at each breakpoint.
Honestly, the best portfolio layouts I've seen in 2026 don't even look like portfolios. They look like products. That shift in thinking changes everything about how you structure the markup.
One more thing — Tailwind v3.4 introduced support for grid-cols-subgrid, which finally lets child elements align to the parent grid. We'll use it for the project cards so descriptions snap to the same baseline across columns.
Setting Up the Page Shell
Start with the outer shell before touching a single card or section. Your <main> needs to carry the full-bleed background, max-width container, and vertical rhythm. Don't nest these concerns inside your hero or sections — own them at the top level.
// app/portfolio/page.tsx
export default function PortfolioPage() {
return (
<main className="min-h-screen bg-zinc-950 text-zinc-100">
<div className="mx-auto max-w-6xl px-6 py-24 space-y-32">
<HeroSection />
<ProjectGrid />
<AboutSection />
</div>
</main>
)
}The space-y-32 on the container gives you 128px between sections without touching margin on any individual component. That's 8rem of breathing room — enough to feel intentional on a 1440px display, not wasted on mobile because space-y scales down naturally.
Worth noting: max-w-6xl is 72rem (1152px). That's wide enough to show three project cards comfortably but not so wide that text line-lengths become unreadable in the about section. You'll likely want a tighter constraint of max-w-2xl on paragraphs — we'll handle that inline.
The px-6 gives you 24px side padding on mobile. That said, you could bump it to px-8 on sm: breakpoints if your design has more breathing room on the sides. One class, done.
Building the Project Grid
The project grid is where Tailwind's grid utilities actually earn their keep. You want a single column on mobile, two columns at md:, and three at lg:. Simple enough — but the interesting part is making the first card span the full width to feature your best work.
// components/ProjectGrid.tsx
const projects = [
{ title: 'Design System', tech: ['React', 'Tailwind'], featured: true },
{ title: 'Dashboard', tech: ['Next.js', 'Recharts'], featured: false },
// ...
]
export function ProjectGrid() {
return (
<section>
<h2 className="text-3xl font-bold mb-12 tracking-tight">Work</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{projects.map((project, i) => (
<ProjectCard
key={project.title}
{...project}
className={i === 0 ? 'md:col-span-2 lg:col-span-2' : ''}
/>
))}
</div>
</section>
)
}The featured card spanning two columns at md: and above lets the hero project breathe without breaking the grid rhythm. Everything else falls into the remaining slot on row one, then flows naturally. If you've tried this with CSS-in-JS before, you'll notice Tailwind makes the conditional class pattern trivial — just template-string it.
For the internal card layout, bento-grid covers the asymmetric spanning pattern in more depth if you want to go further. But for a standard portfolio, the approach above handles 90% of cases cleanly.
In practice, gap-6 (24px) is the sweet spot between "too tight" and "floating islands". At 8px you lose spatial relationships. At 40px cards feel unrelated. 24px signals grouping without crowding.
The Project Card Component
A good project card needs a thumbnail area, a title, a short description, and a tech stack row. It should also have a hover state that doesn't scream "I copied this from a YouTube tutorial." Skip the zoom-on-hover thumbnail. Do a border or shadow shift instead — it's subtler and more professional.
// components/ProjectCard.tsx
interface ProjectCardProps {
title: string
description: string
tech: string[]
href: string
image: string
featured?: boolean
className?: string
}
export function ProjectCard({
title, description, tech, href, image, className = ''
}: ProjectCardProps) {
return (
<a
href={href}
className={`group relative flex flex-col overflow-hidden rounded-2xl
border border-zinc-800 bg-zinc-900
transition-all duration-300 hover:border-zinc-600
hover:shadow-[0_0_0_1px_rgba(255,255,255,0.06)] ${className}`}
>
<div className="aspect-video overflow-hidden">
<img
src={image}
alt={title}
className="h-full w-full object-cover transition-transform
duration-500 group-hover:scale-[1.03]"
/>
</div>
<div className="flex flex-1 flex-col gap-3 p-6">
<h3 className="text-xl font-semibold tracking-tight">{title}</h3>
<p className="text-sm text-zinc-400 leading-relaxed flex-1">{description}</p>
<div className="flex flex-wrap gap-2 pt-2">
{tech.map(t => (
<span key={t}
className="rounded-full bg-zinc-800 px-3 py-1 text-xs
text-zinc-300 font-medium">
{t}
</span>
))}
</div>
</div>
</a>
)
}Quick aside: that hover:shadow-[0_0_0_1px_rgba(255,255,255,0.06)] is a Tailwind arbitrary value adding an inset-border-like highlight on hover. It reads complicated but renders as a 1px white ring at 6% opacity — barely visible, but it gives the card a lifted look without a hard border jump. Check the box shadow generator if you want to experiment with values visually.
The flex-1 on the description paragraph is key. It pushes the tech badges to the bottom of every card regardless of description length, so the badge row always aligns across cards in the same grid row. That's the kind of detail that separates a polished portfolio from a rushed one.
If you want cards with a glass finish, the glassmorphism components are worth looking at — the backdrop blur + border approach works well for cards on image-heavy backgrounds.
The About Section
Most about sections are a centered paragraph of corporate speak followed by a list of technologies you "specialize" in. Don't do that. Structure it as two columns — your bio on the left, a fact/stats column on the right — and let the content breathe at a readable 65-character line length.
// components/AboutSection.tsx
export function AboutSection() {
return (
<section className="grid grid-cols-1 lg:grid-cols-2 gap-16 items-start">
<div className="space-y-6">
<h2 className="text-3xl font-bold tracking-tight">About</h2>
<div className="space-y-4 text-zinc-400 leading-relaxed max-w-prose">
<p>
I build interfaces that ship — not Dribbble shots that never see
production. Five years in, most of my work lives at the intersection
of design systems and developer experience.
</p>
<p>
Currently open to senior frontend roles and select contract work.
</p>
</div>
<a
href="/resume.pdf"
className="inline-flex items-center gap-2 rounded-xl bg-white
px-5 py-3 text-sm font-semibold text-zinc-900
transition-opacity hover:opacity-80"
>
Download Resume
</a>
</div>
<div className="grid grid-cols-2 gap-4">
{[
{ label: 'Projects shipped', value: '40+' },
{ label: 'Years of experience', value: '5' },
{ label: 'Open source stars', value: '2.3k' },
{ label: 'Cups of coffee', value: '∞' },
].map(stat => (
<div key={stat.label}
className="rounded-2xl border border-zinc-800 bg-zinc-900 p-6">
<div className="text-3xl font-bold">{stat.value}</div>
<div className="mt-1 text-sm text-zinc-500">{stat.label}</div>
</div>
))}
</div>
</section>
)
}The max-w-prose utility sets the paragraph container to 65ch — that's Tailwind's built-in reading-width constraint, and it's the right call here. Without it your bio paragraph stretches edge-to-edge on the left column, which gets ugly around 600px wide.
Look, the stat grid is optional. If you don't have meaningful numbers, skip it. But if you do have real metrics — GitHub stars, clients worked with, years of experience — the grid format makes them scannable in under two seconds. Recruiters read portfolios fast.
One more thing — items-start on the parent grid prevents the bio column from stretching to match the stats column height. Without it you'd have awkward vertical centering on the bio text when the stats grid is taller. Small fix, big difference.
Responsive Considerations and Final Touches
The grid collapses gracefully on mobile because we started from grid-cols-1 and scaled up. But there are two more things worth handling: touch targets and the hero section's vertical rhythm on small viewports.
For touch targets, your project card anchors should never be smaller than 44px in height — Apple's HIG has required this since 2007 and it's still the baseline. The aspect-video thumbnail plus the p-6 content area will always clear that threshold, but double-check your tech badge tags. At 28px high they're technically under the target on mobile. Add py-1.5 instead of py-1 if you're concerned.
The tailwind-responsive-design article goes deep on the mobile-first mental model if you're still thinking in max-width breakpoints. Short version: write mobile styles first, use sm:, md:, lg: to add complexity, never remove it.
// Responsive hero typography — scale from mobile to desktop
<h1 className="text-4xl font-bold tracking-tighter sm:text-5xl lg:text-7xl">
Building things<br />
<span className="text-zinc-500">that work.</span>
</h1>That tracking-tighter at large type sizes matters. At 72px (text-7xl is 4.5rem × 16px base = 72px), default letter spacing makes headlines look loose. Tightening it to -0.05em via tracking-tighter brings it back in line with editorial typography. Check the tailwind-typography-guide if you want the full treatment on type scale decisions.
Putting It All Together
You now have a shell that handles full-bleed layout, a grid system that features your best work prominently, cards that align internally without hacks, an about section with actual structure, and responsive behavior that scales up rather than falls apart.
The whole thing compiles to roughly 12kb of CSS in production (with Tailwind's purge step). No custom stylesheets. No runtime CSS-in-JS. Just utility classes that the browser parses fast and the team can read instantly.
If you want to push the visual design further, browse the Empire UI component library for motion-ready UI primitives that drop right into this kind of layout — the animated backgrounds and glassmorphism components in particular pair well with a dark portfolio aesthetic.
What you build with this foundation is up to you. But at least the bones are solid.
FAQ
Yes, all of these are standard React components with no client-only APIs. Mark any interactive bits (hover state with JS) as 'use client', keep the page shell as a Server Component.
Use Next.js <Image> with explicit width and height props, or set aspect-video on the wrapper div and object-cover on the <img>. The aspect ratio container reserves space before the image loads.
After. Visitors want to see your work first — if it's compelling, they'll read about you. Leading with bio copy burns the above-the-fold real estate on the wrong content.
No. The transition-all and group-hover: utilities in Tailwind handle basic hover states in pure CSS. Add Framer Motion only if you need scroll-triggered entrance animations or gesture-based drag interactions.