JP
HomeBlogProjectsResumeAbout

© 2026 JP. All rights reserved.

Back to blog
tutorialprismanextjsnpmStorybookcssTailwind

Building Solo Kanban: A Developer's Project Board with Next.js and @dxsolo/ui Part 2

AdminMarch 22, 202616 min read
Building Solo Kanban: A Developer's Project Board with Next.js and @dxsolo/ui Part 2

On this page

  • Part 2: The Board Layout, CSS Grid, Container Queries, and Column Architecture
  • The App Shell
  • Layout Strategy
  • The Sidebar Component
  • Wiring the Layout
  • Run it
  • The Board: Four Columns with CSS Grid
  • Why CSS Grid Over Flexbox
  • The Board Component
  • The Column Component
  • Wiring the Board into the page
  • Run it
  • Issue Cards with Container Queries
  • Why Container Queries Over Media Queries
  • Setting Up Container Queries
  • The IssueCard Component
  • Inline Styles for Epic Colors
  • Run it
  • Responsive Behavior
  • Desktop (1024px+)
  • Tablet (768px to 1023px)
  • Mobile (below 768px)
  • Column Status Indicators
  • Empty States
  • Run it
  • Full File Reference
  • What We Built in Part 2
  • Troubleshooting
  • What is Next

On this page

  • Part 2: The Board Layout, CSS Grid, Container Queries, and Column Architecture
  • The App Shell
  • Layout Strategy
  • The Sidebar Component
  • Wiring the Layout
  • Run it
  • The Board: Four Columns with CSS Grid
  • Why CSS Grid Over Flexbox
  • The Board Component
  • The Column Component
  • Wiring the Board into the page
  • Run it
  • Issue Cards with Container Queries
  • Why Container Queries Over Media Queries
  • Setting Up Container Queries
  • The IssueCard Component
  • Inline Styles for Epic Colors
  • Run it
  • Responsive Behavior
  • Desktop (1024px+)
  • Tablet (768px to 1023px)
  • Mobile (below 768px)
  • Column Status Indicators
  • Empty States
  • Run it
  • Full File Reference
  • What We Built in Part 2
  • Troubleshooting
  • What is Next
PreviousBuilding Solo Kanban: A Developer's Project Board with Next.js and @dxsolo/uiNextBuilding Solo Kanban: A Developer's Project Board with Next.js and @dxsolo/ui Part 3

Building Solo Kanban: A Developer's Project Board with Next.js and @dxsolo/ui

Part 2: The Board Layout, CSS Grid, Container Queries, and Column Architecture

Welcome back. In Part 1, we scaffolded the project, designed the Prisma schema, seeded the database, and confirmed that @dxsolo/ui renders correctly. Now we build the thing people actually see: the kanban board.

By the end of this post, you will have four scrollable columns (Open, InProgress, Review, Done), responsive issue cards powered by @dxsolo/ui, an app shell with a sidebar, and a layout that adapts from desktop down to tablet using CSS Grid and container queries.

Series overview:

  1. Project Setup and Data Modeling
  2. The Board Layout: CSS Grid, Container Queries, and Column Architecture
  3. Drag and Drop That Actually Works
  4. The Issue Detail View with Markdown Editing
  5. Projects, Epics, and Filtering
  6. Polish, Persistence, and Publishing

The App Shell

Before we touch the board, we need a layout wrapper. Every page in the app shares a sidebar for project navigation and a main content area. Let's build that first.

Layout Strategy

The app shell uses a simple two-column CSS Grid: a fixed-width sidebar on the left and a fluid main area that takes the remaining space. We are not using flexbox here because Grid gives us explicit control over the sidebar width without flex-shrink surprises, and it plays nicely with the nested grid we will use for the board columns.

Create src/components/layout/AppShell.tsx:

tsx
"use client";
 
import { ReactNode } from "react";
 
interface AppShellProps {
  sidebar: ReactNode;
  children: ReactNode;
}
 
export function AppShell({ sidebar, children }: AppShellProps) {
  return (
    <div className="grid grid-cols-[260px_1fr] h-screen overflow-hidden">
      <aside className="border-r border-gray-200 bg-gray-50 overflow-y-auto">
        {sidebar}
      </aside>
      <main className="overflow-hidden">{children}</main>
    </div>
  );
}

