<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Ash - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Ash - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Mon, 12 Oct 2026 15:34:31 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/author/ashvinui/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Add shadcn Charts to a Next.js App Without Writing Recharts Boilerplate ]]>
                </title>
                <description>
                    <![CDATA[ Charts look like a small task on a ticket. Then you open the Recharts docs and remember how much setup every chart needs: a config object for labels and colors, axes, a tooltip, a legend, colors that  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-add-shadcn-ui-charts-to-nextjs/</link>
                <guid isPermaLink="false">6abf4b9d63317980cdaece08</guid>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ shadcn ]]>
                    </category>
                
                    <category>
                        <![CDATA[ charts ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ data visualization tools ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ash ]]>
                </dc:creator>
                <pubDate>Fri, 02 Oct 2026 06:13:49 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/a6e5c1d7-5d61-4cc0-94d8-3a3e068d97e4.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Charts look like a small task on a ticket. Then you open the Recharts docs and remember how much setup every chart needs: a config object for labels and colors, axes, a tooltip, a legend, colors that work in dark mode, and a container that resizes properly. Most of us copy all of that from the last project and rename the keys.</p>
<p>The shadcn/ui chart component removes part of this work. It wraps Recharts with theme-aware colors and tooltips. But you still write every chart by hand.</p>
<p>So I built ChartCN, a free and open-source <a href="https://www.shadcndeck.com/chartcn">shadcn chart generator</a>. You paste your data, pick a chart, and copy one TSX file that depends only on the shadcn/ui chart component and Recharts. It's MIT licensed, and there's no account or extra package.</p>
<p>In this tutorial, you'll learn how to add a revenue-versus-expenses bar chart to a fresh Next.js app. We'll walk through what each part of the generated code does, and then connect the chart to data loaded in a Server Component. The tool saves you the typing, but Steps 5 and 6 apply to any shadcn/ui chart, whether you generate it or write it yourself.</p>
<p><strong>Prerequisites:</strong> You should be comfortable with React and have Node.js installed. Basic familiarity with the Next.js App Router helps, but every step is explained.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-youll-build">What You'll Build</a></p>
</li>
<li><p><a href="#heading-step-1-create-a-nextjs-app-and-initialize-shadcnui">Step 1: Create a Next.js App and Initialize shadcn/ui</a></p>
</li>
<li><p><a href="#heading-step-2-add-the-shadcnui-chart-component">Step 2: Add the shadcn/ui Chart Component</a></p>
</li>
<li><p><a href="#heading-step-3-generate-the-chart-component-in-chartcn">Step 3: Generate the Chart Component in ChartCN</a></p>
</li>
<li><p><a href="#heading-step-4-add-the-chart-to-your-page">Step 4: Add the Chart to Your Page</a></p>
</li>
<li><p><a href="#heading-step-5-understand-the-generated-code">Step 5: Understand the Generated Code</a></p>
</li>
<li><p><a href="#heading-step-6-pass-live-data-as-a-prop">Step 6: Pass Live Data as a Prop</a></p>
</li>
<li><p><a href="#heading-common-issues-and-how-to-fix-them">Common Issues and How to Fix Them</a></p>
</li>
<li><p><a href="#heading-summary">Summary</a></p>
</li>
</ul>
<h2 id="heading-what-youll-build">What You'll Build</h2>
<p>By the end, you'll have two pages:</p>
<ul>
<li><p><code>/</code> shows a grouped bar chart with the data written directly in the component. This is the fastest way to get a chart on screen.</p>
</li>
<li><p><code>/live</code> shows the same chart, but the data comes from an async function running on the server. This is the version you'd use with a real database or API.</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/6a4ea012258c41204fefcf8b/546e7105-3d43-4bd0-a064-a877fffe930a.png" alt="Shadcn Charts - Finished Bar Chart" style="display: block;" width="667" height="661" loading="lazy">

<p>The chart has gradient bars, compact axis labels (40K instead of 40000), a legend, and a tooltip that shows the month's total and each series' share of it.</p>
<h2 id="heading-step-1-create-a-nextjs-app-and-initialize-shadcnui">Step 1: Create a Next.js App and Initialize shadcn/ui</h2>
<p>Create a new app and move into it:</p>
<pre><code class="language-bash">npx create-next-app@latest my-charts-app
cd my-charts-app
</code></pre>
<p>Accept the recommended defaults. You need TypeScript, Tailwind CSS, and the App Router.</p>
<p>Next, initialize shadcn/ui:</p>
<pre><code class="language-bash">npx shadcn@latest init
</code></pre>
<p>This command does three things you'll rely on later:</p>
<ol>
<li><p>It creates <code>components.json</code>, which tells the shadcn/ui CLI where to put components.</p>
</li>
<li><p>It adds <code>lib/utils.ts</code> with the <code>cn()</code> class-merging helper.</p>
</li>
<li><p>It writes theme variables into <code>app/globals.css</code>, including five chart colors: <code>--chart-1</code> through <code>--chart-5</code>, with separate values for light and dark mode.</p>
</li>
</ol>
<p>Those five chart variables matter. Every generated chart uses them, so changing a chart color across your whole app means editing one line of CSS.</p>
<h2 id="heading-step-2-add-the-shadcnui-chart-component">Step 2: Add the shadcn/ui Chart Component</h2>
<p>Now add the chart component:</p>
<pre><code class="language-bash">npx shadcn@latest add chart
</code></pre>
<p>This installs <code>recharts</code> and creates <code>components/ui/chart.tsx</code>. That file exports the building blocks you'll see in the generated code:</p>
<ul>
<li><p><code>ChartContainer</code> wraps Recharts' <code>ResponsiveContainer</code>, so the chart fills its parent.</p>
</li>
<li><p><code>ChartConfig</code> is the type for the object that maps each data key to a label and a color.</p>
</li>
<li><p><code>ChartTooltip</code>, <code>ChartTooltipContent</code>, <code>ChartLegend</code>, and <code>ChartLegendContent</code> are styled versions of the Recharts tooltip and legend.</p>
</li>
</ul>
<p>Before moving on, check which Recharts version was installed:</p>
<pre><code class="language-bash">npm ls recharts
</code></pre>
<p>The code ChartCN generates targets <strong>Recharts 3</strong>. If you see a 2.x version, upgrade it:</p>
<pre><code class="language-bash">npm install recharts@latest
</code></pre>
<p><strong>Takeaway:</strong> the shadcn/ui chart component is a thin layer over Recharts. It doesn't replace Recharts. It gives Recharts your theme.</p>
<h2 id="heading-step-3-generate-the-chart-component-in-chartcn">Step 3: Generate the Chart Component in ChartCN</h2>
<p>Open the <a href="https://www.shadcndeck.com/chartcn/charts/bar">ChartCN bar chart page</a>. The data panel has three tabs: <strong>Paste data</strong>, <strong>Upload file</strong>, and <strong>Edit table</strong>. Paste this CSV into the <strong>Paste data</strong> tab:</p>
<pre><code class="language-plaintext">Month,Revenue,Expenses
Jan,42000,31000
Feb,58000,34000
Mar,51000,29000
Apr,67000,38000
May,72000,41000
Jun,69000,37000
</code></pre>
<p>ChartCN treats the first column as the category axis (the x-axis for a bar chart) and every other column as a numeric series. JSON, TSV, and Markdown tables also work, and the format is detected automatically.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a4ea012258c41204fefcf8b/997a85da-f44d-4e88-8ffe-120ea66b8884.png" alt="Shadcn Charts - Bar Chart" style="display: block;" width="671" height="659" loading="lazy">

