How to Build an HR Software Landing Page with HTML, CSS and JavaScript

How to Build an HR Software Landing Page with HTML, CSS and JavaScript

Software companies sell with landing pages, and HR software is a great one to learn from. The page has to show a product people can't touch yet, explain several modules without overwhelming anyone, and make pricing feel simple. In this tutorial you'll build exactly that for a made-up product, Teamwise HR.

The page has a product dashboard drawn entirely in HTML and CSS, feature tabs you can use with the keyboard, a pricing section with a monthly/yearly switch and a team-size slider, an animated FAQ and a demo booking form with friendly validation.

Open the live demo Switch the tabs and drag the team-size slider.

Teamwise HR landing page hero: the headline All of your HR in one calm place with a highlighter stroke, two buttons, and a tilted dashboard mockup with headcount cards, a bar chart and a list of who is out
The finished hero, with a dashboard mockup made from HTML and CSS, not an image.

Like our freight logistics landing page, this tutorial is split into three parts:

  1. Part 1, HTML: the content and structure, with the right ARIA roles.
  2. Part 2, CSS: the light SaaS look, the dashboard mockup and the responsive layout.
  3. Part 3, JavaScript: tabs, pricing, the accordion and form validation.

What you'll learn

Project setup

hr-landing/
├── index.html   ← all sections, the dashboard mockup and the form
├── style.css    ← theme, layout, mockups, mobile styles
└── script.js    ← menu, tabs, pricing, FAQ, form validation

Every file is divided by marker comments such as <!-- ---------- PRICING ---------- -->. The code blocks follow the markers from top to bottom, so copying them in order rebuilds each file.

Plan the page first

A software landing page has a well-known order, and each section answers the visitor's next question:

  1. Hero: what is it, and what does it look like?
  2. Logos: do other companies trust it?
  3. Feature tabs: what can it do for me?
  4. Modules: what else is included?
  5. Pricing: what will it cost my team?
  6. FAQ: what about my doubts?
  7. Demo form: how do I start?

Part 1: The HTML

1.1 The head and the header

index.html: head
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Teamwise HR - HR Software Landing Page</title>
  <meta name="description" content="Hiring, onboarding, time off and payroll in one simple HR platform.">
  <link rel="stylesheet" href="style.css">
</head>
<body>
index.html: header
  <!-- ---------- HEADER ---------- -->
  <header class="site-header">
    <div class="wrap nav">
      <a class="logo" href="#top"><span class="logo-mark" aria-hidden="true">t</span>Teamwise<b>HR</b></a>
      <button class="menu-btn" type="button" aria-expanded="false" aria-controls="menu" aria-label="Open menu"><span></span></button>
      <nav id="menu" class="menu" aria-label="Main">
        <a href="#features">Features</a>
        <a href="#modules">Modules</a>
        <a href="#pricing">Pricing</a>
        <a href="#faq">FAQ</a>
        <a href="#demo" class="btn btn-small">Book a demo</a>
      </nav>
    </div>
  </header>

  <main id="top">

The header is the same pattern as in the freight tutorial: a logo, a <nav> of section links, and a menu button for phones with aria-expanded and aria-controls. The last link is styled as a button because "Book a demo" is the one action we want every visitor to take.

1.2 The hero and the dashboard mockup

index.html: hero
    <!-- ---------- HERO ---------- -->
    <section class="hero">
      <div class="wrap hero-grid">
        <div class="hero-copy">
          <p class="pill"><span>New</span> Payroll for 30+ countries</p>
          <h1>All of your HR in <span class="marker">one calm place</span></h1>
          <p class="lead">Hire, onboard, track time off and run payroll from one dashboard your team
             will actually enjoy using.</p>
          <div class="hero-actions">
            <a class="btn" href="#demo">Book a free demo</a>
            <a class="btn btn-light" href="#pricing">See pricing</a>
          </div>
          <p class="rating"><span class="stars" aria-hidden="true">&#9733;&#9733;&#9733;&#9733;&#9733;</span>
             4.8 out of 5 from 1,200+ reviews</p>
        </div>

        <div class="app" aria-hidden="true">
          <div class="app-bar"><i></i><i></i><i></i><span>app.teamwise.example</span></div>
          <div class="app-body">
            <aside class="app-side">
              <b class="on">Dashboard</b><b>People</b><b>Time off</b><b>Payroll</b><b>Hiring</b>
            </aside>
            <div class="app-main">
              <p class="hello">Good morning, Priya</p>
              <div class="kpis">
                <div class="kpi"><span>Headcount</span><strong>248</strong><em class="up">+6 this month</em></div>
                <div class="kpi"><span>On leave today</span><strong>7</strong><em>3 sick, 4 holiday</em></div>
                <div class="kpi"><span>Open roles</span><strong>12</strong><em class="up">38 applicants</em></div>
              </div>
              <div class="app-row">
                <div class="panel">
                  <p class="panel-title">New hires per month</p>
                  <div class="bars">
                    <span style="--h: 40%" data-m="Jun"></span><span style="--h: 55%" data-m="Jul"></span><span style="--h: 35%" data-m="Aug"></span>
                    <span style="--h: 70%" data-m="Sep"></span><span style="--h: 60%" data-m="Oct"></span><span style="--h: 90%" data-m="Nov"></span>
                  </div>
                </div>
                <div class="panel">
                  <p class="panel-title">Who's out</p>
                  <ul class="out">
                    <li><i style="--c: #f59e0b">ML</i>Marco L.<small>Holiday</small></li>
                    <li><i style="--c: #10b981">JS</i>Jana S.<small>Sick</small></li>
                    <li><i style="--c: #6366f1">TO</i>Tom O.<small>Holiday</small></li>
                  </ul>
                </div>
              </div>
            </div>
          </div>
          <div class="toast"><span>&#10003;</span><div><strong>Payroll approved</strong><small>248 payslips sent</small></div></div>
        </div>
      </div>
    </section>

The left column has a small "New" pill, the headline, a lead paragraph, two buttons and a rating. The words "one calm place" are wrapped in <span class="marker">, which will get an animated highlighter stroke.

The right column, .app, is a fake app window: a browser bar with three dots, a sidebar, three KPI cards, a bar chart and a "Who's out" list. Why not just use a screenshot?

Each bar sets its own height with a CSS variable, style="--h: 70%", and its month in data-m. Each avatar sets its colour the same way with --c. Because the whole mockup is a picture of the product rather than real content, it has aria-hidden="true".

1.3 The logo strip

index.html: logos
    <!-- ---------- LOGOS ---------- -->
    <section class="logos" aria-label="Customers">
      <div class="wrap">
        <p>Trusted by 3,000+ growing teams</p>
        <div class="logo-row">
          <span>Brightwear</span><span>Kettle&amp;Co</span><span>NOVA labs</span><span>Verde</span><span>orbitly</span><span>Fieldhouse</span>
        </div>
      </div>
    </section>

Real sites use customer logo images here. We fake them with text in different styles, which is enough to show the layout.

1.4 Feature tabs with ARIA roles