A few things to notice:

  • grid-cols-[260px_1fr] gives us a fixed 260px sidebar and a fluid main area. The bracket syntax is Tailwind's arbitrary value support for Grid template columns.
  • h-screen overflow-hidden on the outer container locks the app to the viewport. No page-level scrolling. Each section (sidebar and main) manages its own overflow.
  • overflow-y-auto on the aside lets the sidebar scroll independently when the project list gets long.
  • overflow-hidden on main prevents double scrollbars. The board columns inside will handle their own vertical scrolling.

Pitfall: page-level scroll bleeding. If you forget overflow-hidden on the outer grid, the entire page scrolls when a column overflows. This makes the board feel broken because the column headers scroll out of view. Lock the viewport at the shell level, then give each child its own scroll context.

The Sidebar Component

For now, the sidebar is simple: a project name at the top and a placeholder for epic filters (we will flesh this out in Part 5). We will use @dxsolo/ui components where they fit.

Create src/components/layout/Sidebar.tsx:

tsx
"use client";
 
import { Button } from "@dxsolo/ui";
 
interface SidebarProps {
  projectName: string;
  epicCount: number;
  issueCount: number;
}
 
export function Sidebar({ projectName, epicCount, issueCount }: SidebarProps) {
  return (
    <div className="p-4 flex flex-col gap-4">
      <div>
        <h1 className="text-lg font-bold">{projectName}</h1>
        <p className="text-sm text-gray-500">
          {epicCount} epics, {issueCount} issues
        </p>
      </div>
 
      <nav className="flex flex-col gap-1">
        <Button intent="ghost" className="justify-start w-full">
          Board
        </Button>
        <Button intent="ghost" className="justify-start w-full">
          Backlog
        </Button>
        <Button intent="ghost" className="justify-start w-full">
          Settings
        </Button>
      </nav>
 
      <div className="border-t border-gray-200 pt-4">
        <h2 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">
          Epics
        </h2>
        <p className="text-sm text-gray-500">
          Epic filters coming in Part 5.
        </p>
      </div>
    </div>
  );
}

Nothing fancy. The Button component from @dxsolo/ui with intent="ghost" gives us clean, hover-responsive nav items without writing custom styles. We override alignment with justify-start so the text sits left instead of centered.

Wiring the Layout

Update the project board route. Create src/app/projects/[projectId]/board/page.tsx:

tsx
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { AppShell } from "@/components/layout/AppShell";
import { Sidebar } from "@/components/layout/Sidebar";
 
interface BoardPageProps {
  params: Promise<{ projectId: string }>;
}
 
export default async function BoardPage({ params }: BoardPageProps) {
  const { projectId } = await params;
 
  const project = await prisma.project.findUnique({
    where: { id: projectId },
    include: {
      epics: true,
      issues: {
        include: {
          epic: true,
          subtasks: true,
        },
        orderBy: { order: "asc" },
      },
      _count: {
        select: { epics: true, issues: true },
      },
    },
  });
 
  if (!project) notFound();
 
  return (
    <AppShell
      sidebar={
        <Sidebar
          projectName={project.name}
          epicCount={project._count.epics}
          issueCount={project._count.issues}
        />
      }
    >
      <div className="flex items-center justify-center h-full">
        <p className="text-gray-500">Board coming soon.</p>
      </div>
    </AppShell>
  );
}

Notice we are not importing or rendering a Board component yet — it does not exist. Instead, we have a centered placeholder in the main area. We will swap this out once the board components are built.

This is a React Server Component. The database query runs on the server, and we pass the data down as props. No client-side fetching, no loading spinners, no waterfall requests.

We also need a redirect from the home page to the default project board. Update src/app/page.tsx:

tsx
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
 
export default async function Home() {
  const project = await prisma.project.findFirst({
    orderBy: { createdAt: "asc" },
  });
 
  if (project) {
    redirect(`/projects/${project.id}/board`);
  }
 
  return (
    <main className="flex items-center justify-center h-screen">
      <p className="text-gray-500">No projects yet. Create one to get started.</p>
    </main>
  );
}

Run it

Start the dev server:

bash
npm run dev

Visit http://localhost:3000. The home page should redirect you to your project's board route. You should see:

  • The sidebar on the left (260px wide) with your project name, epic/issue counts, and three nav buttons (Board, Backlog, Settings)
  • The main area on the right showing the "Board coming soon." placeholder text, centered both horizontally and vertically
  • No scrollbars on the page — the shell locks to the viewport