<p>Above the preview, set these options:</p>
<ul>
<li><p><strong>Layout:</strong> Grouped</p>
</li>
<li><p><strong>Tooltip:</strong> Breakdown</p>
</li>
<li><p><strong>Code:</strong> Inline data</p>
</li>
</ul>
<p>Then click <strong>Copy component</strong> in the <code>chart.tsx</code> panel below the preview.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a4ea012258c41204fefcf8b/3317c6c9-e4f4-4b59-ad19-2076728048cd.png" alt="Shadcn Charts - ChartCn Copy component" style="display: block;" width="656" height="569" loading="lazy">

<h2 id="heading-step-4-add-the-chart-to-your-page">Step 4: Add the Chart to Your Page</h2>
<p>Create a new file at <code>components/revenue-chart.tsx</code> and paste the copied code into it.</p>
<p>Don't save it as <code>components/ui/chart.tsx</code>. ChartCN labels its output <code>chart.tsx</code>, but that path already holds the <a href="https://www.shadcndeck.com/blog/shadcn-components">shadcn component</a> from Step 2, and overwriting it breaks every chart in your app.</p>
<p>The generated component is always exported as <code>Chart</code>, so give it a clearer name when you import it. Replace <code>app/page.tsx</code> with this:</p>
<pre><code class="language-tsx">// app/page.tsx
import { Chart as RevenueChart } from "@/components/revenue-chart"

export default function Home() {
  return (
    &lt;main className="mx-auto max-w-3xl p-8"&gt;
      &lt;h1 className="mb-6 text-2xl font-semibold"&gt;Revenue vs. expenses&lt;/h1&gt;
      &lt;RevenueChart /&gt;
    &lt;/main&gt;
  )
}
</code></pre>
<p>Run <code>npm run dev</code> and open <code>http://localhost:3000</code>. You should see the chart.</p>
<p><strong>Takeaway:</strong> a generated chart is just a component in your project. There's nothing to configure and no package to keep in sync.</p>
<h2 id="heading-step-5-understand-the-generated-code">Step 5: Understand the Generated Code</h2>
<p>You own this file now, so it's worth knowing what each part does. Here are the important pieces, trimmed for length.</p>
<p>The file starts with a directive and its imports:</p>
<pre><code class="language-tsx">// components/revenue-chart.tsx
"use client"

import { useId } from "react"
import { Bar, BarChart, CartesianGrid, Rectangle, XAxis, YAxis } from "recharts"
import {
  type ChartConfig,
  ChartContainer,
  ChartLegend,
  ChartLegendContent,
  ChartTooltip,
} from "@/components/ui/chart"
</code></pre>
<p>Recharts measures the DOM and handles mouse events, so the chart must be a Client Component. <code>app/page.tsx</code> can stay a Server Component because it only renders the chart.</p>
<p>Next come the data and the config:</p>
<pre><code class="language-tsx">const data = [
  { Month: "Jan", Revenue: 42000, Expenses: 31000 },
  { Month: "Feb", Revenue: 58000, Expenses: 34000 },
  // ...
]

const chartConfig = {
  Revenue: { label: "Revenue", color: "var(--chart-1)" },
  Expenses: { label: "Expenses", color: "var(--chart-2)" },
} satisfies ChartConfig
</code></pre>
<p>The keys in <code>chartConfig</code> must match the keys in your data. <code>ChartContainer</code> reads this config and creates a CSS variable for each key, scoped to this chart: <code>--color-Revenue</code> and <code>--color-Expenses</code>. Each one points at a theme color, so the chart switches colors in dark mode on its own.</p>
<p>The chart itself uses those variables:</p>
<pre><code class="language-tsx">&lt;ChartContainer config={chartConfig} className="aspect-auto h-[350px] w-full"&gt;
  &lt;BarChart accessibilityLayer data={data} barCategoryGap="30%" barGap={4}&gt;
    {/* ...gradient &lt;defs&gt;, grid, axes, tooltip, legend... */}
    &lt;Bar
      dataKey="Revenue"
      fill="var(--color-Revenue)"
      shape={(props) =&gt; &lt;Rectangle {...props} fill={`url(#${uid}-fill-0)`} /&gt;}
      radius={[6, 6, 0, 0]}
      maxBarSize={36}
    /&gt;
  &lt;/BarChart&gt;
&lt;/ChartContainer&gt;
</code></pre>
<p>A few details are worth knowing:</p>
<ul>
<li><p><strong>Height</strong> is set by <code>h-[350px]</code> on <code>ChartContainer</code>. Change it there.</p>
</li>
<li><p><strong>The gradient</strong> is drawn by the <code>shape</code> prop, while <code>fill</code> stays a solid color. That's why the legend and tooltip dots show a clean color instead of a gradient.</p>
</li>
<li><p><code>uid</code> comes from <code>useId()</code>, so the gradient IDs stay unique if you render two charts on the same page.</p>
</li>
</ul>
<p>The file also includes a <code>ChartBreakdownTooltip</code> component, about 70 lines, that shows the total and each series' percentage. If you'd rather use the standard <a href="https://www.shadcndeck.com/blog/shadcn-tooltip-component">shadcn tooltip</a>, pick <strong>Tooltip: Simple</strong> in ChartCN before copying. The whole file then drops from about 120 lines to about 50.</p>
<p><strong>Takeaway:</strong> in shadcn/ui charts, colors flow from theme variables through <code>chartConfig</code> into <code>--color-&lt;key&gt;</code> variables. Once you understand that path, you can edit any chart by hand.</p>
<h2 id="heading-step-6-pass-live-data-as-a-prop">Step 6: Pass Live Data as a Prop</h2>
<p>Hardcoded data is fine for a demo. For real data, go back to ChartCN, switch <strong>Code</strong> to <strong>Data as prop</strong>, and copy again. Save this version as <code>components/revenue-chart-live.tsx</code>.</p>
<p>The chart markup is identical. Only the top of the file changes: the <code>data</code> array is replaced by a type, and the component accepts <code>data</code> as a prop:</p>
<pre><code class="language-tsx">// components/revenue-chart-live.tsx
export type ChartRow = { Month: string; Revenue: number | null; Expenses: number | null }