index.html: feature tabs
    <!-- ---------- FEATURES TABS ---------- -->
    <section class="section" id="features">
      <div class="wrap">
        <div class="section-head">
          <p class="eyebrow">Features</p>
          <h2>Everything from first interview to first payslip</h2>
        </div>

        <div class="tabs" role="tablist" aria-label="HR features">
          <button role="tab" id="tab-hiring" aria-controls="panel-hiring" aria-selected="true">Hiring</button>
          <button role="tab" id="tab-onboarding" aria-controls="panel-onboarding" aria-selected="false" tabindex="-1">Onboarding</button>
          <button role="tab" id="tab-timeoff" aria-controls="panel-timeoff" aria-selected="false" tabindex="-1">Time off</button>
          <button role="tab" id="tab-payroll" aria-controls="panel-payroll" aria-selected="false" tabindex="-1">Payroll</button>
        </div>

        <div class="tab-panel" role="tabpanel" id="panel-hiring" aria-labelledby="tab-hiring" tabindex="0">
          <div class="feature-text">
            <h3>Hire the right people, faster</h3>
            <p>Post a job to 20 job boards in one click and move candidates through a simple board.</p>
            <ul class="checks"><li>Drag-and-drop hiring pipeline</li><li>Interview scorecards</li><li>Offer letters with e-signature</li></ul>
          </div>
          <div class="mock board">
            <div><p>Applied <b>14</b></p><span>Alex Kim</span><span>Rosa M.</span><span>Liam P.</span></div>
            <div><p>Interview <b>5</b></p><span>Noor A.</span><span class="hot">Ben W.</span></div>
            <div><p>Offer <b>2</b></p><span class="hot">Chloe D.</span></div>
          </div>
        </div>

        <div class="tab-panel" role="tabpanel" id="panel-onboarding" aria-labelledby="tab-onboarding" tabindex="0" hidden>
          <div class="feature-text">
            <h3>A great first day, every time</h3>
            <p>New starters get a welcome page with tasks, documents and their buddy before day one.</p>
            <ul class="checks"><li>Reusable onboarding checklists</li><li>Contracts signed online</li><li>Laptop and account requests</li></ul>
          </div>
          <div class="mock checklist">
            <p class="mock-title">Welcome, Ben! <span>3 of 5 done</span></p>
            <div class="meter"><span style="--w: 60%"></span></div>
            <ul><li class="done">Sign your contract</li><li class="done">Add bank details</li><li class="done">Meet your buddy</li><li>Set up your laptop</li><li>Read the handbook</li></ul>
          </div>
        </div>

        <div class="tab-panel" role="tabpanel" id="panel-timeoff" aria-labelledby="tab-timeoff" tabindex="0" hidden>
          <div class="feature-text">
            <h3>Time off without the spreadsheet</h3>
            <p>Employees request leave in two taps. Managers approve from email or Slack.</p>
            <ul class="checks"><li>Balances that update themselves</li><li>Team calendar with clashes flagged</li><li>Public holidays for 60 countries</li></ul>
          </div>
          <div class="mock rings">
            <div class="ring" style="--p: 64; --c: #6366f1"><strong>16</strong><span>Holiday days left</span></div>
            <div class="ring" style="--p: 80; --c: #14b8a6"><strong>8</strong><span>Sick days left</span></div>
          </div>
        </div>

        <div class="tab-panel" role="tabpanel" id="panel-payroll" aria-labelledby="tab-payroll" tabindex="0" hidden>
          <div class="feature-text">
            <h3>Payroll in minutes, not days</h3>
            <p>Hours, leave and bonuses flow straight into payroll, so there's nothing to copy across.</p>
            <ul class="checks"><li>Automatic tax calculations</li><li>Payslips emailed to every employee</li><li>Reports your accountant will love</li></ul>
          </div>
          <div class="mock payslip">
            <p class="mock-title">November payroll <span class="ok">Ready</span></p>
            <dl><dt>Gross pay</dt><dd>$412,600</dd><dt>Taxes</dt><dd>$98,420</dd><dt>Benefits</dt><dd>$21,300</dd><dt>Net pay</dt><dd class="total">$292,880</dd></dl>
          </div>
        </div>
      </div>
    </section>

Tabs need the right roles so screen readers announce them as tabs, "tab 2 of 4", and not as a row of random buttons. The pattern has three parts:

Each panel holds a short description, a checklist and a small mock of that feature: a hiring board, an onboarding checklist, leave balance rings and a payroll summary.

The Hiring tab is selected: text on the left and a three-column hiring board with Applied, Interview and Offer columns on the right
The Hiring tab and its mini hiring board.

1.5 Modules

index.html: modules
    <!-- ---------- MODULES ---------- -->
    <section class="section soft" id="modules">
      <div class="wrap">
        <div class="section-head">
          <p class="eyebrow">Modules</p>
          <h2>Start with what you need, add the rest later</h2>
        </div>
        <div class="modules">
          <article class="module">
            <span class="m-icon" style="--c: #6366f1"><svg viewBox="0 0 24 24"><circle cx="9" cy="8" r="3.5"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6M16 4.5a3.5 3.5 0 0 1 0 7M18 14c2.2.6 3.5 2.6 3.5 6"/></svg></span>
            <h3>Core HR</h3><p>One profile per person with contracts, documents and history.</p>
          </article>
          <article class="module">
            <span class="m-icon" style="--c: #14b8a6"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg></span>
            <h3>Attendance</h3><p>Clock in from web or phone, with shifts and overtime rules.</p>
          </article>
          <article class="module">
            <span class="m-icon" style="--c: #f59e0b"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1"/></svg></span>
            <h3>Performance</h3><p>Goals, one-to-ones and review cycles that take minutes.</p>
          </article>
          <article class="module">
            <span class="m-icon" style="--c: #ec4899"><svg viewBox="0 0 24 24"><path d="M6 3h12v18l-3-2-3 2-3-2-3 2zM9 8h6M9 12h6"/></svg></span>
            <h3>Expenses</h3><p>Snap a receipt, submit, and get paid with the next payroll.</p>
          </article>
          <article class="module">
            <span class="m-icon" style="--c: #0ea5e9"><svg viewBox="0 0 24 24"><path d="M5 20V11M11 20V4M17 20v-7M2 20h20"/></svg></span>
            <h3>Analytics</h3><p>Headcount, turnover and cost reports, ready to share.</p>
          </article>
          <article class="module">
            <span class="m-icon" style="--c: #22c55e"><svg viewBox="0 0 24 24"><rect x="7" y="2.5" width="10" height="19" rx="2"/><path d="M11 18h2"/></svg></span>
            <h3>Self-service app</h3><p>Payslips, leave and profile updates from any phone.</p>
          </article>
        </div>
      </div>
    </section>

Six cards, each with a line icon. Each card sets one colour with --c, and the CSS uses it for both the icon and its pale background square.

1.6 Pricing

index.html: pricing
    <!-- ---------- PRICING ---------- -->
    <section class="section" id="pricing">
      <div class="wrap">
        <div class="section-head">
          <p class="eyebrow">Pricing</p>
          <h2>Simple pricing per employee</h2>
          <p>Every plan includes unlimited admins and free setup help.</p>
        </div>

        <div class="price-controls">
          <div class="billing">
            <span id="lblMonthly" class="active">Monthly</span>
            <button type="button" class="switch" role="switch" aria-checked="false" aria-labelledby="lblYearly"></button>
            <span id="lblYearly">Yearly <em>Save 20%</em></span>
          </div>
          <label class="seats" for="seats">
            <span>Team size: <output id="seatsOut" for="seats">50</output> employees</span>
            <input type="range" id="seats" min="5" max="500" step="5" value="50">
          </label>
        </div>

        <div class="plans">
          <article class="plan">
            <h3>Starter</h3>
            <p class="plan-desc">Core HR and time off for small teams.</p>
            <p class="plan-price"><strong data-price="4">$4</strong><span>/ employee / month</span></p>
            <p class="plan-total" data-total="4"></p>
            <ul class="checks"><li>Employee profiles</li><li>Time off and calendar</li><li>Self-service app</li></ul>
            <a class="btn btn-light" href="#demo">Start free trial</a>
          </article>
          <article class="plan featured">
            <span class="ribbon">Most popular</span>
            <h3>Growth</h3>
            <p class="plan-desc">Everything to hire, onboard and pay.</p>
            <p class="plan-price"><strong data-price="8">$8</strong><span>/ employee / month</span></p>
            <p class="plan-total" data-total="8"></p>
            <ul class="checks"><li>Everything in Starter</li><li>Hiring and onboarding</li><li>Payroll and expenses</li><li>Performance reviews</li></ul>
            <a class="btn" href="#demo">Start free trial</a>
          </article>
          <article class="plan">
            <h3>Enterprise</h3>
            <p class="plan-desc">For 500+ people and complex rules.</p>
            <p class="plan-price"><strong>Custom</strong></p>
            <p class="plan-total">Volume pricing and a dedicated manager</p>
            <ul class="checks"><li>Everything in Growth</li><li>Single sign-on (SSO)</li><li>Custom approval flows</li></ul>
            <a class="btn btn-light" href="#demo">Talk to sales</a>
          </article>
        </div>
      </div>
    </section>