If you see this, the app shell is wired up correctly. Keep the dev server running as we build the board components next.


The Board: Four Columns with CSS Grid

Now the main event. The board is a horizontal grid of four columns, one for each status. Each column has a sticky header and a scrollable list of issue cards.

Why CSS Grid Over Flexbox

Flexbox can do horizontal layouts, but Grid is the better fit here for two reasons:

  1. Equal-width columns are trivial. grid-template-columns: repeat(4, 1fr) and you are done. With flexbox, you need flex: 1 on each child and have to deal with min-width: 0 to prevent content from blowing out the column width.
  2. The board is a fixed-height grid inside a fixed-height shell. Grid handles nested height constraints more predictably than flexbox. When you say "this column should fill the available height and scroll internally," Grid's minmax(0, 1fr) pattern does exactly that without surprises.

The Board Component

Create src/components/board/Board.tsx:

tsx
"use client";
 
import { IssueStatus } from "@prisma/client";
import { Column } from "./Column";
import type { IssueWithRelations } from "@/types";
 
const COLUMNS: { status: IssueStatus; label: string }[] = [
  { status: "OPEN", label: "Open" },
  { status: "IN_PROGRESS", label: "In Progress" },
  { status: "REVIEW", label: "Review" },
  { status: "DONE", label: "Done" },
];
 
interface BoardProps {
  issues: IssueWithRelations[];
}
 
export function Board({ issues }: BoardProps) {
  const issuesByStatus = COLUMNS.map((col) => ({
    ...col,
    issues: issues.filter((issue) => issue.status === col.status),
  }));
 
  return (
    <div className="h-full p-4 overflow-x-auto">
      <div className="grid grid-cols-4 gap-4 h-full min-w-[800px]">
        {issuesByStatus.map((col) => (
          <Column
            key={col.status}
            status={col.status}
            label={col.label}
            issues={col.issues}
          />
        ))}
      </div>
    </div>
  );
}

Let's define the type we referenced. Create src/types/index.ts:

typescript
import type { Issue, Epic, Subtask } from "@prisma/client";
 
export type IssueWithRelations = Issue & {
  epic: Epic | null;
  subtasks: Subtask[];
};

A few design decisions in the Board component:

**min-w-[800px] with overflow-x-auto.** On narrow viewports (phones), four columns cannot fit. Instead of collapsing to a vertical stack, which breaks the kanban mental model, we allow horizontal scrolling. This keeps the spatial relationship between columns intact. The 800px minimum ensures each column gets at least 200px before scrolling kicks in.

Client component. We marked this "use client" because the board will eventually handle drag-and-drop state, keyboard navigation, and other interactive behaviors. Even though the initial render could technically be a server component, making it a client component now avoids a refactor later.

Grouping issues by status on the client. We could have done this grouping in the server component or even in the database query with separate queries per status. But a single query with client-side grouping is simpler, results in one database round trip, and the filtering cost is negligible for the number of issues a solo dev will have.

The Column Component

Create src/components/board/Column.tsx:

tsx
"use client";
 
import { IssueStatus } from "@prisma/client";
import { IssueCard } from "./IssueCard";
import type { IssueWithRelations } from "@/types";
 
interface ColumnProps {
  status: IssueStatus;
  label: string;
  issues: IssueWithRelations[];
}
 
const statusColors: Record<IssueStatus, string> = {
  OPEN: "bg-gray-100 text-gray-700",
  IN_PROGRESS: "bg-blue-100 text-blue-700",
  REVIEW: "bg-amber-100 text-amber-700",
  DONE: "bg-green-100 text-green-700",
};
 
export function Column({ status, label, issues }: ColumnProps) {
  return (
    <div className="flex flex-col h-full min-h-0 rounded-lg bg-gray-50">
      {/* Column header: sticky, never scrolls */}
      <div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
        <div className="flex items-center gap-2">
          <span
            className={`text-xs font-semibold px-2 py-0.5 rounded-full ${statusColors[status]}`}
          >
            {label}
          </span>
          <span className="text-xs text-gray-400">{issues.length}</span>
        </div>
      </div>
 
      {/* Card list: scrollable */}
      <div className="flex-1 overflow-y-auto p-2 flex flex-col gap-2">
        {issues.map((issue) => (
          <IssueCard key={issue.id} issue={issue} />
        ))}
 
        {issues.length === 0 && (
          <p className="text-xs text-gray-400 text-center py-8">
            No issues
          </p>
        )}
      </div>
    </div>
  );
}