export interface ChartProps {
  data: ChartRow[]
}

// ...chartConfig is unchanged...

export function Chart({ data }: ChartProps) {
  // ...same JSX as before
}
</code></pre>
<p>The series are typed as <code>number | null</code> because real data has gaps. A <code>null</code> value renders as a missing bar, not as a bar of zero.</p>
<p>Now create a function that loads the data on the server:</p>
<pre><code class="language-ts">// lib/get-revenue.ts
import type { ChartRow } from "@/components/revenue-chart-live"

export async function getRevenue(): Promise&lt;ChartRow[]&gt; {
  // Replace this with your database query or API call.
  // For example: const res = await fetch("https://api.example.com/revenue")
  return [
    { Month: "Jan", Revenue: 42000, Expenses: 31000 },
    { Month: "Feb", Revenue: 58000, Expenses: 34000 },
    { Month: "Mar", Revenue: 51000, Expenses: 29000 },
    { Month: "Apr", Revenue: 67000, Expenses: 38000 },
    { Month: "May", Revenue: 72000, Expenses: null },
  ]
}
</code></pre>
<p>Then call it from a Server Component page:</p>
<pre><code class="language-tsx">// app/live/page.tsx
import { Chart as RevenueChart } from "@/components/revenue-chart-live"
import { getRevenue } from "@/lib/get-revenue"

