Sticky pricing tables are perfect for showing products and services with long lists of comparable features. In this tutorial we’re going to create a pricing table with a sticky header; along the way we’ll learn how to pin and unpin elements after a certain amount of scrolling.
There are a number of JavaScript plugins out there offering this kind of functionality like ScrollMagic.js. In actual fact, I’ve already published a tutorial showing how you can implement a similar effect with this plugin.
Today, however, let’s challenge ourselves and write our own code.
The Pricing Table We’re Building
For this hands-on exercise we’ll design a pricing page which will include the different subscription plans in columns of a pricing table. As we scroll down, the header will become sticky so as to stay in view, then will be released at a later point. The pricing table with sticky header combo is a popular UI pattern which you may have seen on websites like monday.com.
Bear in mind the sticky functionality of our demo triggers on viewports wider than 779px. You may need to check out the full screen demo to fully appreciate it!
When Should You Use a Pricing Table?
Pricing tables come into their own when a collection of products, services, or variants, offer the consumer too much choice. If the consumer finds it difficult to decide which option to choose, they’re more likely to opt for nothing instead. Information overload = failure to convert.
Wasting time making choices negates any advantage to actually having the choice in the first place!
“Research [..] shows that an excess of choices often leads us to be less, not more, satisfied once we actually decide.” – Alina Tugend, NYT
The role of a pricing table is to help the user visually interpret the options available to them, perhaps sometimes even encouraging them to focus on the most appealing choice.
Read more in Val Geisler’s How to design a pricing page that converts on the InVision blog.
Pricing Table Plugins for WordPress
Before diving into the tutorial, you may want to take a look at the array of pricing table plugins available for WordPress on Envato Market.

With that said, let’s now help our users by creating a super clear table for them to choose from!
1. Begin With the Page Markup
We’ll start with three sections:
<section>...</section> <section>...</section> <section>...</section>
The first and third sections won’t play an important role; they’ll contain dummy content and styles to ensure that the page is long enough for triggering the desired scrolling effect.
Inside the second section, we’ll place the table. We’ll wrap it within a container, which will make it behave responsively on small screens:
<div class="container"> <div class="table-wrapper"> <table> <thead> <tr> <th>...</th> <th>...</th> <th>...</th> <th>...</th> </tr> </thead> <tbody> <tr> <td>...</td> <td>...</td> <td>...</td> <td>...</td> </tr> <!-- more rows here --> </tbody> </table> </div> </div>
The table itself will contain four columns and represent the pricing plans for a product or service. In our case, we’ll have three subscription plans: starter, essential, and professional.
Table Headers
The table’s sticky header will clearly identify those plans. Each plan will also include a call-to-action button.

Here’s the markup for the table headers:
<tr> <th> <div> Select your plan <div class="svg-wrapper"> <svg viewBox="0 0 24 24"><path d="M12 0c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm1 17v-4h-8v-2h8v-4l6 5-6 5z"/></svg> </div> </div> </th> <th> <div class="heading">...</div> <div class="info"> <div class="amount">...</div> <div class="billing-msg">...</div> <button type="button">...</button> </div> </th> <th> <div class="heading">...</div> <div class="info"> <div class="popular">...</div> <div class="amount">...</div> <div class="billing-msg">...</div> <button type="button">...</button> </div> </th> <th> <div class="heading">...</div> <div class="info"> <div class="amount">...</div> <div class="billing-msg">...</div> <button type="button">...</button> </div> </th> </tr>
Table Rows
Each table row will describe the availability of a feature across all plans. If a plan includes this feature, an SVG check mark icon will appear. Otherwise, a gray cross icon will appear.