The critical CSS pattern here is the column's internal layout:

plaintext
flex flex-col h-full min-h-0
  ├── header  (fixed height, never scrolls)
  └── card list  (flex-1 overflow-y-auto, scrolls independently)

**min-h-0 is the key.** Without it, the flex column will not shrink below its content height, and overflow-y-auto on the card list does nothing. This is one of the most common CSS Grid/Flexbox pitfalls: flex items default to min-height: auto, which means they grow to fit their content. Setting min-h-0 tells the column, "you are allowed to be smaller than your content, let the children scroll."

Pitfall: the invisible overflow. You build the columns, add 20 cards, and they all render but the column just stretches. No scrollbar appears. You check overflow-y-auto and it is there. The problem is almost always a missing min-h-0 somewhere in the flex/grid chain between the viewport and the scrollable container. Every ancestor in the chain needs to constrain its height.

Wiring the Board into the page

Now that Board, Column, and the types exist, swap out the placeholder in src/app/projects/[projectId]/board/page.tsx. Add the Board import at the top and replace the placeholder div with the Board component:

diff
  import { AppShell } from "@/components/layout/AppShell";
  import { Sidebar } from "@/components/layout/Sidebar";
+ import { Board } from "@/components/board/Board";
diff
      >
-       <div className="flex items-center justify-center h-full">
-         <p className="text-gray-500">Board coming soon.</p>
-       </div>
+       <Board issues={project.issues} />
      </AppShell>

Run it

We still need IssueCard before the app will compile. Create a temporary stub to see the columns in action. Create src/components/board/IssueCard.tsx:

tsx
import type { IssueWithRelations } from "@/types";
 
export function IssueCard({ issue }: { issue: IssueWithRelations }) {
  return (
    <div className="p-2 bg-white rounded border text-sm">{issue.title}</div>
  );
}

Visit http://localhost:3000 (or let the dev server hot-reload). You should see:

  • The sidebar, unchanged from before
  • Four columns with colored header badges: Open, In Progress, Review, Done
  • Your seeded issues rendered as plain white cards sorted into the correct columns
  • Columns with no issues show "No issues" text
  • Resize the browser window narrower than ~800px and the board scrolls horizontally

This stub is temporary — we will replace it with the full IssueCard component in the next section.


Issue Cards with Container Queries

The issue card is the most visible component on the board. It shows the title, a priority badge, an optional epic label, and a subtask progress indicator. We want cards to adapt their layout based on the column width, not the viewport width. This is exactly what CSS container queries are for.

Why Container Queries Over Media Queries

Media queries respond to the browser viewport. Container queries respond to the size of a parent element. On our board, all four columns are the same width, so media queries would work fine, right? Yes, for now. But in Part 5, when we add the ability to collapse or expand the sidebar, the column widths change without the viewport changing. And if we ever add a detail panel that shrinks the board, same thing.

Container queries future-proof the cards against layout changes they do not control. The card says, "if my container is narrower than 200px, hide the epic label." It does not care why the container is narrow.

Setting Up Container Queries

The column's card list becomes a container query context. In Column.tsx, find the card list div and add @container to its class list. The only change is appending @container to the end of the existing classes:

diff
- <div className="flex-1 overflow-y-auto p-2 flex flex-col gap-2">
+ <div className="flex-1 overflow-y-auto p-2 flex flex-col gap-2 @container">

Do not wrap the card list in an additional <div className="@container">. The @container class goes directly on the existing scrollable div. This is important because separating the container query context from the scroll container can cause width-reporting issues (covered in the pitfalls section below).