export default async function LivePage() {
  const data = await getRevenue()

  return (
    &lt;main className="mx-auto max-w-3xl p-8"&gt;
      &lt;h1 className="mb-6 text-2xl font-semibold"&gt;Revenue vs. expenses&lt;/h1&gt;
      &lt;RevenueChart data={data} /&gt;
    &lt;/main&gt;
  )
}
</code></pre>
<p>Open <code>http://localhost:3000/live</code>. May shows a Revenue bar with no Expenses bar next to it, because that value is <code>null</code>.</p>
<p>The page fetches on the server and sends only the rows to the client component. Your database credentials and API keys stay on the server. When you swap in a real <code>fetch</code> or database call, check the <a href="https://nextjs.org/docs/app/getting-started/fetching-data">Next.js data fetching docs</a> to control how often the data refreshes.</p>
<p><strong>Takeaway:</strong> keep data loading in a Server Component and rendering in a Client Component. The exported <code>ChartRow</code> type makes TypeScript check that your data matches what the chart expects.</p>
<h2 id="heading-common-issues-and-how-to-fix-them">Common Issues and How to Fix Them</h2>
<p><strong>Type errors such as "Property 'itemSorter' does not exist".</strong> Your project has Recharts 2 installed. Run <code>npm install recharts@latest</code> to upgrade to Recharts 3.</p>
<p><strong>The bars are black and the legend dots are missing.</strong> Your <code>app/globals.css</code> doesn't define <code>--chart-1</code> through <code>--chart-5</code>. Run <code>npx shadcn@latest init</code> again, or copy the chart variables from the shadcn/ui theming docs.</p>
<p><strong>Every chart stopped working after you pasted one.</strong> You probably saved the generated file over <code>components/ui/chart.tsx</code>. Restore it with <code>npx shadcn@latest add chart --overwrite</code> and save your chart under a different name.</p>
<p><strong>Two charts on one page have the same name.</strong> Every generated component is exported as <code>Chart</code>. Rename it when you import it, for example <code>import { Chart as SignupsChart } from "@/components/signups-chart"</code>.</p>
<h2 id="heading-summary">Summary</h2>
<ol>
<li><p><strong>The shadcn/ui chart component themes Recharts.</strong> It doesn't replace it, and ChartCN's output requires Recharts 3.</p>
</li>
<li><p><strong>Colors flow from CSS variables.</strong> <code>--chart-1</code> becomes <code>--color-Revenue</code> through <code>chartConfig</code>, so dark mode works without extra code.</p>
</li>
<li><p><strong>Save generated charts under their own names.</strong> <code>components/ui/chart.tsx</code> belongs to shadcn/ui.</p>
</li>
<li><p><strong>Use Data as prop for real data.</strong> Load it in a Server Component and pass the typed rows to the chart.</p>
</li>
<li><p><code>null</code> <strong>means "no data".</strong> Gaps render as gaps, not as zeros.</p>
</li>
</ol>
<p>Because ChartCN is a <a href="https://www.shadcndeck.com/chartcn">shadcn chart generator</a> rather than a component library, there is no ChartCN package to install or keep updated. You can browse all 13 <a href="https://www.shadcndeck.com/chartcn/charts">shadcn charts</a> it supports, including line, area, pie, radar, KPI cards, waterfall, and a heatmap. They all follow the same paste, pick, and copy workflow you used here.</p>
<p>The source is on <a href="https://github.com/ShadcnDeck/chartcn-shadcn-chart-generator">GitHub</a> under the MIT license. If it's useful, a star helps other developers find it.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an Open Source SaaS Landing Page Template with shadcn/ui ]]>
                </title>
                <description>
                    <![CDATA[ Most SaaS landing pages share the same core sections: a hero, social proof, features, pricing, FAQ, and a footer. And most developers end up building these from scratch on every project. That's repeti ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-landing-page-nextjs-shadcn/</link>
                <guid isPermaLink="false">6a70e0650d58f4d80d2eca59</guid>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                    <category>
                        <![CDATA[ shadcn ]]>
                    </category>
                
                    <category>
                        <![CDATA[ shadcnui ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ash ]]>
                </dc:creator>
                <pubDate>Mon, 03 Aug 2026 18:39:33 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/33d9aa05-3187-4d07-8aea-bcd83fe13ac0.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most SaaS landing pages share the same core sections: a hero, social proof, features, pricing, FAQ, and a footer. And most developers end up building these from scratch on every project. That's repetition, not engineering.</p>
<p>So I built and open-sourced a complete SaaS landing page template called <a href="https://www.shadcndeck.com/templates/chatdeck-saas-landing-page">ChatDeck</a>. It runs on Next.js 16, React 19, shadcn/ui with the new <code>base-nova</code> style, Tailwind CSS v4, and TypeScript. The full source is on GitHub under the MIT license. I built and open-sourced this template, and everything here comes from decisions made during that process.</p>
<p>Building it forced me to make real decisions on a stack that moved significantly in the past 12 months. This article is about those decisions: what worked, what didn't, and what I'd do differently if I started today.</p>
<p><strong>Prerequisites:</strong> This article assumes you're comfortable with React and TypeScript. Some familiarity with the Next.js App Router is helpful but not required. Each lesson is explained from first principles.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-the-stack-choices-and-why-they-matter">The Stack Choices and Why They Matter</a></p>
</li>
<li><p><a href="#heading-getting-started">Getting Started</a></p>
</li>
<li><p><a href="#heading-project-structure">Project Structure</a></p>
</li>
<li><p><a href="#heading-lesson-1-shadcnuis-new-base-nova-style-changes-what-accessible-means">Lesson 1: shadcn/ui's Newbase-novaStyle Changes What "Accessible" Means</a></p>
</li>
<li><p><a href="#heading-lesson-2-tailwind-css-v4-requires-a-mental-model-shift">Lesson 2: Tailwind CSS v4 Requires a Mental Model Shift</a></p>
</li>
<li><p><a href="#heading-lesson-3-oklch-colors-make-dark-mode-predictable">Lesson 3: OKLCH Colors Make Dark Mode Predictable</a></p>
</li>
<li><p><a href="#heading-lesson-4-page-architecture-flat-beats-clever">Lesson 4: Page Architecture — Flat Beats Clever</a></p>
</li>
<li><p><a href="#heading-lesson-5-staggered-animations-without-managing-individual-delays">Lesson 5: Staggered Animations Without Managing Individual Delays</a></p>
</li>
<li><p><a href="#heading-lesson-6-css-only-infinite-scroll-no-library-needed">Lesson 6: CSS-Only Infinite Scroll — No Library Needed</a></p>
</li>
<li><p><a href="#heading-lesson-7-css-subgrid-solves-pricing-card-alignment-natively">Lesson 7: CSS Subgrid Solves Pricing Card Alignment Natively</a></p>
</li>
<li><p><a href="#heading-lesson-8-inline-svgs-beat-image-libraries-for-simple-logos">Lesson 8: Inline SVGs Beat Image Libraries for Simple Logos</a></p>
</li>
<li><p><a href="#heading-what-id-do-differently">What I'd Do Differently</a></p>
</li>
<li><p><a href="#heading-summary">Summary</a></p>
</li>
</ul>
<h2 id="heading-the-stack-choices-and-why-they-matter">The Stack Choices and Why They Matter</h2>
<p>Before getting into the code, here's what the template runs on. Each choice was deliberate — none of these are defaults you get from <code>create-next-app</code>.</p>
<table>
<thead>
<tr>
<th>Technology</th>
<th>Version</th>
<th>Why I chose it</th>
</tr>
</thead>
<tbody><tr>
<td>Next.js</td>
<td>^16.0.3</td>
<td>App Router gives you React Server Components out of the box. Static sections like Hero and Features render on the server — no client-side JS needed for content that never changes.</td>
</tr>
<tr>
<td>React</td>
<td>19.2.0</td>
<td>React 19 stabilises the <code>use</code> hook and concurrent features. Staying on the latest version means the template doesn't immediately feel stale.</td>
</tr>
<tr>
<td>shadcn/ui</td>
<td>^4.13.0 (CLI)</td>
<td>Components are copied into your codebase, not installed as a package. You own the code. No version lock-in, no fighting library defaults when you need to customize.</td>
</tr>
<tr>
<td>Base UI (<code>@base-ui/react</code>)</td>
<td>^1.6.0</td>
<td>shadcn/ui's new <code>base-nova</code> style uses Base UI instead of Radix as its headless primitive layer. It has a smaller peer dependency footprint and tighter ARIA integration. More on this in Lesson 1.</td>
</tr>
<tr>
<td>Tailwind CSS</td>
<td>^4</td>
<td>v4 moves theme configuration from a JavaScript config file into CSS directly. Custom animations, color tokens, and radius scales all live in <code>globals.css</code>. More on this in Lesson 2.</td>
</tr>
<tr>
<td>Motion (<code>motion/react</code>)</td>
<td>^12.23.24</td>
<td>The rebranded Framer Motion. Handles entrance animations on the Hero and scroll-triggered animations on the Features section. Chosen over CSS animations because staggered sequences are much simpler to manage.</td>
</tr>
<tr>
<td>TypeScript</td>
<td>^5</td>
<td>Full type safety throughout. Component props, icon maps, pricing plan objects — all typed. Catches errors at build time, not at runtime.</td>
</tr>
<tr>
<td>Lucide React</td>
<td>^0.553.0</td>
<td>Consistent, well-maintained icon set that works cleanly with Tailwind's <code>size-*</code> utilities. No custom SVG wrangling needed for UI icons.</td>
</tr>
</tbody></table>
<p>The most interesting decisions in this list are the ones that reflect how the ecosystem changed in the past year: Base UI replacing Radix inside shadcn/ui, and Tailwind v4's shift to CSS-first configuration. The lessons below walk through each of these in detail, starting with the choices that had the biggest impact on how the code is actually written.</p>
<h2 id="heading-getting-started">Getting Started</h2>
<p>Before diving into the lessons, here's how to get the project running locally. Having it open alongside this article makes the code examples easier to follow.</p>
<pre><code class="language-bash">git clone https://github.com/ShadcnDeck/chatdeck-shadcn-saas-landing-page-template.git
cd chatdeck-shadcn-saas-landing-page-template
pnpm install
pnpm dev
</code></pre>
<p>Open <code>http://localhost:3000</code> and you'll see the full landing page running locally.</p>
<p>All section content lives as plain TypeScript arrays inside each Block component. To change the features, edit the <code>features</code> array in <code>FeatureSection.tsx</code>. To change pricing tiers, edit the <code>plans</code> array in <code>PricingSection.tsx</code>. No CMS, no config files — just TypeScript objects.</p>
<p>To customize colors, update the OKLCH values in <code>app/globals.css</code> under the <code>:root</code> block. Change <code>--primary</code> and every button, link, and accent color updates across the entire template.</p>
<p>Deploy to Vercel with a single <code>vercel</code> command or by pushing to GitHub and connecting the repo. Next.js is detected automatically.</p>
<h2 id="heading-project-structure">Project Structure</h2>
<p>Here's the full directory layout before we go through each part of it:</p>
<pre><code class="language-plaintext">chatdeck/
├── app/
│   ├── globals.css         # Theme tokens + custom animations (Tailwind v4 @theme)
│   ├── layout.tsx          # Root layout — Navbar, Footer, fonts
│   └── page.tsx            # Section imports — 16 lines
├── components/
│   ├── Blocks/             # Page sections (Hero, Features, Pricing, etc.)
│   ├── ui/                 # shadcn/ui components — base-nova style
│   └── navbar.tsx          # Scroll-aware sticky navbar
└── lib/
    └── utils.ts            # cn() helper (clsx + tailwind-merge)
</code></pre>
<p>The key separation is <code>Blocks/</code> vs <code>ui/</code>. The <code>ui/</code> folder holds primitive components — Button, Badge, Accordion — that come from shadcn/ui and rarely change. The <code>Blocks/</code> folder holds page-level sections that are specific to this template and change often. When you're customising, you mostly work in <code>Blocks/</code>. When you upgrade <a href="https://www.shadcndeck.com/blog/shadcn-components">shadcn/ui components</a>, you touch <code>ui/</code>.</p>
<p>The lessons below go through specific files in this structure piece by piece: <code>components.json</code> and <code>ui/accordion.tsx</code> in Lesson 1, <code>app/globals.css</code> in Lessons 2 and 3, <code>app/page.tsx</code> in Lesson 4, and the individual Block components in Lessons 5 through 8.</p>
<h2 id="heading-lesson-1-shadcnuis-new-base-nova-style-changes-what-accessible-means">Lesson 1: shadcn/ui's New <code>base-nova</code> Style Changes What "Accessible" Means</h2>
<p>If you've used shadcn/ui before, you know the default setup uses <strong>Radix UI</strong> primitives, headless components that handle focus management, keyboard navigation, and ARIA attributes. Radix has been the default for years.</p>
<p>But shadcn/ui introduced a new style in 2025 called <code>base-nova</code>, which replaces <a href="https://www.shadcndeck.com/blog/radix-vs-base-ui">Radix with <strong>Base UI</strong></a>, the headless primitive library from MUI.</p>
<p>Based on shadcn's public direction and the components released through 2025, <code>base-nova</code> appears to be the intended default going forward (though shadcn hasn't yet deprecated the Radix style).</p>
<p>In the project's <code>components.json</code>:</p>
<pre><code class="language-json">{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "base-nova",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "css": "app/globals.css",
    "baseColor": "neutral",
    "cssVariables": true
  },
  "iconLibrary": "lucide"
}
</code></pre>
<p>The <code>"style": "base-nova"</code> line means every component the shadcn/ui CLI installs wraps Base UI primitives instead of Radix. To understand what this changes in practice, here's what the same Accordion trigger component looks like in the older Radix-based default style:</p>
<pre><code class="language-tsx">// Radix-based default style (the old way)
import * as AccordionPrimitive from "@radix-ui/react-accordion"