The billing switch is a <button> with role="switch" and aria-checked, so it's announced as an on/off switch. aria-labelledby names it after the "Yearly" label, so a screen reader says "Yearly, switch, off".

Each plan's monthly price per employee is stored in data-price. JavaScript reads it, applies the discount and the team size, and writes the results. The Enterprise plan has no data-price, so the script simply skips it.

1.7 FAQ, demo form and footer

index.html: FAQ
    <!-- ---------- FAQ ---------- -->
    <section class="section soft" id="faq">
      <div class="wrap faq-wrap">
        <div class="section-head">
          <p class="eyebrow">FAQ</p>
          <h2>Questions, answered</h2>
        </div>
        <div class="faq">
          <h3><button type="button" aria-expanded="true" aria-controls="faq1">How long does setup take?</button></h3>
          <div class="faq-a open" id="faq1"><div><p>Most teams are live in a week. Upload a spreadsheet of your people and we map the rest. A specialist helps you for free.</p></div></div>
          <h3><button type="button" aria-expanded="false" aria-controls="faq2">Can I move from my current HR tool?</button></h3>
          <div class="faq-a" id="faq2"><div><p>Yes. We import people, balances and documents from the common HR tools and from CSV files.</p></div></div>
          <h3><button type="button" aria-expanded="false" aria-controls="faq3">Is our employee data secure?</button></h3>
          <div class="faq-a" id="faq3"><div><p>Data is encrypted in transit and at rest, access is role-based, and every change is logged.</p></div></div>
          <h3><button type="button" aria-expanded="false" aria-controls="faq4">Do you charge for leavers?</button></h3>
          <div class="faq-a" id="faq4"><div><p>No. You only pay for active employees. Leavers' records stay archived at no cost.</p></div></div>
        </div>
      </div>
    </section>

Each question is a <button> inside a heading, so screen reader users can jump between questions by heading and open them like buttons. aria-expanded says whether the answer is open. Each answer has an extra inner <div>, which the height animation in Part 2 needs.

index.html: demo form
    <!-- ---------- DEMO FORM ---------- -->
    <section class="section" id="demo">
      <div class="wrap demo">
        <div class="demo-copy">
          <h2>See Teamwise in 20 minutes</h2>
          <p>A product specialist will walk you through the modules that matter to your team and answer
             every question.</p>
          <ul class="checks light"><li>No credit card needed</li><li>Free data import</li><li>Cancel any time</li></ul>
        </div>
        <form class="demo-form" id="demoForm" novalidate>
          <div class="field">
            <label for="name">Full name</label>
            <input id="name" name="name" autocomplete="name" required aria-describedby="nameError">
            <small class="error" id="nameError"></small>
          </div>
          <div class="field">
            <label for="email">Work email</label>
            <input id="email" name="email" type="email" autocomplete="email" required aria-describedby="emailError">
            <small class="error" id="emailError"></small>
          </div>
          <div class="field">
            <label for="size">Company size</label>
            <select id="size" name="size" required aria-describedby="sizeError">
              <option value="">Choose one</option>
              <option>1-20</option><option>21-100</option><option>101-500</option><option>500+</option>
            </select>
            <small class="error" id="sizeError"></small>
          </div>
          <button class="btn btn-block" type="submit">Book my demo</button>
          <p class="form-done" id="formDone" role="status"></p>
        </form>
      </div>
    </section>
  </main>

Every field has a real <label>, an autocomplete value so browsers can fill it in, and aria-describedby pointing at its error line. That way a screen reader reads the error out when the field is focused. The form uses novalidate so we can show our own messages instead of the browser's pop-ups.

index.html: footer
  <!-- ---------- FOOTER ---------- -->
  <footer class="site-footer">
    <div class="wrap footer-row">
      <a class="logo" href="#top"><span class="logo-mark" aria-hidden="true">t</span>Teamwise<b>HR</b></a>
      <nav aria-label="Footer"><a href="#features">Features</a><a href="#pricing">Pricing</a><a href="#faq">FAQ</a><a href="#demo">Contact</a></nav>
      <span>&copy; 2026 Teamwise HR. A demo page.</span>
    </div>
  </footer>

  <script src="script.js"></script>
</body>
</html>

Part 2: The CSS

2.1 Theme and shared styles

style.css: base
/* ---------- BASE ---------- */
:root {
  --brand: #4f46e5;
  --brand-dark: #4338ca;
  --brand-soft: #eef2ff;
  --teal: #14b8a6;
  --ink: #111827;
  --text: #4b5563;
  --muted: #6b7280;
  --line: #e5e7eb;
  --soft: #f8fafc;
  --shadow: 0 16px 40px -12px rgba(17, 24, 39, .15);
}