The @container class (Tailwind's container query utility) marks this div as a containment context. Any child can now use @[width]: prefixed utilities to respond to this container's width.

The IssueCard Component

Replace the stub in src/components/board/IssueCard.tsx with the full implementation:

tsx
"use client";
 
import { Card, CardContent } from "@dxsolo/ui";
import type { IssueWithRelations } from "@/types";
import { IssuePriority } from "@prisma/client";
 
interface IssueCardProps {
  issue: IssueWithRelations;
}
 
const priorityConfig: Record<
  IssuePriority,
  { label: string; className: string }
> = {
  URGENT: { label: "Urgent", className: "bg-red-100 text-red-700" },
  HIGH: { label: "High", className: "bg-orange-100 text-orange-700" },
  MEDIUM: { label: "Med", className: "bg-yellow-100 text-yellow-700" },
  LOW: { label: "Low", className: "bg-gray-100 text-gray-500" },
};
 
export function IssueCard({ issue }: IssueCardProps) {
  const priority = priorityConfig[issue.priority];
  const completedSubtasks = issue.subtasks.filter((s) => s.completed).length;
  const totalSubtasks = issue.subtasks.length;
 
  return (
    <Card className="cursor-pointer hover:ring-2 hover:ring-blue-300 transition-shadow">
      <CardContent className="p-3">
        {/* Title */}
        <h3 className="text-sm font-medium leading-snug">{issue.title}</h3>
 
        {/* Meta row: priority + epic label */}
        <div className="flex items-center gap-2 mt-2 flex-wrap">
          <span
            className={`text-xs font-medium px-1.5 py-0.5 rounded ${priority.className}`}
          >
            {priority.label}
          </span>
 
          {/* Epic label: hidden when container is too narrow */}
          {issue.epic && (
            <span
              className="text-xs px-1.5 py-0.5 rounded hidden @[200px]:inline-block"
              style={{ backgroundColor: `${issue.epic.color}20`, color: issue.epic.color }}
            >
              {issue.epic.name}
            </span>
          )}
        </div>
 
        {/* Subtask progress */}
        {totalSubtasks > 0 && (
          <div className="mt-2 flex items-center gap-2">
            <div className="flex-1 h-1 bg-gray-200 rounded-full overflow-hidden">
              <div
                className="h-full bg-green-500 rounded-full transition-all"
                style={{
                  width: `${(completedSubtasks / totalSubtasks) * 100}%`,
                }}
              />
            </div>
            <span className="text-xs text-gray-400">
              {completedSubtasks}/{totalSubtasks}
            </span>
          </div>
        )}
      </CardContent>
    </Card>
  );
}

Let's break down the container query usage:

tsx
<span className="hidden @[200px]:inline-block">

This says: "hide this element by default, but show it as inline-block when the nearest @container ancestor is at least 200px wide." When the board columns are wide enough, you see the epic label. When they are narrow (small screen, expanded sidebar, or collapsed view), the epic label hides to save space. The priority badge and title always show.

Pitfall: container queries inside scrollable overflow columns. There is a subtle issue with container queries and overflow: auto. The CSS contain property (which @container implies) can interact with overflow in unexpected ways on some browsers. Specifically, if the container element itself has overflow: hidden or overflow: auto, the containment can sometimes cause the container to report a width of 0.

The fix: make sure the @container is on the scrollable div, not on a wrapper inside it. In our case, the card list div is both the scroll container and the query container, which works correctly. If you separate them (one div for scrolling, a child div for containment), test thoroughly.

Inline Styles for Epic Colors

You probably noticed we used inline style for the epic label background:

tsx
style={{ backgroundColor: `${issue.epic.color}20`, color: issue.epic.color }}

The 20 suffix adds 12% opacity to the hex color, giving us a tinted background. We cannot use Tailwind classes here because the epic color is dynamic, stored in the database. Tailwind needs to know class names at build time, and bg-[${someVariable}] does not work. Inline styles are the correct approach for truly dynamic values.

Decision rationale: we could have mapped epic colors to a fixed set of Tailwind color classes (like bg-indigo-100, bg-amber-100) and stored the class name in the database. That works if you want a constrained palette. We chose raw hex colors because it gives users more freedom and makes the color picker in Part 5 simpler to implement.

Run it

Visit http://localhost:3000 (or let the dev server hot-reload). The plain white stub cards are gone, replaced by the full IssueCard component. You should now see:

  • Issue titles in each column
  • Colored priority badges — Urgent in red, High in orange, Med in yellow, Low in gray
  • Epic labels with tinted backgrounds next to the priority badge (if the column is wide enough)
  • Subtask progress bars with completion counts on issues that have subtasks

Try resizing your browser window. When the columns get narrow, the epic labels disappear — that is the container query at work. The priority badge and title always remain visible. Widen the window and the epic labels come back.


Responsive Behavior

Let's talk about how the board adapts across screen sizes.

Desktop (1024px+)

Full layout: 260px sidebar, four equal columns in the remaining space. Each column is roughly 170-200px wide depending on the viewport. Epic labels are visible. Everything fits.

Tablet (768px to 1023px)

The sidebar could eat too much space at 260px. We have a few options:

  1. Collapse the sidebar to icons only (like Jira does)
  2. Hide the sidebar behind a toggle
  3. Let the board scroll horizontally

For this series, we will go with option 2: a collapsible sidebar. Update AppShell.tsx to support this:

tsx
"use client";
 
import { ReactNode, useState } from "react";
import { Button } from "@dxsolo/ui";
 
interface AppShellProps {
  sidebar: ReactNode;
  children: ReactNode;
}
 
export function AppShell({ sidebar, children }: AppShellProps) {
  const [sidebarOpen, setSidebarOpen] = useState(true);
 
  return (
    <div
      className={`grid h-screen overflow-hidden transition-[grid-template-columns] duration-200 ${
        sidebarOpen ? "grid-cols-[260px_1fr]" : "grid-cols-[0px_1fr]"
      }`}
    >
      <aside
        className={`border-r border-gray-200 bg-gray-50 overflow-y-auto overflow-x-hidden transition-opacity duration-200 ${
          sidebarOpen ? "opacity-100" : "opacity-0 pointer-events-none"
        }`}
      >
        {sidebar}
      </aside>
      <main className="overflow-hidden relative">
        {/* Sidebar toggle */}
        <Button
          intent="ghost"
          className="absolute top-2 left-2 z-10"
          onClick={() => setSidebarOpen(!sidebarOpen)}
        >
          {sidebarOpen ? "◀" : "▶"}
        </Button>
        <div className="h-full pt-10">{children}</div>
      </main>
    </div>
  );
}

When the sidebar collapses, the board columns gain 260px of extra width. Because we used 1fr for the main area, the columns expand automatically. And because our issue cards use container queries instead of media queries, the epic labels appear or hide based on the actual column width, not the viewport. This is container queries doing exactly what they were designed for.

**transition-[grid-template-columns]** gives us a smooth animation when toggling. Tailwind's arbitrary property syntax lets us transition any CSS property, including grid template columns.

Mobile (below 768px)

A four-column kanban board does not work on a phone. Period. The columns would be too narrow to read. For a real production app, you would build an entirely different mobile view, probably a vertical list grouped by status, or a single-column view where you swipe between statuses.

For this series, we will keep it simple: the board scrolls horizontally on mobile with the min-w-[800px] we set earlier. It is functional, just not optimized. A mobile-specific view is a great exercise for readers who want to extend the project.


Column Status Indicators

Small detail that makes a big difference: visual differentiation between columns. Right now the column headers have colored badges, but the columns themselves all look the same. Let's add a subtle top border to each column that matches its status color.

Update the column wrapper in Column.tsx:

tsx
const statusBorderColors: Record<IssueStatus, string> = {
  OPEN: "border-t-gray-400",
  IN_PROGRESS: "border-t-blue-500",
  REVIEW: "border-t-amber-500",
  DONE: "border-t-green-500",
};
 
export function Column({ status, label, issues }: ColumnProps) {
  return (
    <div
      className={`flex flex-col h-full min-h-0 rounded-lg bg-gray-50 border-t-2 ${statusBorderColors[status]}`}
    >
      {/* ... rest unchanged */}
    </div>
  );
}

A 2px colored top border on each column gives instant visual orientation. When you are scanning the board, your eye uses the color to confirm which column you are looking at before you read the label. It is a tiny detail, but kanban boards live and die by scanability.


Empty States

An empty column should not just be blank. It should communicate that it is a valid drop target (important for Part 3) and gently encourage action.

We already have basic empty state text in the Column component. Let's make it slightly more useful by varying the message per status.

First, add the emptyMessages map alongside the other constants at the top of Column.tsx (next to statusColors and statusBorderColors):

tsx
const emptyMessages: Record<IssueStatus, string> = {
  OPEN: "No open issues. Create one to get started.",
  IN_PROGRESS: "Nothing in progress. Drag an issue here.",
  REVIEW: "Review queue is empty.",
  DONE: "No completed issues yet.",
};

Then, inside the card list div, replace the existing empty state paragraph with the new dashed-border version:

diff
  {issues.length === 0 && (
-   <p className="text-xs text-gray-400 text-center py-8">
-     No issues
-   </p>
+   <div className="flex items-center justify-center h-32 border-2 border-dashed border-gray-200 rounded-lg">
+     <p className="text-xs text-gray-400 text-center px-4">
+       {emptyMessages[status]}
+     </p>
+   </div>
  )}

The dashed border hints that this is a drop zone. We will make it an actual drop target in Part 3.

Run it

Visit http://localhost:3000 and verify the final round of changes:

  • Collapsible sidebar: click the toggle arrow in the top-left of the main area. The sidebar should animate closed and the board columns expand to fill the extra space. Epic labels may reappear on cards that previously hid them, because the columns are now wider. Click the toggle again to reopen the sidebar.
  • Colored top borders: each column should have a subtle 2px colored border at the top — gray for Open, blue for In Progress, amber for Review, green for Done.
  • Empty states: any column with no issues should show a dashed-border box with a status-specific message (e.g., "Nothing in progress. Drag an issue here." for the In Progress column) instead of plain "No issues" text.

Full File Reference

Here is every file we created or modified in this part, listed for quick reference:

FilePurpose
src/components/layout/AppShell.tsxTwo-column grid shell with collapsible sidebar
src/components/layout/Sidebar.tsxProject info and navigation
src/components/board/Board.tsxFour-column grid, groups issues by status
src/components/board/Column.tsxSingle status column with sticky header and scrollable cards
src/components/board/IssueCard.tsxCard with priority badge, epic label, subtask progress
src/types/index.tsShared TypeScript types
src/app/projects/[projectId]/board/page.tsxBoard route, fetches data server-side
src/app/page.tsxRedirects to default project board

What We Built in Part 2

Here is what we accomplished:

  • An app shell with a collapsible sidebar using CSS Grid and animated grid-template-columns transitions
  • A four-column kanban board using CSS Grid with repeat(4, 1fr)
  • Scrollable columns with sticky headers, using the min-h-0 pattern for proper overflow
  • Issue cards built on @dxsolo/ui Card and CardContent, with priority badges, epic labels, and subtask progress bars
  • Container queries on the card list so epic labels hide on narrow columns without media query breakpoints
  • Status-colored column headers and top borders for quick visual scanning
  • Contextual empty states with dashed borders hinting at future drop targets
  • Horizontal scroll fallback on narrow viewports with min-w-[800px]

Key pitfalls we covered:

  • **min-h-0 on flex columns.** Without it, overflow scrolling silently breaks.
  • Container queries on the scroll container. Separating the @container from the overflow-y-auto element can cause width reporting issues.
  • Dynamic colors via inline styles. Tailwind classes do not support runtime values. Inline styles are the right tool for user-defined colors.
  • **@source in CSS.** Still worth repeating from Part 1, if your @dxsolo/ui components look unstyled, check this first.

Troubleshooting

If something looks off at any point, check these common issues:

  • Cards are unstyled (no borders, no shadows): make sure your @source directive in src/app/globals.css includes the @dxsolo/ui package path, as covered in Part 1.
  • Columns stretch instead of scrolling: look for a missing min-h-0 in the flex/grid chain. Every ancestor between the viewport and the scrollable card list needs to constrain its height.
  • Epic labels never appear: check that the @container class is on the card list div in Column.tsx, and that IssueCard.tsx uses @[200px]:inline-block on the epic label span.
  • Database errors: make sure you ran npx prisma db push and npx prisma db seed from Part 1.
  • createContext is not a function: a component that imports from @dxsolo/ui is missing "use client" at the top of the file.

What is Next

In Part 3, we make the board interactive. Drag and drop: moving issues between columns, reordering within a column, persisting the new positions to the database, handling optimistic updates, and dealing with the many edge cases that make drag and drop genuinely hard. We will evaluate the options (HTML Drag and Drop API, dnd-kit, pragmatic-drag-and-drop) and pick the one that fits best.

See you there.


Source code for this part: github.com/jpDxsoloOrg/solo-kanban/tree/part-2

Live demo: Coming in Part 6

@dxsolo/ui Storybook: jpdxsoloorg.github.io/jpComponent