EmpireUI
Get Pro
← Blog8 min read#tailwind#portfolio#layout

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.

Developer portfolio grid layout with project cards on dark background

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

Can I use this layout with Next.js App Router?

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.

How do I make the project grid load images without layout shift?

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.

Should the About section come before or after the project grid?

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.

Do I need Framer Motion for card hover animations?

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.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Tailwind Grid Layouts Advanced: Auto-Fill, Span, SubgridFull Page Layouts in Tailwind: Header, Main, Footer, Two-ColumnMasonry Grid in React: CSS columns vs JavaScript Grid LayoutCSS Subgrid: Real Layout Problems It Solves That Grid Can't