const AccordionTrigger = React.forwardRef&lt;
  React.ElementRef&lt;typeof AccordionPrimitive.Trigger&gt;,
  React.ComponentPropsWithoutRef&lt;typeof AccordionPrimitive.Trigger&gt;
&gt;(({ className, children, ...props }, ref) =&gt; {
  const [isOpen, setIsOpen] = React.useState(false)

  return (
    &lt;AccordionPrimitive.Header className="flex"&gt;
      &lt;AccordionPrimitive.Trigger
        ref={ref}
        className={cn("flex flex-1 items-center justify-between ...", className)}
        onClick={() =&gt; setIsOpen(!isOpen)}
        {...props}
      &gt;
        {children}
        &lt;ChevronDownIcon
          className={cn(
            "h-4 w-4 shrink-0 transition-transform duration-200",
            isOpen ? "hidden" : "block"
          )}
        /&gt;
        &lt;ChevronUpIcon
          className={cn(
            "h-4 w-4 shrink-0 transition-transform duration-200",
            isOpen ? "block" : "hidden"
          )}
        /&gt;
      &lt;/AccordionPrimitive.Trigger&gt;
    &lt;/AccordionPrimitive.Header&gt;
  )
})
</code></pre>
<p>Notice the <code>useState(false)</code> tracking whether the accordion is open, and the <code>onClick</code> handler that toggles it. This means the component has to manually keep its own <code>isOpen</code> state in sync with what Radix internally knows about the open/closed state.</p>
<p>Now here's the same component using the <code>base-nova</code> style with Base UI:</p>
<pre><code class="language-tsx">// components/ui/accordion.tsx — base-nova style (the new way)
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"

function AccordionTrigger({ className, children, ...props }: AccordionPrimitive.Trigger.Props) {
  return (
    &lt;AccordionPrimitive.Header className="flex"&gt;
      &lt;AccordionPrimitive.Trigger
        data-slot="accordion-trigger"
        className={cn(
          "group/accordion-trigger relative flex flex-1 items-start ...",
          className
        )}
        {...props}
      &gt;
        {children}
        &lt;ChevronDownIcon
          className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
        /&gt;
        &lt;ChevronUpIcon
          className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline"
        /&gt;
      &lt;/AccordionPrimitive.Trigger&gt;
    &lt;/AccordionPrimitive.Header&gt;
  )
}
</code></pre>
<p>No <code>useState</code>. No <code>onClick</code>. No <code>isOpen</code> variable. The chevron visibility is controlled entirely by <code>group-aria-expanded/accordion-trigger:hidden</code> — a Tailwind class that reads the <code>aria-expanded</code> attribute Base UI sets automatically on the trigger element.</p>
<p><strong>The lesson here:</strong> in the Radix version, you have two parallel systems: the component's own <code>isOpen</code> state, and the ARIA attributes that the library manages separately for screen readers. These can drift out of sync — for example, if the accordion closes via keyboard navigation, the ARIA state updates correctly but your <code>isOpen</code> state doesn't unless you wire up the right callbacks. In the Base UI version, there is only one system. ARIA state IS the state. Tailwind reads it directly. There's nothing to keep in sync and nothing that can drift.</p>
<p><strong>Lesson:</strong> use the primitive library's ARIA attributes as your source of truth for visual state. If your headless component library already sets <code>aria-expanded</code>, <code>aria-selected</code>, or <code>aria-checked</code>, Tailwind can respond to those directly with <code>aria-*</code> variant classes — no parallel JavaScript state needed.</p>
<p>So when you install shadcn/ui today, choose <code>base-nova</code> over the default Radix style. You get tighter Base UI integration, a smaller peer dependency footprint, and components that are more aligned with where the ecosystem is moving.</p>
<h2 id="heading-lesson-2-tailwind-css-v4-requires-a-mental-model-shift">Lesson 2: Tailwind CSS v4 Requires a Mental Model Shift</h2>
<p>Tailwind CSS v4 moves primary theme configuration out of the JavaScript config file and into CSS. This sounds small. In practice, it changes how you think about the entire theming system.</p>
<p>In Tailwind v3, you'd extend the theme in <code>tailwind.config.js</code>:</p>
<pre><code class="language-js">// OLD — tailwind.config.js (v3)
module.exports = {
  theme: {
    extend: {
      animation: {
        marquee: "marquee 40s linear infinite",
      },
      keyframes: {
        marquee: {
          from: { transform: "translateX(0)" },
          to: { transform: "translateX(calc(-100% - var(--gap)))" },
        },
      },
    },
  },
}
</code></pre>
<p>In Tailwind v4, that same configuration lives in your CSS file instead:</p>
<pre><code class="language-css">/* app/globals.css — Tailwind v4 */
@import "tailwindcss";