Here’s the markup for a feature that is supported by all plans:
<tr> <td>...</td> <td> <svg class="starter" viewBox="0 0 24 24"><path d="M12 0c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm-1.959 17l-4.5-4.319 1.395-1.435 3.08 2.937 7.021-7.183 1.422 1.409-8.418 8.591z"/></svg> </td> <td> <svg class="essential" viewBox="0 0 24 24"><path d="M12 0c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm-1.959 17l-4.5-4.319 1.395-1.435 3.08 2.937 7.021-7.183 1.422 1.409-8.418 8.591z"/></svg> </td> <td> <svg class="professional" viewBox="0 0 24 24"><path d="M12 0c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm-1.959 17l-4.5-4.319 1.395-1.435 3.08 2.937 7.021-7.183 1.422 1.409-8.418 8.591z"/></svg> </td> </tr>
And here’s the markup for a feature which is available only on the “Professional” plan:
<tr> <td>...</td> <td> <svg class="not-included" viewBox="0 0 24 24"><path d="M12 0c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm4.151 17.943l-4.143-4.102-4.117 4.159-1.833-1.833 4.104-4.157-4.162-4.119 1.833-1.833 4.155 4.102 4.106-4.16 1.849 1.849-4.1 4.141 4.157 4.104-1.849 1.849z"/></svg> </td> <td> <svg class="not-included" viewBox="0 0 24 24"><path d="M12 0c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm4.151 17.943l-4.143-4.102-4.117 4.159-1.833-1.833 4.104-4.157-4.162-4.119 1.833-1.833 4.155 4.102 4.106-4.16 1.849 1.849-4.1 4.141 4.157 4.104-1.849 1.849z"/></svg> </td> <td> <svg class="professional" viewBox="0 0 24 24"><path d="M12 0c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm-1.959 17l-4.5-4.319 1.395-1.435 3.08 2.937 7.021-7.183 1.422 1.409-8.418 8.591z"/></svg> </td> </tr>
2. Define Some Basic Styles
With the markup ready, we’ll forge on with the CSS. Our first step is to set up some CSS variables and common reset styles.
For each plan, we’ll define its corresponding variable. That will give us the ability to easily change its look and feel, if needed.
Here are the reset styles:
:root { --white: white; --gray: #999; --lightgray: whitesmoke; --popular: #ffdd40; --starter: #f73859; --essential: #00AEEF; --professional: #FF7F45; } * { padding: 0; margin: 0; box-sizing: border-box; } button { background: none; border: none; cursor: pointer; } table { border-collapse: collapse; } body { font: 18px/1.5 'Noto Sans', sans-serif; background: var(--lightgray); }
Note: for simplicity I won’t walk through all the CSS rules in the tutorial. There are almost 290 lines of CSS here. I’ll only discuss the most important ones. Make sure to check them all by clicking at the CSS tab of the demo project.
3. Style the Pricing Table
The pricing table will have a maximum width and be horizontally centered within the page:
.container { max-width: 850px; padding: 0 10px; margin: 0 auto; } table { width: 100%; }
The rows will behave as flex containers:
table tr { display: flex; }
All cells will have the same width:
table th, table td { width: 25%; min-width: 150px; }
To separate the plans and provide a clear view of which cell belongs to each plan, we’ll add a light gray border to the target elements:
--lightgray: whitesmoke; table th .info, table td:not(:first-child) { border-left: 1px solid var(--lightgray); }
Making the Pricing Table Responsive
As we’ll see in the upcoming section, the sticky effect will trigger on viewports greater than 779px (you can change this value to suit). On smaller viewports, we’ll show a typical table which can be scrolled horizontally.

Bootstrap also uses similar functionality for responsive tables. There are other (better?) approaches for handling responsive tables out there, but this method certainly does the job here.
4. Pin/Unpin the Table Header
With the HTML and CSS in place, we’ll now concentrate on the scrolling effect using some JavaScript. This will enable us to make our header sticky.
Variables
For our first step we’ll grab a copy of the desired elements. We’ll store in variables the two classes which we’re going to use later:
const body = document.body; const firstSection = document.querySelector("section:nth-child(1)"); const lastSection = document.querySelector("section:nth-child(3)"); const table = document.querySelector("table"); const thead = document.querySelector("table thead"); const mq = window.matchMedia("(min-width: 780px)"); const stickyClass = "sticky-table"; const sticky2Class = "sticky2-table";
The next step is to perform some calculations. Specifically, we want to calculate the following things:
- The table width.
- The top position of the table relative to the viewport.
- The height of the
thead
. This is the element will be pinned/unpinned.
Here’s the required JavaScript to do that:
let tableWidth = table.offsetWidth; let tableOffsetTop = table.getBoundingClientRect().top; let theadHeight = thead.offsetHeight;
Notice that we store the aforementioned values in let
variables. We did this intentionally. When the page gets resized, we should recalculate the stuff described above and thus, reassign those new values to these variables.
On Scroll
Each time we scroll up or down, the scrollHandler
function will be executed:
Inside that function we’ll perform the following actions, which will run only if the window width is at least 780px:
- Get the number of pixels a user has scrolled from the top of the viewport.
- Get the top position of the last section relative to the viewport.
- Check if a user has scrolled more than or equal to the table’s initial top position.
- If that happens, we set the
thead
’s width equal to the table’s initial width. - Then, we check whether the resulting value of the step 2 is greater than the
thead
’s height. - If that happens, we pin the
thead
element by adding thesticky-table
class to thebody
and removing thesticky-table2
class from the same element. At that point, thethead
becomes a fixed positioned element. We then position it at the top of the viewport. We also give thebody
a top padding equal to thethead
’s height. - If that doesn’t happen, we stop pinning the
thead
by adding thesticky-table2
class to thebody
and removing thesticky-table
class from the same element. At that point, thethead
is released and becomes an absolutely positioned element. We then position it at the bottom of the table.
- In case a user has scrolled less than the table’s initial top position (the
thead
hasn’t been pinned yet), we remove thesticky-table
andstick-table2
classes from thebody
. Also, we set its top padding to 0. At that point, thethead
hasn’t any position (static element), so we reset its defaulttop
position. Finally, we set its width to 100% (we can skip it).
The code that implements all this behavior is as follows:
window.addEventListener("scroll", scrollHandler); function scrollHandler() { if (mq.matches) { // 1 const scrollY = window.pageYOffset; // 2 const lastSectionOffsetTop = lastSection.getBoundingClientRect().top; // 3 if (scrollY >= tableOffsetTop) { // 4 thead.style.width = `${tableWidth}px`; // 5 if (lastSectionOffsetTop > theadHeight) { // 6 body.classList.remove(sticky2Class); body.classList.add(stickyClass); thead.style.top = 0; body.style.paddingTop = `${theadHeight}px`; } else { // 7 body.classList.remove(stickyClass); body.classList.add(sticky2Class); thead.style.top = `calc(100% - ${theadHeight}px)`; } } else { // 8 body.classList.remove(stickyClass, sticky2Class); body.style.paddingTop = 0; thead.style.width = "100%"; thead.style.top = "auto"; } } }
And the related styles for each class:
table thead { transition: box-shadow 0.2s; } .sticky-table table thead { position: fixed; left: 50%; transform: translateX(-50%); } .sticky-table table thead { box-shadow: 0px 3px 6px rgba(0, 0, 0, 0.12); } .sticky2-table table thead { position: absolute; left: 0; }
5. Resizing the Page
We’ve almost done the job, folks! So far, the sticky header effect will work fine when the page loads. But what happens when the page gets resized? Well, it’d be really nice if we could make this demo work on resize as well, right? Let’s do it!
So each time we resize the page, the resizeHandler
function will be executed.
Inside that function, we’ll first check the window width, then either update the values of the aforementioned let
variables, or reset the thead
and body
inline styles. Notice that we retrieve the top position of the table by grabbing the first section’s height. You might be wondering why we didn’t use the table’s offsetTop
property? During my tests I’ve noticed that it doesn’t always give accurate results on resize. Also, the getBoundingClientRect()
method won’t work because it gives incorrect (even negative) values as well.
The required JavaScript code:
window.addEventListener("resize", resizeHandler); function resizeHandler() { if (mq.matches) { tableWidth = firstSection.offsetHeight; tableOffsetTop = table.offsetTop; theadHeight = thead.offsetHeight; } else { body.classList.remove(stickyClass, sticky2Class); body.style.paddingTop = 0; thead.style.width = "100%"; thead.style.top = "auto"; } }
Conclusion
That’s all, folks! In this tutorial we’ve managed to build a useful scrolling effect, without using any external library. Not only did we learn how to create sticky elements, but also how to unpin (release) them after a certain amount of scrolling.
All this combined has given us a really useful pricing table with a sticky header.
I hope that this exercise helped you learn something new and inspired you for using it in an upcoming project.
Before closing, I’d like to highlight two things:
- I used the
table
element to build this effect. This might not always be the ideal method, considering the fact that there are more flexible and powerful layout solutions today like CSS Grid. However, a table is often the best element for displaying data, so I went with it on this occasion. - It’s always a big challenge to present tabular data on mobile devices. On this occasion I went with a simple scrolling solution. Another approach might be to discard the table method entirely (keep it only on >779px), for a carousel solution with three slides where each slide will represent a pricing plan. Perhaps you have a better idea which we could discuss in the comments below.
As always, thanks a lot for reading!
More Sticky Tutorials
We have a few tutorials which explore different approaches to sticking elements whilst scrolling a web page:
- CSSSticky Positioning Without JavaScript (Thanks to CSS Position: Sticky)Thoriq Firdaus
- jQueryQuick Tip: Sticky Navigation, Without the Awkward JumpAdi Purdila
- HTMLHow to Hide/Reveal a Sticky Header on Scroll (With JavaScript)George Martsoukos
- PatternsHow to Create a Fixed Header Which Animates on Page ScrollGeorge Martsoukos
Learn More About Pricing Tables
Subscribe below and we’ll send you a weekly email summary of all new Web Design tutorials. Never miss out on learning about the next big thing.
Update me weeklyEnvato Tuts+ tutorials are translated into other languages by our community members—you can be involved too!
Translate this post