*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; scroll-padding-top: 76px; }
body {
  margin: 0;
  font-family: "Segoe UI", system-ui, -apple-system, Roboto, Arial, sans-serif;
  color: var(--text);
  background: #fff;
  line-height: 1.6;
}
h1, h2, h3 { color: var(--ink); line-height: 1.15; margin: 0 0 .5em; }
a { color: inherit; }
svg { display: block; }
:focus-visible { outline: 3px solid #a5b4fc; outline-offset: 2px; }

.wrap { width: min(1160px, 100% - 40px); margin-inline: auto; }
.section { padding: 96px 0; }
.soft { background: var(--soft); }
.section-head { max-width: 820px; margin: 0 auto 48px; text-align: center; }
.section-head h2 { font-size: clamp(1.8rem, 3.2vw, 2.5rem); letter-spacing: -.02em; }
.eyebrow {
  margin: 0 0 12px;
  font-size: .8rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase;
  color: var(--brand);
}

.btn {
  display: inline-flex; align-items: center; justify-content: center;
  padding: 12px 22px;
  border: 0; border-radius: 10px;
  background: var(--brand); color: #fff;
  font: inherit; font-weight: 600; text-decoration: none; white-space: nowrap;
  cursor: pointer;
  box-shadow: 0 6px 16px -6px rgba(79, 70, 229, .6);
  transition: background .2s, transform .2s, box-shadow .2s;
}
.btn:hover { background: var(--brand-dark); transform: translateY(-1px); }
.btn:disabled { opacity: .8; cursor: default; transform: none; }
.btn-light { background: #fff; color: var(--ink); box-shadow: inset 0 0 0 1.5px var(--line); }
.btn-light:hover { background: var(--soft); }
.btn-small { padding: 8px 16px; }
.btn-block { width: 100%; }

.checks { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; }
.checks li { position: relative; padding-left: 32px; }
.checks li::before {
  content: "\2713"; position: absolute; left: 0; top: 2px;
  display: grid; place-items: center; width: 22px; height: 22px; border-radius: 50%;
  background: var(--brand-soft); color: var(--brand); font-size: 12px; font-weight: 800;
}
.checks.light li::before { background: rgba(255, 255, 255, .18); color: #fff; }
/* ---------- END BASE ---------- */

The theme is indigo (--brand) with teal (--teal) as a second colour, on white and very light grey. Light SaaS designs rely on three things you can see here: lots of white space (.section has 96px of padding), soft borders (--line) instead of heavy shadows, and one strong colour for actions.

The .checks list is used all over the page. Its round tick is a ::before with content: "\2713" on a pale indigo circle.

2.2 A frosted sticky header

style.css: header
/* ---------- HEADER ---------- */
.site-header {
  position: sticky; top: 0; z-index: 50;
  background: rgba(255, 255, 255, .8);
  backdrop-filter: blur(12px);
  border-bottom: 1px solid transparent;
  transition: border-color .3s, box-shadow .3s;
}
.site-header.scrolled { border-color: var(--line); box-shadow: 0 4px 20px rgba(17, 24, 39, .06); }
.nav { display: flex; align-items: center; justify-content: space-between; height: 68px; }

.logo {
  display: inline-flex; align-items: center; gap: 10px;
  color: var(--ink); font-weight: 800; font-size: 1.2rem; text-decoration: none;
}
.logo b { margin-left: -6px; color: var(--brand); }
.logo-mark {
  display: grid; place-items: center; width: 32px; height: 32px; border-radius: 10px;
  background: linear-gradient(135deg, var(--brand), var(--teal));
  color: #fff; font-size: 1.1rem;
}

.menu { display: flex; align-items: center; gap: 28px; }
.menu a { color: var(--text); text-decoration: none; font-weight: 500; }
.menu a:hover { color: var(--brand); }
.menu .btn { color: #fff; }
.menu-btn { display: none; }
/* ---------- END HEADER ---------- */

The header is 80% white, and backdrop-filter: blur(12px) blurs whatever scrolls behind it. That's the frosted glass effect you see on many modern apps. The bottom border starts transparent and only appears with the .scrolled class.

2.3 The hero and the highlighter

style.css: hero
/* ---------- HERO ---------- */
.hero {
  padding: 72px 0 104px;
  background:
    radial-gradient(640px 420px at 5% 0%, #e0e7ff, transparent 70%),
    radial-gradient(640px 420px at 100% 35%, #ccfbf1, transparent 70%),
    #fff;
  overflow: hidden;
}
.hero-grid { display: grid; grid-template-columns: 1fr 1.15fr; gap: 64px; align-items: center; }
.pill {
  display: inline-flex; align-items: center; gap: 10px;
  margin: 0 0 20px; padding: 4px 14px 4px 4px;
  background: #fff; border: 1px solid var(--line); border-radius: 999px;
  font-size: .88rem; font-weight: 500; color: var(--ink);
}
.pill span {
  padding: 2px 10px; border-radius: 999px;
  background: var(--brand); color: #fff; font-size: .75rem; font-weight: 700;
}
.hero h1 { font-size: clamp(2.3rem, 4.8vw, 3.7rem); letter-spacing: -.03em; }
.marker {                     /* a highlighter stroke that draws itself */
  background: linear-gradient(#c7d2fe, #c7d2fe) no-repeat 0 88% / 0% 32%;
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
  animation: mark .8s .3s ease forwards;
}
.lead { max-width: 32em; margin: 0 0 28px; font-size: 1.15rem; }
.hero-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 20px; }
.rating { margin: 0; font-size: .9rem; color: var(--muted); }
.stars { margin-right: 6px; color: #f59e0b; letter-spacing: 1px; }
/* ---------- END HERO ---------- */

Two soft radial-gradient blobs, indigo top left and teal on the right, give the hero colour without any images.

The highlighter is a single-colour linear-gradient used as a background image. It's placed at 0 88% (the bottom part of the text) with a size of 0% 32%, so it starts with no width. The mark animation grows the width to 100%, and the stroke draws itself across the words. box-decoration-break: clone makes it work even when the phrase wraps onto two lines.

2.4 The dashboard mockup

style.css: app mockup
/* ---------- APP MOCKUP ---------- */
.app {
  position: relative;
  background: #fff; border: 1px solid var(--line); border-radius: 16px;
  box-shadow: 0 40px 80px -24px rgba(79, 70, 229, .35), 0 0 0 8px rgba(255, 255, 255, .6);
  font-size: 13px;
  transform: perspective(1400px) rotateY(-7deg) rotateX(2deg);
}
.app-bar { display: flex; align-items: center; gap: 6px; padding: 10px 14px; border-bottom: 1px solid var(--line); }
.app-bar i { width: 10px; height: 10px; border-radius: 50%; }
.app-bar i:nth-child(1) { background: #f87171; }
.app-bar i:nth-child(2) { background: #fbbf24; }
.app-bar i:nth-child(3) { background: #34d399; }
.app-bar span { margin-left: 12px; padding: 2px 12px; border-radius: 6px; background: var(--soft); color: var(--muted); font-size: 11px; }

.app-body { display: grid; grid-template-columns: 116px 1fr; }
.app-side {
  display: flex; flex-direction: column; gap: 4px; padding: 14px 10px;
  background: var(--soft); border-right: 1px solid var(--line); border-radius: 0 0 0 16px;
}
.app-side b { padding: 7px 10px; border-radius: 8px; font-weight: 500; color: var(--muted); }
.app-side b.on { background: var(--brand-soft); color: var(--brand); font-weight: 600; }
.app-main { min-width: 0; padding: 16px; }
.hello { margin: 0 0 12px; font-size: 15px; font-weight: 700; color: var(--ink); }

.kpis { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.kpi { padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; }
.kpi span, .kpi em { display: block; font-size: 11px; font-style: normal; color: var(--muted); }
.kpi strong { display: block; font-size: 22px; line-height: 1.3; color: var(--ink); }
.kpi em.up { color: #059669; }

.app-row { display: grid; grid-template-columns: 1.3fr 1fr; gap: 10px; margin-top: 10px; }
.panel { padding: 12px; border: 1px solid var(--line); border-radius: 10px; }
.panel-title { margin: 0 0 10px; font-size: 12px; font-weight: 600; color: var(--ink); }

.bars { display: flex; align-items: flex-end; gap: 8px; height: 110px; padding-bottom: 18px; }
.bars span {
  position: relative; flex: 1; height: var(--h);
  border-radius: 6px 6px 2px 2px;
  background: linear-gradient(180deg, #818cf8, var(--brand));
  transform-origin: bottom;
  animation: grow .9s cubic-bezier(.2, .8, .2, 1) both;
}
.bars span:last-child { background: linear-gradient(180deg, #5eead4, var(--teal)); }
.bars span:nth-child(2) { animation-delay: .08s; }
.bars span:nth-child(3) { animation-delay: .16s; }
.bars span:nth-child(4) { animation-delay: .24s; }
.bars span:nth-child(5) { animation-delay: .32s; }
.bars span:nth-child(6) { animation-delay: .4s; }
.bars span::after {           /* month label under each bar */
  content: attr(data-m);
  position: absolute; top: 100%; left: 0; right: 0; padding-top: 3px;
  text-align: center; font-size: 10px; color: var(--muted);
}

.out { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
.out li { display: flex; align-items: center; gap: 8px; font-weight: 500; color: var(--ink); }
.out i {
  display: grid; place-items: center; flex: none; width: 24px; height: 24px; border-radius: 50%;
  background: var(--c); color: #fff; font-size: 9px; font-style: normal; font-weight: 700;
}
.out small { margin-left: auto; font-size: 11px; color: var(--muted); }

.toast {
  position: absolute; left: -28px; bottom: -26px;
  display: flex; align-items: center; gap: 10px; padding: 10px 16px 10px 10px;
  background: #fff; border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow);
  animation: float 4s ease-in-out infinite;
}
.toast > span {
  display: grid; place-items: center; width: 30px; height: 30px; border-radius: 8px;
  background: #d1fae5; color: #059669; font-weight: 800;
}
.toast strong { display: block; font-size: 13px; color: var(--ink); }
.toast small { font-size: 11px; color: var(--muted); }
/* ---------- END APP MOCKUP ---------- */

The tilt comes from transform: perspective(1400px) rotateY(-7deg) rotateX(2deg). perspective sets how far away the viewer is: a smaller number gives a stronger 3D effect. Two shadows sell the depth: a large soft indigo one below, and an 8px white ring that looks like a glass frame.

The bar chart is a flex row with align-items: flex-end, so every bar stands on the bottom. Each bar reads its height from var(--h). The grow animation starts each bar at scaleY(0), and transform-origin: bottom makes it grow upwards. The animation-delays ripple the bars in from left to right. The month labels come from content: attr(data-m), which shows an attribute's value as text.

The "Payroll approved" toast is positioned outside the window, with a negative left and bottom, and bobs gently with the float animation. It breaks the rectangle and makes the mockup feel alive.

2.5 Logos and tabs

style.css: logos
/* ---------- LOGOS ---------- */
.logos { padding: 8px 0 24px; text-align: center; }
.logos p { margin: 0 0 18px; font-size: .9rem; color: var(--muted); }
.logo-row { display: flex; flex-wrap: wrap; justify-content: center; gap: 16px 48px; }
.logo-row span { font-size: 1.25rem; font-weight: 800; letter-spacing: -.02em; color: #9ca3af; }
.logo-row span:nth-child(2) { font-family: Georgia, serif; font-style: italic; font-weight: 700; }
.logo-row span:nth-child(3) { font-size: 1rem; letter-spacing: .2em; }
.logo-row span:nth-child(5) { font-weight: 600; letter-spacing: .02em; }
/* ---------- END LOGOS ---------- */
style.css: tabs
/* ---------- TABS ---------- */
.tabs {
  display: flex; gap: 6px; width: max-content; max-width: 100%;
  margin: 0 auto 40px; padding: 6px;
  background: var(--soft); border: 1px solid var(--line); border-radius: 14px;
  overflow-x: auto;
}
.tabs button {
  padding: 10px 20px; border: 0; border-radius: 10px;
  background: transparent; color: var(--muted);
  font: inherit; font-weight: 600; white-space: nowrap; cursor: pointer;
  transition: background .2s, color .2s, box-shadow .2s;
}
.tabs button:hover { color: var(--ink); }
.tabs button[aria-selected="true"] { background: #fff; color: var(--brand); box-shadow: 0 2px 8px rgba(17, 24, 39, .08); }

.tab-panel {
  display: grid; grid-template-columns: 1fr 1fr; gap: 48px; align-items: center;
  padding: 44px;
  background: linear-gradient(135deg, #fff, var(--brand-soft));
  border: 1px solid var(--line); border-radius: 20px;
  animation: fadeUp .4s ease;
}
.tab-panel[hidden] { display: none; }   /* display: grid would otherwise beat the hidden attribute */
.feature-text h3 { font-size: 1.6rem; letter-spacing: -.01em; }
.feature-text p { margin: 0 0 20px; }
/* ---------- END TABS ---------- */

The tab bar is a grey "segmented control". width: max-content shrinks it to fit its buttons, margin: 0 auto centres it, and overflow-x: auto lets it scroll sideways on very narrow phones. The selected tab is styled with [aria-selected="true"]. The CSS reads the same attribute that screen readers use, so there's no extra "active" class to keep in sync.

One line here is easy to miss: .tab-panel[hidden] { display: none; }. The hidden attribute works by setting display: none, but our own display: grid rule is more specific and would win, so every panel would show. Whenever you give an element a display value, re-state [hidden] like this.

2.6 The feature mockups

style.css: feature mocks
/* ---------- FEATURE MOCKS ---------- */
.mock {
  padding: 24px;
  background: #fff; border: 1px solid var(--line); border-radius: 16px; box-shadow: var(--shadow);
  font-size: .92rem;
}
.mock-title { display: flex; justify-content: space-between; align-items: center; margin: 0 0 12px; font-weight: 700; color: var(--ink); }
.mock-title span { font-size: .85rem; font-weight: 500; color: var(--muted); }
.mock-title .ok { padding: 2px 10px; border-radius: 999px; background: #d1fae5; color: #047857; font-weight: 600; }

.board { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.board div { display: flex; flex-direction: column; gap: 8px; padding: 10px; background: var(--soft); border-radius: 10px; }
.board p { display: flex; justify-content: space-between; margin: 0 0 2px; font-size: .85rem; font-weight: 600; color: var(--ink); }
.board b { font-weight: 500; color: var(--muted); }
.board span { padding: 8px 10px; background: #fff; border: 1px solid var(--line); border-radius: 8px; font-size: .85rem; color: var(--ink); }
.board span.hot { border-left: 3px solid var(--brand); }

.meter { height: 8px; margin-bottom: 16px; background: var(--line); border-radius: 99px; overflow: hidden; }
.meter span { display: block; width: var(--w); height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--brand), var(--teal)); }
.checklist ul { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; }
.checklist li { display: flex; align-items: center; gap: 10px; color: var(--ink); }
.checklist li::before {
  content: ""; display: grid; place-items: center; flex: none; width: 20px; height: 20px;
  border: 2px solid var(--line); border-radius: 6px; font-size: 11px; font-weight: 800; color: #fff;
}
.checklist li.done { color: var(--muted); }
.checklist li.done::before { content: "\2713"; background: var(--brand); border-color: var(--brand); }

@property --p { syntax: "<number>"; inherits: false; initial-value: 0; }
.rings { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.ring {
  position: relative;
  display: grid; place-items: center; align-content: center; aspect-ratio: 1;
  border-radius: 50%; text-align: center;
  background: conic-gradient(var(--c) calc(var(--p) * 1%), var(--line) 0);
  animation: fill 1.2s ease both;
}
.ring::before { content: ""; position: absolute; inset: 14px; border-radius: 50%; background: #fff; }
.ring strong, .ring span { position: relative; }
.ring strong { font-size: 2.2rem; line-height: 1; color: var(--ink); }
.ring span { max-width: 7em; font-size: .8rem; color: var(--muted); }

.payslip dl { display: grid; grid-template-columns: 1fr auto; gap: 12px 16px; margin: 0; }
.payslip dt { color: var(--muted); }
.payslip dd { margin: 0; text-align: right; font-weight: 600; color: var(--ink); font-variant-numeric: tabular-nums; }
.payslip dt:last-of-type, .payslip dd.total { padding-top: 12px; border-top: 1px dashed var(--line); }
.payslip dd.total { font-size: 1.3rem; color: var(--brand); }
/* ---------- END FEATURE MOCKS ---------- */

Each mock is a few lines of CSS:

The Onboarding tab: a welcome checklist for Ben with a 60% progress bar and three of five tasks ticked
Onboarding: a progress bar and a checklist, all CSS.

The leave rings are the most interesting part. Each ring is a circle with a conic-gradient: coloured up to --p percent of the way round, grey for the rest. A white ::before circle on top turns the pie into a ring.

To animate the ring filling up, we need the browser to understand that --p is a number. Normally custom properties are just text, and text can't be animated between two values. @property --p { syntax: "<number>"; ... } registers it as a number. Now the fill animation can smoothly count it up from 0.

The Time off tab with two progress rings: 16 holiday days left in indigo and 8 sick days left in teal
Leave rings made with conic-gradient and animated with @property.
The Payroll tab with a November payroll summary: gross pay, taxes, benefits and net pay of $292,880 marked Ready
The payroll summary is a definition list laid out with CSS Grid.

2.7 Modules

style.css: modules
/* ---------- MODULES ---------- */
.modules { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
.module {
  padding: 26px;
  background: #fff; border: 1px solid var(--line); border-radius: 16px;
  transition: transform .25s, box-shadow .25s;
}
.module:hover { transform: translateY(-4px); box-shadow: var(--shadow); }
.m-icon {
  display: grid; place-items: center; width: 46px; height: 46px; margin-bottom: 16px;
  border-radius: 12px;
  color: var(--c);
  background: color-mix(in srgb, var(--c) 12%, white);
}
.m-icon svg {
  width: 24px; height: 24px;
  fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
}
.module h3 { font-size: 1.1rem; }
.module p { margin: 0; }
/* ---------- END MODULES ---------- */

The icon background is color-mix(in srgb, var(--c) 12%, white): 12% of the card's colour mixed into white. One variable gives both the strong icon colour and its matching pale background, so there's no second colour to pick for each card.

Six module cards in a three-column grid: Core HR, Attendance, Performance, Expenses, Analytics and Self-service app, each with a coloured line icon
Each card sets one colour, and color-mix makes the pale background.

2.8 Pricing

style.css: pricing
/* ---------- PRICING ---------- */
.price-controls {
  display: flex; flex-wrap: wrap; justify-content: center; align-items: center; gap: 24px 56px;
  margin-bottom: 48px;
}
.billing { display: flex; align-items: center; gap: 12px; font-weight: 600; color: var(--muted); }
.billing > span { cursor: pointer; transition: color .2s; }
.billing .active { color: var(--ink); }
.billing em {
  margin-left: 4px; padding: 2px 8px; border-radius: 999px;
  background: #d1fae5; color: #047857; font-size: .75rem; font-style: normal;
}
.switch {
  position: relative; width: 52px; height: 30px; border: 0; border-radius: 999px;
  background: #cbd5e1; cursor: pointer; transition: background .25s;
}
.switch::after {
  content: ""; position: absolute; top: 4px; left: 4px; width: 22px; height: 22px; border-radius: 50%;
  background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, .25);
  transition: transform .25s;
}
.switch[aria-checked="true"] { background: var(--brand); }
.switch[aria-checked="true"]::after { transform: translateX(22px); }

.seats { display: grid; gap: 6px; font-weight: 600; color: var(--ink); }
.seats output { color: var(--brand); font-variant-numeric: tabular-nums; }
.seats input { width: 260px; accent-color: var(--brand); }

.plans { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; align-items: stretch; }
.plan {
  position: relative; display: flex; flex-direction: column; padding: 32px;
  background: #fff; border: 1px solid var(--line); border-radius: 20px;
}
.plan.featured {
  border: 2px solid var(--brand);
  box-shadow: 0 24px 48px -16px rgba(79, 70, 229, .35);
  transform: translateY(-8px);
}
.ribbon {
  position: absolute; top: -13px; left: 50%; transform: translateX(-50%);
  padding: 4px 14px; border-radius: 999px;
  background: var(--brand); color: #fff; font-size: .78rem; font-weight: 700; white-space: nowrap;
}
.plan h3 { margin-bottom: 4px; font-size: 1.2rem; }
.plan-desc { margin: 0 0 20px; font-size: .95rem; color: var(--muted); }
.plan-price { display: flex; align-items: baseline; gap: 6px; margin: 0; }
.plan-price strong { font-size: 2.6rem; line-height: 1; letter-spacing: -.02em; color: var(--ink); font-variant-numeric: tabular-nums; }
.plan-price span { font-size: .9rem; color: var(--muted); }
.plan-total { min-height: 3em; margin: 10px 0 20px; font-size: .9rem; font-weight: 600; color: var(--brand); }
.plan .checks { flex: 1; align-content: start; margin-bottom: 28px; }
.plan .btn { width: 100%; }
/* ---------- END PRICING ---------- */

The toggle switch is a grey pill with a white knob drawn by ::after. When aria-checked="true", the pill turns indigo and the knob slides 22px to the right with translateX.

The featured plan stands out in three ways: a 2px indigo border, a coloured shadow and translateY(-8px) to raise it above the others. The "Most popular" ribbon is centred on the top border with left: 50% and translateX(-50%), the standard centring trick for absolutely positioned elements.

Each plan is a flex column and the feature list has flex: 1, so the buttons line up at the bottom of all three cards.

2.9 The FAQ height animation

style.css: FAQ
/* ---------- FAQ ---------- */
.faq-wrap { max-width: 780px; }
.faq { background: #fff; border: 1px solid var(--line); border-radius: 16px; overflow: hidden; }
.faq h3 { margin: 0; font-size: 1rem; }
.faq h3 + .faq-a + h3 { border-top: 1px solid var(--line); }
.faq button {
  display: flex; justify-content: space-between; align-items: center; gap: 16px;
  width: 100%; padding: 20px 24px; border: 0; background: none;
  font: inherit; font-weight: 600; color: var(--ink); text-align: left; cursor: pointer;
}
.faq button::after {
  content: "+"; flex: none;
  display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%;
  background: var(--brand-soft); color: var(--brand); font-size: 1.2rem; line-height: 1;
  transition: transform .3s;
}
.faq button[aria-expanded="true"]::after { transform: rotate(45deg); }
/* Animate height from 0 to "auto": a one-row grid that goes from 0fr to 1fr */
.faq-a { display: grid; grid-template-rows: 0fr; transition: grid-template-rows .3s ease; }
.faq-a.open { grid-template-rows: 1fr; }
.faq-a > div { overflow: hidden; }
.faq-a p { margin: 0; padding: 0 24px 20px; }
/* ---------- END FAQ ---------- */

CSS can't transition height from 0 to auto. This is the modern workaround. Make the answer a one-row grid and transition grid-template-rows from 0fr (no space) to 1fr (all the space the content needs). The inner <div> has overflow: hidden, so the text is clipped while the row is small. The result is a smooth open and close at the content's natural height, with no JavaScript measuring.

The plus sign rotates 45° into an × when the question is open.

The FAQ list with two open answers and two closed questions; open questions show an x icon and closed ones a plus
Open answers expand to their natural height. The + rotates into an x.

2.10 Demo form, footer, animations and phones

style.css: demo form
/* ---------- DEMO FORM ---------- */
.demo {
  display: grid; grid-template-columns: 1fr 1fr; gap: 56px; align-items: center;
  padding: 56px; border-radius: 24px; color: #e0e7ff;
  background: linear-gradient(135deg, var(--brand-dark), var(--brand) 50%, #0d9488);
}
.demo h2 { color: #fff; font-size: clamp(1.8rem, 3vw, 2.4rem); }
.demo-copy p { margin: 0 0 24px; font-size: 1.05rem; }
.demo-form {
  display: grid; gap: 6px; padding: 28px;
  background: #fff; border-radius: 16px; color: var(--text);
  box-shadow: 0 30px 60px -20px rgba(0, 0, 0, .4);
}
.field label { display: block; margin-bottom: 6px; font-size: .92rem; font-weight: 600; color: var(--ink); }
.field input, .field select {
  width: 100%; padding: 11px 14px;
  border: 1.5px solid var(--line); border-radius: 10px;
  background: #fff; font: inherit; color: var(--ink);
}
.field input:focus, .field select:focus { outline: 0; border-color: var(--brand); box-shadow: 0 0 0 4px rgba(79, 70, 229, .15); }
.field [aria-invalid="true"] { border-color: #dc2626; }
.error { display: block; min-height: 1.4em; margin-top: 2px; font-size: .82rem; color: #dc2626; }
.form-done { margin: 8px 0 0; text-align: center; font-weight: 600; color: #047857; }
.form-done:empty { display: none; }
/* ---------- END DEMO FORM ---------- */

Invalid fields are styled with [aria-invalid="true"], again reusing the attribute that screen readers announce. .form-done:empty hides the success line until it has text.

style.css: footer
/* ---------- FOOTER ---------- */
.site-footer { padding: 32px 0; border-top: 1px solid var(--line); font-size: .9rem; color: var(--muted); }
.footer-row { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 16px; }
.footer-row nav { display: flex; flex-wrap: wrap; gap: 24px; }
.footer-row nav a { text-decoration: none; }
.footer-row nav a:hover { color: var(--brand); }
/* ---------- END FOOTER ---------- */
style.css: animations
/* ---------- ANIMATIONS ---------- */
@keyframes mark { to { background-size: 100% 32%; } }
@keyframes grow { from { transform: scaleY(0); } }
@keyframes float { 50% { transform: translateY(-8px); } }
@keyframes fadeUp { from { opacity: 0; transform: translateY(10px); } }
@keyframes fill { from { --p: 0; } }
/* ---------- END ANIMATIONS ---------- */

Notice how short the keyframes are. @keyframes grow { from { transform: scaleY(0); } } only gives a starting point. With no to, the browser animates to the element's normal style.

style.css: small screens
/* ---------- SMALL SCREENS ---------- */
@media (max-width: 980px) {
  .hero-grid, .tab-panel, .demo { grid-template-columns: 1fr; }
  .app { transform: none; }
  .tab-panel { padding: 28px; gap: 32px; }
  .modules { grid-template-columns: 1fr 1fr; }
  .plans { grid-template-columns: 1fr; max-width: 460px; margin-inline: auto; }
  .plan.featured { transform: none; }
  .demo { padding: 40px; gap: 32px; }
}

@media (max-width: 860px) {
  .menu-btn {
    display: grid; place-items: center; width: 44px; height: 44px;
    border: 0; border-radius: 10px; background: var(--soft); cursor: pointer;
  }
  .menu-btn span, .menu-btn span::before, .menu-btn span::after {
    display: block; width: 20px; height: 2px; border-radius: 2px; background: var(--ink);
    transition: transform .3s, background .3s;
  }
  .menu-btn span { position: relative; }
  .menu-btn span::before, .menu-btn span::after { content: ""; position: absolute; left: 0; }
  .menu-btn span::before { top: -6px; }
  .menu-btn span::after { top: 6px; }
  .menu-btn[aria-expanded="true"] span { background: transparent; }
  .menu-btn[aria-expanded="true"] span::before { transform: translateY(6px) rotate(45deg); }
  .menu-btn[aria-expanded="true"] span::after { transform: translateY(-6px) rotate(-45deg); }

  .menu {
    position: absolute; top: 68px; left: 0; right: 0;
    display: none; flex-direction: column; align-items: stretch; gap: 0;
    padding: 8px 20px 20px;
    background: #fff; border-bottom: 1px solid var(--line); box-shadow: var(--shadow);
  }
  .menu.open { display: flex; }
  .menu a { padding: 12px 0; }
  .menu .btn { margin-top: 8px; padding: 12px; }
}

@media (max-width: 600px) {
  .section { padding: 64px 0; }
  .hero { padding: 48px 0 72px; }
  .app-body, .app-row { grid-template-columns: 1fr; }
  .app-side { display: none; }
  .toast { left: 8px; bottom: -28px; }
  .modules { grid-template-columns: 1fr; }
  .board { grid-template-columns: 1fr; }
  .tab-panel { padding: 20px; }
  .tabs { justify-content: flex-start; }
  .seats input { width: 100%; }
  .price-controls { flex-direction: column; }
  .demo { padding: 24px; border-radius: 16px; }
  .demo-form { padding: 20px; }
}
/* ---------- END SMALL SCREENS ---------- */

At 980px the two-column sections stack, the dashboard loses its tilt, and the plans become one column. At 860px the menu turns into a hamburger. At 600px the mockup hides its sidebar and everything goes single-column.

The HR landing page on a phone: headline, buttons and the dashboard mockup without its sidebar
On a phone.
The phone layout with the menu open below the header
Menu open.
style.css: reduced motion
/* ---------- REDUCED MOTION ---------- */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    animation-delay: 0s !important;
    transition-duration: .01ms !important;
  }
}
/* ---------- END REDUCED MOTION ---------- */

Part 3: The JavaScript

3.1 Settings, menu and header

script.js: settings
// ---------- SETTINGS ----------
const YEARLY_DISCOUNT = 0.2;   // 20% off when billed yearly
const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
const seatPrice = n => '$' + (n % 1 ? n.toFixed(2) : n);   // $8 or $6.40
// ---------- END SETTINGS ----------

seatPrice() shows whole prices without decimals ($8) and others with two ($6.40). n % 1 is the part after the decimal point, so it's zero for whole numbers.

script.js: menu and header
// ---------- MENU AND HEADER ----------
const menuBtn = document.querySelector('.menu-btn');
const menu = document.getElementById('menu');
const header = document.querySelector('.site-header');

function setMenu(open) {
  menuBtn.setAttribute('aria-expanded', open);
  menuBtn.setAttribute('aria-label', open ? 'Close menu' : 'Open menu');
  menu.classList.toggle('open', open);
}
menuBtn.addEventListener('click', () => setMenu(menuBtn.getAttribute('aria-expanded') !== 'true'));
menu.addEventListener('click', e => { if (e.target.closest('a')) setMenu(false); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') setMenu(false); });

function onScroll() { header.classList.toggle('scrolled', window.scrollY > 10); }
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
// ---------- END MENU AND HEADER ----------

This is the same menu and header code as the freight tutorial. One setMenu() function keeps aria-expanded, the label and the CSS class in sync. The menu closes on a link click or Esc.

3.2 Accessible tabs

script.js: tabs
// ---------- TABS ----------
const tabs = [...document.querySelectorAll('[role="tab"]')];

function selectTab(tab, moveFocus) {
  tabs.forEach(t => {
    const selected = t === tab;
    t.setAttribute('aria-selected', selected);
    t.tabIndex = selected ? 0 : -1;           // only the active tab is in the Tab order
    document.getElementById(t.getAttribute('aria-controls')).hidden = !selected;
  });
  if (moveFocus) tab.focus();
}

tabs.forEach((tab, i) => {
  tab.addEventListener('click', () => selectTab(tab));
  tab.addEventListener('keydown', e => {
    const keys = {
      ArrowRight: tabs[(i + 1) % tabs.length],
      ArrowLeft: tabs[(i - 1 + tabs.length) % tabs.length],
      Home: tabs[0],
      End: tabs[tabs.length - 1]
    };
    if (keys[e.key]) {
      e.preventDefault();
      selectTab(keys[e.key], true);
    }
  });
});
// ---------- END TABS ----------

selectTab() loops over every tab and, for each one, sets aria-selected, its tabIndex and its panel's hidden. The CSS does all the visual work from those attributes.

The keyboard handler follows the standard tabs pattern that screen reader users expect: → and ← move to the next and previous tab, wrapping around at the ends, and Home and End jump to the first and last. Instead of a chain of ifs, a small keys object maps each key name to the tab it should select. The % tabs.length does the wrapping: one past the last tab is index 0 again.

Only the selected tab has tabIndex = 0. This is called a roving tabindex: Tab enters the tab list once, the arrows move inside it, and the next Tab goes straight into the panel.

3.3 The pricing calculator

script.js: pricing
// ---------- PRICING ----------
const billingSwitch = document.querySelector('.switch');
const seatsInput = document.getElementById('seats');
const seatsOut = document.getElementById('seatsOut');

function updatePrices() {
  const yearly = billingSwitch.getAttribute('aria-checked') === 'true';
  const seats = Number(seatsInput.value);
  seatsOut.textContent = seats;
  document.getElementById('lblMonthly').classList.toggle('active', !yearly);
  document.getElementById('lblYearly').classList.toggle('active', yearly);

  document.querySelectorAll('[data-price]').forEach(el => {
    const perSeat = Number(el.dataset.price) * (yearly ? 1 - YEARLY_DISCOUNT : 1);
    el.textContent = seatPrice(perSeat);
    el.closest('.plan').querySelector('[data-total]').textContent = yearly
      ? `${money.format(perSeat * seats * 12)} billed yearly for ${seats} people`
      : `${money.format(perSeat * seats)} a month for ${seats} people`;
  });
}

function setYearly(on) {
  billingSwitch.setAttribute('aria-checked', on);
  updatePrices();
}
billingSwitch.addEventListener('click', () => setYearly(billingSwitch.getAttribute('aria-checked') !== 'true'));
document.getElementById('lblMonthly').addEventListener('click', () => setYearly(false));
document.getElementById('lblYearly').addEventListener('click', () => setYearly(true));
seatsInput.addEventListener('input', updatePrices);
updatePrices();
// ---------- END PRICING ----------

updatePrices() reads two inputs, whether yearly billing is on and the team size, then updates every plan that has a data-price:

The switch flips aria-checked. Clicking the words "Monthly" or "Yearly" sets it directly, which is a small touch people appreciate. The slider recalculates on every input event, so the totals change while you drag.

The pricing section with yearly billing switched on and a team size of 120: Starter shows $3.20 and Growth shows $6.40 per employee with yearly totals underneath
Yearly billing for 120 people: the switch, the slider and every total update together.

3.4 The FAQ accordion

script.js: FAQ accordion
// ---------- FAQ ACCORDION ----------
document.querySelectorAll('.faq button').forEach(btn => {
  const answer = document.getElementById(btn.getAttribute('aria-controls'));
  answer.inert = btn.getAttribute('aria-expanded') !== 'true';   // closed answers can't be reached

  btn.addEventListener('click', () => {
    const open = btn.getAttribute('aria-expanded') !== 'true';
    btn.setAttribute('aria-expanded', open);
    answer.classList.toggle('open', open);
    answer.inert = !open;
  });
});
// ---------- END FAQ ACCORDION ----------

Each click flips aria-expanded on the button and the open class on the answer. The CSS from 2.9 handles the animation. Closed answers also get the inert property, so they can't be focused or read while they're collapsed. Without it, a screen reader could still read the hidden text, because a 0fr row hides content visually but not from assistive technology.

3.5 Demo form validation

script.js: demo form
// ---------- DEMO FORM ----------
const demoForm = document.getElementById('demoForm');
const formDone = document.getElementById('formDone');

// Each rule returns true, or the error message to show
const rules = {
  name: v => v.trim().length >= 2 || 'Please enter your name.',
  email: v => /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v.trim()) || 'Please enter a valid work email.',
  size: v => v !== '' || 'Please choose your company size.'
};

function check(field) {
  const result = rules[field.name](field.value);
  const ok = result === true;
  field.setAttribute('aria-invalid', !ok);
  document.getElementById(field.name + 'Error').textContent = ok ? '' : result;
  return ok;
}

demoForm.addEventListener('submit', e => {
  e.preventDefault();
  const fields = [...demoForm.elements].filter(el => rules[el.name]);
  const invalid = fields.filter(field => !check(field));
  if (invalid.length) return invalid[0].focus();

  const button = demoForm.querySelector('button');
  button.disabled = true;
  button.textContent = 'Booking...';
  setTimeout(() => {                         // pretend to send it to a server
    const first = document.getElementById('name').value.trim().split(' ')[0];
    button.textContent = 'Demo booked ✓';
    formDone.textContent = `Thanks, ${first}! We'll email you within one business day.`;
  }, 900);
});

// Once a field has shown an error, re-check it as the visitor fixes it
demoForm.addEventListener('input', e => {
  if (rules[e.target.name] && e.target.getAttribute('aria-invalid') === 'true') check(e.target);
});
// ---------- END DEMO FORM ----------

The validation is driven by a small rules object. Each rule is a function that gets the field's value and returns true or an error message. The trick is ||: condition || 'message' gives true when the condition passes and the message when it fails. To validate a new field, add one line to rules, a field with the same name, and an error element with the id name + 'Error'.

On submit, every field with a rule is checked, not just the first bad one, so the visitor sees all the problems at once. Focus moves to the first invalid field. After the first failed attempt, each field re-checks itself as you type, so the red disappears the moment the value is right.

The demo form after submitting with mistakes: all three fields have red borders with the messages Please enter your name, Please enter a valid work email and Please choose your company size
All the errors show at once, and each clears as soon as it is fixed.

When everything is valid, the button is disabled (so it can't be clicked twice) and shows "Booking...". After a pretend server delay it says "Demo booked" and thanks the visitor by first name. The message is written with textContent, so whatever someone types as their name is shown as plain text and can never run as code.

Test it yourself

  1. Click the Hiring tab, then press → three times and Home. Watch the panels change.
  2. Open the Time off tab and watch the rings fill.
  3. Turn on yearly billing and drag the team size to 200. Check that Growth shows $6.40 and $15,360 billed yearly.
  4. Open and close a few FAQ questions.
  5. Submit the empty demo form, then fix one field at a time.
  6. Make the window narrow and check the menu, the mockup and the plans.

Using it on a real site

Share your version

Made your own SaaS landing page? Share a screenshot as a link with sshot.online, or share the code with the free code sharing tool. No account needed.

Conclusion

In three parts you've built a landing page that would fit any modern software product, and learned the patterns behind it:

Next, try the freight logistics landing page. It uses the same three-part structure with an animated SVG route, a shipment tracker and a quote calculator.

Frequently asked questions

Is the dashboard in the hero an image?

No. The dashboard mockup is built from HTML elements styled with CSS, so it stays sharp at any size, loads fast and can be animated.

Are the feature tabs accessible?

Yes. They use the tablist, tab and tabpanel roles with aria-selected, a roving tabindex, and arrow, Home and End key support, which is the standard pattern screen reader users expect.

How does the FAQ animate to its natural height?

Each answer is a one-row CSS grid whose grid-template-rows transitions from 0fr to 1fr, with an inner element set to overflow: hidden. No JavaScript measuring is needed.

How does the yearly pricing work?

Each plan stores its monthly price per employee in a data-price attribute. When yearly billing is on, the script takes 20% off and multiplies by the team size and by 12 to show the yearly total.

Can I use this page for my own product?

Yes. Change the colour variables in :root, replace the text and mockup numbers with your own, and connect the demo form to your backend with fetch().

Related articles

← All guides