@theme inline {
  --animate-marquee: marquee var(--duration) infinite linear;
  --animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;

  @keyframes marquee {
    from { transform: translateX(0); }
    to   { transform: translateX(calc(-100% - var(--gap))); }
  }

  --radius-2xl: calc(var(--radius) * 1.8);
  --radius-3xl: calc(var(--radius) * 2.2);
  --radius-4xl: calc(var(--radius) * 2.6);
}
</code></pre>
<p>The <code>@theme inline</code> block extends Tailwind's design token system. Define <code>--animate-marquee</code> here and you can use <code>className="animate-marquee"</code> anywhere in your components. Tailwind generates the utility class automatically from the CSS variable.</p>
<p>Custom animations, radius scales, and color tokens all live in CSS now. The benefit is that CSS is where styles belong. The config file was always an indirection layer between "what I want my design system to look like" and "where that actually lives." Tailwind v4 removes the indirection.</p>
<p><strong>The friction:</strong> if you start a Tailwind v4 project with a v3 mental model, you'll spend time looking for theme config in the wrong place. Read the v4 migration guide before you start, not after you're confused.</p>
<p><strong>Lesson:</strong> move your mental model of "theme config" from JavaScript to CSS. In Tailwind v4, if you want a custom animation, a new radius scale, or a color token, define it in <code>@theme inline</code> inside <code>globals.css</code>. That's where it belongs, and that's where every developer on your team will find it.</p>
<h2 id="heading-lesson-3-oklch-colors-make-dark-mode-predictable">Lesson 3: OKLCH Colors Make Dark Mode Predictable</h2>
<p>The template uses OKLCH color values throughout, not hex or HSL:</p>
<pre><code class="language-css">:root {
  --background: oklch(1 0 0);        /* white */
  --foreground: oklch(0.145 0 0);    /* near-black */
  --primary: oklch(0.205 0 0);
  --border: oklch(0.922 0 0);
}

.dark {
  --background: oklch(0.145 0 0);    /* near-black */
  --foreground: oklch(0.985 0 0);    /* near-white */
  --primary: oklch(0.922 0 0);
  --border: oklch(1 0 0 / 10%);      /* white at 10% opacity */
}
</code></pre>
<p>OKLCH is a perceptually uniform color space. When you increase the lightness value in OKLCH, the color actually <em>looks</em> lighter to human eyes, consistently. Hex and HSL don't guarantee this. You can increase the <code>L</code> in HSL and get a color that looks the same or even darker depending on the hue.</p>
<p>For dark mode specifically, this matters because you're inverting a whole color system. With HSL, you'll often end up manually tweaking individual color values until contrast ratios look right. With OKLCH, increasing or decreasing the lightness value gives you predictable results across all your tokens.</p>
<p>The dark mode switch itself is <strong>zero JavaScript.</strong> Adding <code>class="dark"</code> to the <code>&lt;html&gt;</code> element swaps every CSS variable. Tailwind reads the updated variables and re-renders every component. There's no context provider and no <code>useTheme</code> hook needed for the CSS layer — just a class toggle on the root element.</p>
<p><strong>Lesson:</strong> swap your color tokens to OKLCH. When defining dark mode values, adjust the first OKLCH parameter (lightness) and the result will look predictably lighter or darker. With hex or HSL you're often guessing; with OKLCH you're reasoning.</p>
<h2 id="heading-lesson-4-page-architecture-flat-beats-clever">Lesson 4: Page Architecture — Flat Beats Clever</h2>
<p>The main page file is 16 lines:</p>
<pre><code class="language-tsx">// app/page.tsx
import Hero from "@/components/Blocks/Hero";
import { LogoCarousel } from "@/components/Blocks/LogoCarousel";
import { FeatureSection } from "@/components/Blocks/FeatureSection";
import { TeamSection } from "@/components/Blocks/TeamSection";
import { TestimonialSection } from "@/components/Blocks/TestimonialSection";
import { PricingSection } from "@/components/Blocks/PricingSection";
import { FaqSection } from "@/components/Blocks/FaqSection";

export default function Home() {
  return (
    &lt;main className="min-h-screen bg-white dark:bg-black"&gt;
      &lt;div className="mx-auto max-w-7xl px-6 pt-40"&gt;
        &lt;Hero /&gt;
        &lt;LogoCarousel /&gt;
        &lt;FeatureSection /&gt;
        &lt;TeamSection /&gt;
        &lt;TestimonialSection /&gt;
        &lt;PricingSection /&gt;
        &lt;FaqSection /&gt;
      &lt;/div&gt;
    &lt;/main&gt;
  );
}
</code></pre>
<p>No dynamic imports, no lazy-loading config, no context providers wrapping everything. Each section is a completely self-contained component in <code>components/Blocks/</code>. None of them import from each other.</p>
<p>This decision came from watching how developers actually use <a href="https://www.shadcndeck.com/templates">shadcn templates</a>. The first thing anyone does after cloning is delete the sections they don't need and reorder the ones they keep. With flat imports, removing the Team section is one deleted line. Reordering sections is moving one line. Adding a new section is creating a file and adding one import.</p>
<p>The alternative (a sections array, a renderer loop, a config file that controls order) sounds sophisticated. In practice, it adds indirection that makes the template harder to understand and slower to customize. Templates should be obvious, not impressive.</p>
<p><strong>Lesson:</strong> in a template context, the simplest architecture is the correct architecture. The developer cloning your template isn't impressed by abstraction. They want to understand the code fast and change it faster.</p>
<h2 id="heading-lesson-5-staggered-animations-without-managing-individual-delays">Lesson 5: Staggered Animations Without Managing Individual Delays</h2>
<p>The Hero section uses entrance animations where each element fades up sequentially: badge first, then heading, then subheading, then CTA. The naïve approach sets a different <code>delay</code> prop on each element manually. The correct approach uses <code>staggerChildren</code>:</p>
<pre><code class="language-tsx">// components/Blocks/Hero.tsx
"use client"
import { motion, type Variants } from "motion/react"

const containerVariants: Variants = {
  hidden: { opacity: 0 },
  visible: {
    opacity: 1,
    transition: {
      staggerChildren: 0.15,  // each child animates 150ms after the previous
      delayChildren: 0.1,
    },
  },
}

const fadeUpVariants: Variants = {
  hidden: { opacity: 0, y: 20 },
  visible: {
    opacity: 1,
    y: 0,
    transition: { duration: 0.5, ease: "easeOut" },
  },
}

const Hero = () =&gt; (
  &lt;motion.div variants={containerVariants} initial="hidden" animate="visible"&gt;
    &lt;motion.div variants={fadeUpVariants}&gt;
      {/* Badge */}
    &lt;/motion.div&gt;
    &lt;motion.h1 variants={fadeUpVariants}&gt;
      AI Chatbot for Customer Support.
    &lt;/motion.h1&gt;
    &lt;motion.p variants={fadeUpVariants}&gt;
      {/* Subheading */}
    &lt;/motion.p&gt;
    &lt;motion.div variants={fadeUpVariants}&gt;
      {/* CTA */}
    &lt;/motion.div&gt;
  &lt;/motion.div&gt;
)
</code></pre>
<p>The parent defines <code>staggerChildren: 0.15</code>. Every child with <code>variants={fadeUpVariants}</code> automatically inherits a 150ms offset from the previous child. Want to add a new element? Give it <code>variants={fadeUpVariants}</code> and the stagger chain extends automatically. No manually updated delay values.</p>
<p>The Features section uses <strong>scroll-triggered animations</strong> with a different easing:</p>
<pre><code class="language-tsx">// components/Blocks/FeatureSection.tsx
&lt;motion.div
  initial={{ opacity: 0, y: 40 }}
  whileInView={{ opacity: 1, y: 0 }}
  viewport={{ once: true, amount: 0.3 }}
  transition={{
    duration: 0.5,
    delay: index * 0.15,
    ease: [0.22, 1, 0.36, 1],
  }}
&gt;
</code></pre>
<p><code>viewport={{ once: true }}</code> fires the animation once when the element enters the viewport, not on every scroll pass. <code>amount: 0.3</code> starts the animation when 30% of the element is visible, not when the full element is on screen. The cubic bezier <code>[0.22, 1, 0.36, 1]</code> is a fast-out-slow-in curve that feels physical rather than mechanical.</p>
<p><strong>Quick note on the import:</strong> most of the core API is compatible, but <code>motion/react</code> isn't a straight drop-in rename of <code>framer-motion</code>. If you're upgrading an existing project, check the <a href="https://motion.dev/docs/react-upgrade-guide">official migration guide</a> before swapping the import. Layout animations, <code>AnimatePresence</code> behaviour, and some hooks changed.</p>
<p><strong>Lesson:</strong> define animation variants at the parent level and use <code>staggerChildren</code> to orchestrate the sequence. Never set <code>delay</code> manually on individual elements — that creates a brittle list of numbers you have to update every time you add or remove an element. Let the parent handle timing; let children just declare what they animate to.</p>
<h2 id="heading-lesson-6-css-only-infinite-scroll-no-library-needed">Lesson 6: CSS-Only Infinite Scroll — No Library Needed</h2>
<p>The testimonials use a dual-row auto-scrolling marquee. The second row scrolls in reverse. There's no third-party marquee package. It's a small component built entirely on CSS animations defined in Tailwind v4's <code>@theme</code> block.</p>
<pre><code class="language-tsx">// components/ui/marquee.tsx
export function Marquee({
  reverse = false,
  pauseOnHover = false,
  vertical = false,
  children,
  repeat = 4,
  ...props
}) {
  return (
    &lt;div className="group flex gap-(--gap) overflow-hidden [--duration:40s] [--gap:2rem]"&gt;
      {Array(repeat).fill(0).map((_, i) =&gt; (
        &lt;div
          key={i}
          className={cn("flex shrink-0 justify-around gap-(--gap)", {
            "animate-marquee flex-row": !vertical,
            "group-hover:paused": pauseOnHover,
            "[animation-direction:reverse]": reverse,
          })}
        &gt;
          {children}
        &lt;/div&gt;
      ))}
    &lt;/div&gt;
  )
}
</code></pre>
<p>The <code>repeat={4}</code> prop renders the children 4 times side by side. As the CSS animation scrolls the container left, the repetitions create a seamless loop. By the time the first set has scrolled off screen, the second set is already in position.</p>
<p><code>group-hover:paused</code> is Tailwind applying <code>animation-play-state: paused</code> when the parent has <code>group</code> class and is hovered. No <code>onMouseEnter</code>/<code>onMouseLeave</code> handlers or state, just pure CSS.</p>
<p>To customize the scroll speed without touching the component source, you override the CSS variable inline:</p>
<pre><code class="language-tsx">&lt;Marquee pauseOnHover className="[--duration:20s]"&gt;
  {items.map(item =&gt; &lt;Card key={item.id} {...item} /&gt;)}
&lt;/Marquee&gt;
</code></pre>
<p><code>[--duration:20s]</code> is a Tailwind arbitrary property. It sets <code>--duration</code> directly on the element, which the animation reads via <code>var(--duration)</code>. Speed customization without a prop, without touching the component.</p>
<p><strong>Lesson:</strong> before reaching for a third-party animation package, check whether a CSS keyframe animation and a couple of Tailwind utilities can do the same job. A marquee, a fade loop, a pulsing skeleton — all of these are achievable with native CSS. Fewer dependencies means fewer breaking changes when the ecosystem moves.</p>
<h2 id="heading-lesson-7-css-subgrid-solves-pricing-card-alignment-natively">Lesson 7: CSS Subgrid Solves Pricing Card Alignment Natively</h2>
<p>The pricing section has three cards: Free, Pro, and Business. Each card has four rows: plan name, price, CTA button, and features list. The features list height varies between plans. Without CSS subgrid, the rows don't align across cards.</p>
<p>The common workaround is <code>min-height</code> on each row, or JavaScript that measures each card and sets explicit heights. Both approaches are fragile. Subgrid solves it in CSS:</p>
<pre><code class="language-tsx">// components/Blocks/PricingSection.tsx
&lt;div className="grid lg:grid-cols-3"&gt;
  {plans.map((plan) =&gt; (
    &lt;div className="p-8 grid grid-rows-subgrid row-span-4 gap-6"&gt;
      &lt;div&gt;{/* Plan name + description */}&lt;/div&gt;
      &lt;div&gt;{/* Price */}&lt;/div&gt;
      &lt;div&gt;{/* CTA button */}&lt;/div&gt;
      &lt;div&gt;{/* Features list */}&lt;/div&gt;
    &lt;/div&gt;
  ))}
&lt;/div&gt;
</code></pre>
<p><code>grid-rows-subgrid</code> tells each card to participate in the parent grid's row tracks rather than creating its own. Each card spans 4 rows (<code>row-span-4</code>). The plan name row, price row, CTA row, and features row align across all three cards (regardless of content height) because they're all on the same row tracks.</p>
<p>Each card's <code>row-span-4</code> reserves four rows in the parent's implicit grid. Because every card spans the same four shared row tracks, their internal rows align automatically even though the parent never declares explicit row heights.</p>
<p>CSS subgrid has been in all modern browsers since late 2023. There's no reason to reach for a JavaScript layout solution when the platform handles it.</p>
<p><strong>Lesson:</strong> when you have a grid of cards where each card has multiple internal rows that need to align across columns, reach for <code>grid-rows-subgrid</code> before reaching for <code>min-height</code> or JavaScript. Define the number of rows each card spans with <code>row-span-N</code>, and the browser handles the rest.</p>
<h2 id="heading-lesson-8-inline-svgs-beat-image-libraries-for-simple-logos">Lesson 8: Inline SVGs Beat Image Libraries for Simple Logos</h2>
<p>The logo carousel renders 12 brand logos: Shopify, Stripe, GitHub, Google, and others. The first instinct is to use a package like <code>react-icons</code> or <code>simple-icons</code>. I went a different direction: inline SVG paths stored as a plain TypeScript object.</p>
<pre><code class="language-tsx">// components/Blocks/LogoCarousel.tsx
const iconMap = {
  stripe: "M13.976 9.15c-2.172-.806...",
  github: "M12 .297c-6.63 0-12...",
  google: "M12.48 10.92v3.28h7.84...",
  // ...
} as const

const SimpleIcon = ({ iconSlug, size = 24 }: { iconSlug: string; size?: number }) =&gt; {
  const iconPath = iconMap[iconSlug as keyof typeof iconMap]
  return (
    &lt;svg role="img" viewBox="0 0 24 24" className="fill-black dark:fill-white"&gt;
      &lt;path d={iconPath} /&gt;
    &lt;/svg&gt;
  )
}
</code></pre>
<p>The <code>fill-black dark:fill-white</code> class means every logo automatically inverts in dark mode: no separate dark mode logo assets, and no conditional rendering based on theme.</p>
<p>The carousel itself duplicates the logo array to create a seamless loop:</p>
<pre><code class="language-tsx">{/* First pass */}
{techCompanies.map((company, i) =&gt; &lt;LogoCard key={`first-${i}`} {...company} /&gt;)}
{/* Second pass — identical, creates the seamless loop */}
{techCompanies.map((company, i) =&gt; &lt;LogoCard key={`second-${i}`} {...company} /&gt;)}
</code></pre>
<p>The CSS animation (<code>animate-logo-scroll</code>) scrolls the container left. When the first pass disappears off the left edge, the second pass is already in position. The loop is seamless.</p>
<p><strong>The trade-off:</strong> maintaining SVG paths manually is fine for a fixed set of logos. If you need a large dynamic icon set, reach for <code>simple-icons</code> or a proper icon library. For 12 brand logos that rarely change, this approach ships zero extra dependencies.</p>
<p><strong>Lesson:</strong> match your tooling to your actual requirements. A logo carousel with a fixed set of brand logos doesn't need an icon library — it needs a TypeScript object and two Tailwind classes. Installing a package to solve a problem you could solve with 10 lines of code adds maintenance surface for no gain.</p>
<h2 id="heading-what-id-do-differently">What I'd Do Differently</h2>
<p>These are the three decisions I'd change if starting the template today.</p>
<h3 id="heading-1-extract-animation-variants-to-a-shared-file">1. Extract Animation Variants to a Shared File</h3>
<p><code>containerVariants</code> and <code>fadeUpVariants</code> are currently defined locally in both <code>Hero.tsx</code> and <code>FeatureSection.tsx</code>. If you want to change the global animation timing (say, reduce duration from 0.5s to 0.3s) you update two files. A shared <code>lib/animations.ts</code> exporting the standard variants would make global timing changes a one-line edit.</p>
<pre><code class="language-ts">// lib/animations.ts
export const fadeUpVariants: Variants = {
  hidden: { opacity: 0, y: 20 },
  visible: { opacity: 1, y: 0, transition: { duration: 0.5, ease: "easeOut" } },
}

export const containerVariants: Variants = {
  hidden: { opacity: 0 },
  visible: { opacity: 1, transition: { staggerChildren: 0.15, delayChildren: 0.1 } },
}
</code></pre>
<h3 id="heading-2-use-subgrid-in-the-features-section-too">2. Use Subgrid in the Features Section Too</h3>
<p>The Features grid uses a border-based visual separation pattern — borders between cells create the grid appearance. It works, but the hover states have an inconsistency: the gradient hover overlay height varies slightly between cells in the same row because content heights differ. Subgrid would lock those row heights across cards the same way it does in the Pricing section.</p>
<h3 id="heading-3-use-nextfont-more-consistently">3. Use <code>next/font</code> More Consistently</h3>
<p>The layout loads both Geist and Inter font families. Inter is used via <code>--font-sans</code>. Geist is loaded but the <code>geistSans.variable</code> and <code>geistMono.variable</code> are applied to <code>&lt;body&gt;</code> as className strings while Inter drives the actual font rendering through the CSS variable. The result is that Geist is loaded but not actually displayed. Cleaning this up could shave tens of kilobytes from the font payload — worth verifying in Lighthouse or the Network tab before deploying.</p>
<h2 id="heading-summary">Summary</h2>
<p>These are the five things from this build worth taking into your next project:</p>
<ol>
<li><p><strong>shadcn/ui's</strong> <code>base-nova</code> <strong>style</strong> runs on Base UI primitives. ARIA state drives visual state — no parallel JavaScript state needed.</p>
</li>
<li><p><strong>Tailwind v4 moves theme config to CSS.</strong> All theme tokens, custom animations, and radius scales live in CSS via <code>@theme inline</code>. This is the right place for them.</p>
</li>
<li><p><strong>OKLCH gives predictable dark mode contrast.</strong> Adjusting lightness in OKLCH actually changes perceived brightness. Hex and HSL don't guarantee this.</p>
</li>
<li><p><code>staggerChildren</code> <strong>in motion/react</strong> eliminates manually managed animation delays. The parent orchestrates while the children just declare their animation variant.</p>
</li>
<li><p><strong>CSS subgrid (</strong><code>grid-rows-subgrid</code><strong>)</strong> aligns card rows across columns natively. No JavaScript measurement, no fixed heights.</p>
</li>
</ol>
<p>The full template is MIT-licensed and available at <a href="https://github.com/ShadcnDeck/chatdeck-shadcn-saas-landing-page-template">github.com/ShadcnDeck/chatdeck-shadcn-saas-landing-page-template</a>. If it's useful, a star helps others find it.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
