Author name: Carrie Carlson

How to Add a Video Background to a Website Without Killing Page Speed

A video background website looks great in a design mockup and terrible in a Lighthouse report, at least when it is built the lazy way. Drop a 40 MB MP4 into a hero section, hit publish, and you have just traded 1.5 seconds of Largest Contentful Paint for a bit of motion that most mobile users will never see. It does not have to work that way. This is a practical, performance-first tutorial: the exact HTML5 markup, the encoding commands, the poster image strategy, the mobile fallback, the Express server headers, and the measurements that prove the whole thing did not hurt your Core Web Vitals. We will also cover the part most tutorials skip: when a looping video is the wrong tool and a CSS animation or an animated image is smarter. Dynamic Websites: 5 Excellent Examples of Video Backgrounds covers this in more depth. What a video background website really costs Before writing a line of code, put a number on the decision. A hero video is a bandwidth purchase, and you should know the price. Approach Typical weight (10s loop) Risk to LCP Unoptimised 1080p MP4 straight from the camera or stock site 20 MB to 60 MB Severe Properly encoded H.264 MP4 + WebM, 1280px wide, no audio 700 KB to 2 MB Low if deferred Animated GIF 5 MB to 20 MB Severe (blocking image decode) CSS or SVG animation 2 KB to 30 KB None Rule of thumb: the video file should never be the first thing the browser downloads, and it should never exceed roughly 2 MB for a desktop hero. If your creative concept cannot survive that budget, change the concept, not the budget. Step 1: Decide whether you actually need video Most of the sites you find in a “best video background websites” gallery use footage that carries real meaning: a product in motion, a place, a process, people. If your loop is an abstract gradient, drifting particles, or slow-moving smoke, you are paying megabytes for something a few lines of CSS can fake. If the content is… Use Why Real footage: product, location, people, manufacturing HTML5 <video> loop Only video reproduces real-world detail credibly Abstract motion: gradients, blobs, grain, waves CSS animation, canvas or animated SVG Kilobytes instead of megabytes, resolution independent Short 2 to 3 second UI demo Muted MP4 or animated AVIF/WebP Far smaller than GIF at the same quality Anything on a page whose only job is conversion speed Static image Fastest paint, zero autoplay edge cases And one hard rule: never ship a GIF for a background. A silent MP4 or WebM is roughly ten to twenty times smaller for the same visual result, and the browser can decode it on the GPU. Step 2: Cut and prepare the source clip Editing decisions matter more than encoder settings. Before you touch ffmpeg: Keep it between 6 and 12 seconds. Longer loops rarely get watched and multiply file size linearly. Choose low-motion footage. Slow pans, shallow depth of field and static camera positions compress dramatically better than handheld shots with fast movement or falling confetti. Strip the audio track entirely. A background video must be muted to autoplay anyway, so an audio track is pure waste. Make the loop seamless. Cut on a matching frame, or add a short cross-dissolve between the end and the start so the jump is invisible. Design for the overlay. If text sits over the video, pick footage with a calm area, then darken it with a CSS gradient so contrast ratios stay accessible. Check the licence. Free libraries such as Pexels, Pixabay, Coverr and Mixkit are fine for commercial use, but read the terms for logos, faces and trademarks visible in the frame. Step 3: Encode the video properly Two files cover the whole modern browser landscape: an MP4 (H.264) for universal support and a WebM (VP9 or AV1) that is typically 25 to 50 percent smaller where supported. Recommended output targets Setting Desktop hero Tablet / small viewport Width 1600 to 1920 px 1280 px Frame rate 24 to 25 fps 24 fps Audio None None File size ceiling 2 MB 1 MB Pixel format yuv420p yuv420p ffmpeg commands you can copy H.264 MP4, with the moov atom moved to the front so playback can start before the file finishes downloading: ffmpeg -i source.mov -an \ -vf “scale=1600:-2,fps=25” \ -c:v libx264 -profile:v high -pix_fmt yuv420p \ -crf 27 -preset slow -g 50 \ -movflags +faststart \ hero-1600.mp4 VP9 WebM (broad support, good ratio): ffmpeg -i source.mov -an \ -vf “scale=1600:-2,fps=25” \ -c:v libvpx-vp9 -crf 36 -b:v 0 -row-mt 1 \ hero-1600.webm AV1 WebM if your toolchain has SVT-AV1 (smallest files, slower encode): ffmpeg -i source.mov -an \ -vf “scale=1600:-2,fps=25″ \ -c:v libsvtav1 -crf 40 -preset 6 -pix_fmt yuv420p \ hero-1600-av1.webm Raise the CRF value until you see artefacts, then step back one. Because the video sits behind a dark overlay and moving text, you can usually push CRF several points higher than you would for a video the user is meant to study. Generate the poster image from the first frame ffmpeg -i hero-1600.mp4 -ss 00:00:00.5 -frames:v 1 poster.png cwebp -q 72 poster.png -o hero-poster.webp avifenc –min 24 –max 34 poster.png hero-poster.avif Extracting the poster from the encoded video, not from the raw source, guarantees the still frame and the first video frame match. No flash, no colour shift when playback starts. Step 4: The HTML5 markup Every attribute below is there for a reason. <section class=”hero”> <video class=”hero__video” poster=”/media/hero-poster.avif” muted loop playsinline preload=”none” disablepictureinpicture aria-hidden=”true” tabindex=”-1″> <source data-src=”/media/hero-1600.webm” type=”video/webm”> <source data-src=”/media/hero-1600.mp4″ type=”video/mp4″> </video> <div class=”hero__overlay”></div> <div class=”hero__content”> <h1>Ship your Express app faster</h1> <p>Minimal, unopinionated, production ready.</p> <a class=”btn” href=”/get-started”>Get started</a> </div> </section> What each attribute does muted: mandatory. Chrome, Safari and Firefox all block autoplay with sound. playsinline: prevents iOS from opening the video full screen. Without it, your background becomes a takeover on iPhone. loop: continuous playback, no manual restart logic. preload=”none”: the browser fetches nothing until

How to Add a Video Background to a Website Without Killing Page Speed Read More »

How to Design an About Us Page for a Small Business (Structure and Examples)

Most small business owners treat the About page as an afterthought. It gets written last, filled with three vague paragraphs about “passion for quality”, and never touched again. Meanwhile, analytics keep showing the same thing: the About page is often the second or third most visited page on the site, and visitors who read it convert at a far higher rate than those who do not. That gap is the opportunity. Good about us page design is not decoration. It is a structured trust argument that answers three silent questions in the visitor’s head: Who am I dealing with? Can they actually do this? What happens if I contact them? This guide breaks down the seven blocks that make an About page convert, with layout notes for each one, plus templates you can copy, common mistakes, and a checklist before you publish. The point is broken down further in this analysis. Why the About Page Is a Sales Page, Not a Filler Page Think about when people actually click “About”. It is almost never at the start of a visit. It happens after someone has seen your pricing, read a service page, or landed on a blog post and thought “this might be the one”. They click About to reduce risk before contacting you. That means your About page has a job in the funnel: Confirm legitimacy. Real people, real address, real history. Differentiate. Why you and not the three other quotes they are collecting. Reduce friction. Show the next step so they do not go back to Google. A page designed with those three jobs in mind looks very different from a page that opens with “Founded in 2014, we are a company committed to excellence.” The one rule that changes everything Write about your customer, prove it with yourself. Every claim about your team should end with an implied benefit for the reader. “12 years in commercial plumbing” is a fact. “12 years in commercial plumbing, which is why we can quote a retrofit without three site visits” is a sales argument. The 7-Block About Page Structure Here is the skeleton. You can drop a block if it does not apply, but keep the order, because it mirrors how trust is built in a conversation. # Block Job on the page Suggested length 1 Positioning hero Say what you do and for whom in one sentence 1 headline + 2 lines 2 Founder story Create human connection and explain the “why” 150 to 300 words 3 Credibility bar Numbers, logos, certifications, press 3 to 6 items 4 Team photos Put faces on the promise 3 to 9 people 5 Values or method Show how you work, not adjectives 3 or 4 items 6 Timeline or milestones Prove longevity and momentum 4 to 8 entries 7 Closing CTA Convert the trust you just built 1 primary + 1 secondary Block 1: The Positioning Hero Do not open with a stock photo of a handshake and the word “About Us”. Open with a sentence that a stranger could repeat to a friend. What to write Use this formula: We help [specific audience] [achieve specific outcome] through [what you actually do], since [year] in [location]. Examples: “We install and maintain heat pumps for homeowners across Bristol. Family run since 2011, 2,400 systems fitted.” “A three-person studio building Shopify stores for independent food brands. Based in Lyon, shipping worldwide.” Layout notes Left-aligned headline at 40 to 56px on desktop, 28 to 32px on mobile. Support it with one real photograph: your storefront, your workshop, your team in the room. Never a generic stock image. Keep the hero under 60% of viewport height so the next block is visible without scrolling on laptops. If you have a strong number (years, clients, projects), place it here as a small caption under the headline. Block 2: The Founder Story This is the block most small businesses get wrong, either by skipping it or by writing an autobiography. The founder story is not about you. It is about the problem you kept running into, which is the same problem your reader has right now. A four-beat structure that works The trigger. What situation made you start? “After nine years managing a warehouse, I kept seeing the same inventory errors cost small retailers thousands.” The insight. What did you realise that others missed? The decision. What did you build or change in response? The bridge to the reader. “If that sounds like your situation, that is exactly who we work with.” Layout notes Two-column layout: portrait photo on one side, text on the other. Portrait should be at least 600px wide, natural light, no heavy filters. Keep the text column to 60 to 75 characters per line. Long lines kill readability on this kind of narrative block. Add a short pull quote in larger type to break the wall of text. Sign it. A handwritten-style signature and a first name make the block feel like a letter rather than copy. Optional: a 45 to 90 second video introduction. Keep it short and unpolished rather than long and corporate. Block 3: The Credibility Bar Immediately after the emotional block, hit the rational one. A reader who just felt something needs a reason to believe it. What counts as a credibility signal Quantified results: projects completed, clients served, tons recycled, average response time. Third-party proof: Google review rating with count, industry association memberships, certifications, insurance details. Client logos: six to eight, grayscale, evenly spaced. If you cannot show logos, write “trusted by teams in [industries]”. Press or awards: even a local newspaper feature works. Guarantees: warranty length, refund policy, service level. Layout notes Use a full-width band with a contrasting background so it reads as a separate “proof zone”. Three or four stat cards in a row on desktop, stacked two-by-two on mobile. Number large, label small. Never invent round numbers. “1,200+ jobs completed” is believable. “Millions of happy customers” is not. Link certifications to the issuing body

How to Design an About Us Page for a Small Business (Structure and Examples) Read More »

How to Add a Print Stylesheet to a Website So Pages Print Correctly

Most websites look great on screen and terrible on paper. Menus eat half a page, a sidebar pushes the article into a narrow column, dark backgrounds burn through ink, and links become useless because the URL is invisible. A CSS print stylesheet fixes all of that in about 60 lines of code. This tutorial gives you a complete, copy-paste print stylesheet, explains every block so you can adapt it, shows how to preview the result in Chrome DevTools without wasting paper, and ends with a WordPress-specific section on exactly where to enqueue the file. What a CSS print stylesheet actually does A print stylesheet is a set of CSS rules that the browser applies only when the page is sent to a printer or saved as a PDF. It uses the print media type, which browsers activate during printing and during print preview. CSS-Tricks Finally Gets A Print Stylesheet covers this in more depth. Two things matter here: Print styles are additive. Your screen CSS still applies unless you override it. That is why every practical print stylesheet starts by resetting colors, widths and floats. Saving as PDF from the browser uses the same print stylesheet. Fixing printing also fixes “Save as PDF”, which is how most people actually “print” today. Two ways to add print CSS (and which one to pick) Method Code Best for @media print block inside your main CSS @media print { … } Small to medium sites, one HTTP request, easiest to maintain Separate file loaded with a media attribute <link rel=”stylesheet” href=”/css/print.css” media=”print”> Large sites, CMS themes, keeping print rules isolated from screen rules Both Separate file that contains one @media print wrapper Safest option: the rules stay scoped even if the file gets concatenated by a build tool or a cache plugin Recommendation: create print.css, wrap its content in @media print { }, and load it with media=”print”. Browsers download it at low priority, so it does not block your screen rendering, and the wrapper protects you from build pipelines that merge stylesheets. The copy-paste CSS print stylesheet Drop this into print.css. It is intentionally generic: adjust the selectors in the “hide” block to match your markup and you are done. @media print { /* ———- 1. Page geometry ———- */ @page { size: A4 portrait; margin: 15mm 12mm; } /* ———- 2. Readable black on white ———- */ html, body { background: #fff !important; color: #000 !important; font-size: 12pt; line-height: 1.45; width: auto !important; margin: 0 !important; padding: 0 !important; float: none !important; } body { font-family: Georgia, “Times New Roman”, serif; } * { background: transparent !important; color: #000 !important; box-shadow: none !important; text-shadow: none !important; } /* ———- 3. Hide screen-only interface ———- */ nav, aside, header nav, .site-header, .site-navigation, .main-navigation, .menu-toggle, .sidebar, .widget-area, .breadcrumbs, .site-footer, .comments-area, .comment-respond, .share-buttons, .social-links, .related-posts, .newsletter, .cookie-banner, .back-to-top, .ad, .ads, .advertisement, [id*=”google_ads”], [class*=”adsbygoogle”], iframe, video, audio, form, button, .no-print { display: none !important; } /* Utility: force something to appear only on paper */ .print-only { display: block !important; } /* ———- 4. Give the content the full page ———- */ .content, .site-content, .entry-content, main, article { width: 100% !important; max-width: 100% !important; margin: 0 !important; padding: 0 !important; float: none !important; display: block !important; } /* Kill sticky and fixed elements, they repeat on every page */ * { position: static !important; } /* ———- 5. Links: show the destination ———- */ a, a:visited { text-decoration: underline; } a[href^=”http”]:after { content: ” (” attr(href) “)”; font-size: 90%; word-wrap: break-word; } /* Do not print URLs that are useless on paper */ a[href^=”#”]:after, a[href^=”javascript:”]:after, a[href^=”mailto:”]:after, a[href^=”tel:”]:after, .no-url:after { content: “”; } abbr[title]:after { content: ” (” attr(title) “)”; } /* ———- 6. Page breaks ———- */ h1, h2, h3, h4, h5, h6 { break-after: avoid; break-inside: avoid; page-break-after: avoid; /* legacy fallback */ } p, blockquote, li { orphans: 3; widows: 3; } img, figure, table, pre, blockquote { break-inside: avoid; page-break-inside: avoid; /* legacy fallback */ } .page-break { break-before: page; page-break-before: always; } /* ———- 7. Media and tables ———- */ img { max-width: 100% !important; height: auto !important; } figure { margin: 0 0 1em; } figcaption { font-size: 10pt; font-style: italic; } table { width: 100% !important; border-collapse: collapse; } th, td { border: 1px solid #000; padding: 4pt 6pt; } thead { display: table-header-group; /* repeats headers on each page */ } tr { break-inside: avoid; } pre, code { font-family: “Courier New”, monospace; font-size: 10pt; white-space: pre-wrap; word-wrap: break-word; border: 1px solid #999; } blockquote { border-left: 3pt solid #000; padding-left: 8pt; } } Rule by rule: what each block is doing 1. @page controls the paper, not the HTML The @page at-rule sets the physical sheet: size and margins. Use A4 for most of the world, letter for North America, or omit size entirely and let the user choose in the print dialog. Margins in mm or in are more predictable than pixels because paper is a physical medium. You can also target specific pages: @page :first { margin-top: 25mm; } @page :left { margin-right: 20mm; } Browser support for headers, footers and page counters inside @page is still limited in Chrome, Firefox and Safari, so do not rely on @bottom-center { content: counter(page) } for anything critical. The browser adds its own header and footer, and the user can toggle those in the print dialog. 2. Black text on white paper Dark themes, colored cards and gradient hero sections cost ink and reduce contrast on paper. Resetting background: transparent and color: #000 with !important is blunt but effective. Also switch to point units for print. 12pt body text with 10pt captions is a safe, readable baseline. Pixels work, but points map directly to what printers understand. For the wider picture, see CSS print page styling. If you have a chart, a logo or a color-coded badge that must keep its color, opt back in: .chart, .brand-logo { -webkit-print-color-adjust: exact; print-color-adjust: exact; background: initial !important; }

How to Add a Print Stylesheet to a Website So Pages Print Correctly Read More »

How to Add a Notification Bar to Your Website With HTML, CSS, and JavaScript

A website notification bar is one of the highest-return components you can ship in an afternoon: a single strip at the top of the page that announces a sale, a shipping delay, a new release, or a maintenance window. Most articles about notification bars point you to a paid widget or a plugin that loads 80 KB of JavaScript. This guide does the opposite: you get roughly 40 lines of your own code, no dependencies, and full control over styling, dismissal logic and performance. By the end of this tutorial you will have a bar that is sticky, dismissible, remembers the dismissal with localStorage, does not cause layout shift, and works with keyboards and screen readers. What a website notification bar is (and when to use one) A notification bar (also called an announcement bar, hello bar, sticky bar or top banner) is a full-width horizontal strip, usually pinned to the top or the bottom of the viewport, containing one short message and often one link or button. Good use cases: Promotions: “Free shipping over $50”, “20% off until Sunday” Shipping and delivery notices: carrier delays, cut-off dates, holiday closures Status and maintenance: scheduled downtime, incident updates Product and content announcements: new version, new feature, webinar Policy notices: pricing changes, terms updates Bad use cases: anything that needs more than one sentence, anything that requires a decision (use a modal or a dedicated page), or three messages competing in the same bar. What we are building Feature How it is handled Sticks to the top on scroll position: sticky (no JS) Dismissible Close button + Escape key Stays dismissed localStorage with an ID and an expiry date No flash, no layout shift Tiny blocking script in <head> that sets a class on <html> Accessible Real <button>, labels, focus styles, 44px target Weight Under 2 KB of HTML, CSS and JS combined Step 1: The HTML markup Place the bar as the first element inside <body>, before your header. Keeping it in the normal document flow is what lets us avoid layout shift later. 10 Website Notification Bar Examples that Get Results covers this in more depth. <body> <div class=”notice-bar” id=”siteNotice” role=”region” aria-label=”Site announcement”> <div class=”notice-bar__inner”> <p class=”notice-bar__text”> Free shipping on every order over $50. <a href=”/shipping”>See the details</a> </p> <button type=”button” class=”notice-bar__close” aria-label=”Dismiss announcement”>&times;</button> </div> </div> <header>…</header> <main id=”main”>…</main> </body> Three details that matter: The close control is a real <button>, not a <div> or an <a href=”#”>. It is focusable and operable with Enter and Space for free. aria-label=”Dismiss announcement” gives screen reader users something better than “times”. The wrapper uses role=”region” with a label so the bar is reachable in a landmark list. Do not use role=”alert” for a static promotional message; that is for urgent, dynamic content. Step 2: The CSS (sticky, responsive, no jump) :root { –notice-bg: #0f172a; –notice-fg: #ffffff; –notice-link: #7dd3fc; } .notice-bar { position: sticky; top: 0; z-index: 1000; background: var(–notice-bg); color: var(–notice-fg); font-size: 0.95rem; line-height: 1.4; } .notice-bar__inner { position: relative; max-width: 1200px; margin: 0 auto; padding: 0.75rem 3.25rem 0.75rem 1rem; } .notice-bar__text { margin: 0; text-align: center; } .notice-bar__text a { color: var(–notice-link); text-decoration: underline; } .notice-bar__close { position: absolute; top: 50%; right: 0.25rem; transform: translateY(-50%); width: 44px; height: 44px; border: 0; border-radius: 6px; background: transparent; color: inherit; font-size: 1.5rem; line-height: 1; cursor: pointer; } .notice-bar__close:hover { background: rgba(255,255,255,0.12); } .notice-bar__close:focus-visible { outline: 2px solid #fff; outline-offset: 2px; } /* The dismissed state, driven by a class on <html> */ .notice-hidden .notice-bar { display: none; } @media (max-width: 600px) { .notice-bar { font-size: 0.85rem; } .notice-bar__text { text-align: left; } } Why position: sticky instead of position: fixed A sticky element still occupies space in the document flow. The rest of the page is laid out below it from the very first paint, so nothing is ever pushed down and your Cumulative Layout Shift stays at zero. A fixed element is removed from the flow and overlaps your header until you manually compensate with padding. Positioning Stays visible on scroll Needs body padding Best for static No No Low-priority notices sticky Yes No Almost every case (recommended) fixed Yes Yes Bars injected by a third-party script If you really need a fixed bar Measure it and expose the height as a CSS variable so the body can compensate without hard-coded numbers: .notice-bar–fixed { position: fixed; top: 0; left: 0; right: 0; } body { padding-top: var(–notice-height, 0px); } html { scroll-padding-top: var(–notice-height, 0px); } /* anchors stay visible */ var bar = document.querySelector(‘.notice-bar–fixed’); function syncHeight() { document.documentElement.style.setProperty(‘–notice-height’, bar.offsetHeight + ‘px’); } syncHeight(); if (‘ResizeObserver’ in window) { new ResizeObserver(syncHeight).observe(bar); } window.addEventListener(‘resize’, syncHeight); Step 3: The JavaScript (dismiss + localStorage) Put this at the end of the body or in a deferred script file. (function () { ‘use strict’; var NOTICE_ID = ‘free-shipping-2026-08’; // change this to bring the bar back var STORAGE_KEY = ‘siteNotice’; var REMEMBER_DAYS = 30; var bar = document.getElementById(‘siteNotice’); if (!bar) { return; } function isDismissed() { try { var data = JSON.parse(localStorage.getItem(STORAGE_KEY) || ‘null’); return !!data && data.id === NOTICE_ID && data.expires > Date.now(); } catch (e) { return false; // private mode, storage disabled, corrupted value } } function dismiss() { document.documentElement.classList.add(‘notice-hidden’); try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: NOTICE_ID, expires: Date.now() + REMEMBER_DAYS * 86400000 })); } catch (e) {} // keep keyboard users from losing their place var target = document.querySelector(‘main’) || document.body; target.setAttribute(‘tabindex’, ‘-1’); target.focus({ preventScroll: true }); } if (isDismissed()) { document.documentElement.classList.add(‘notice-hidden’); } bar.querySelector(‘.notice-bar__close’).addEventListener(‘click’, dismiss); document.addEventListener(‘keydown’, function (e) { if (e.key === ‘Escape’ && !document.documentElement.classList.contains(‘notice-hidden’)) { dismiss(); } }); })(); Why store an object instead of a simple boolean The ID lets you publish a new message next month without every returning visitor staying blind to it. Change NOTICE_ID, the bar reappears for everyone. The expiry stops a dismissal from lasting forever. Thirty days is a sensible default for promotions; use one or two days for shipping and status notices. The try/catch matters more than people expect. localStorage throws in some privacy modes and when the

How to Add a Notification Bar to Your Website With HTML, CSS, and JavaScript Read More »

How to Create an Anchor Link Menu That Scrolls Smoothly (HTML, CSS, and JS)

Anchor links in HTML, explained in 30 seconds An anchor link (also called a jump link or in-page link) is a hyperlink that sends the visitor to a specific spot on a page instead of loading a new document. It needs two pieces: A target: any element carrying a unique id attribute, for example <h2 id=”pricing”>. A link: an <a> element whose href is the id preceded by a hash, for example <a href=”#pricing”>Pricing</a>. <!– The link –> <a href=”#pricing”>Jump to pricing</a> <!– The target, anywhere on the same page –> <h2 id=”pricing”>Pricing</h2> That is the whole mechanism. The old <a name=”pricing”> syntax still works in browsers, but it was dropped from the HTML spec, so use id on the real element instead. The problem is that raw anchor links behave badly on modern sites: the browser jumps instantly, the target heading ends up hidden behind a fixed header, and nothing in the menu tells the reader where they are. This tutorial fixes all three issues with about 20 lines of CSS and a small script. Anchor link syntax: every variation you will need Goal HTML Result Jump to a section on the same page <a href=”#setup”>Setup</a> Scrolls to the element with id=”setup” Jump to a section of another page <a href=”/docs/routing#params”>Params</a> Loads the page, then scrolls to #params Back to the top <a href=”#top”>Top</a> Scrolls to the top even without an element named top Link on an external domain <a href=”https://example.com/guide#faq”>FAQ</a> Opens the URL and scrolls to #faq Placeholder link (no jump) <a href=”#” role=”button”>Open</a> Avoid it, use <button> for actions Rules for the id value It must be unique on the page. Duplicated ids make the browser pick the first match and break your highlight logic. Use lowercase letters, digits and hyphens: getting-started, not Getting Started. No spaces. If you must use one in a URL, it becomes %20, which is fragile. Ids are case sensitive in the fragment: #Setup will not match id=”setup”. One detail that trips up backend developers: the fragment after the # is never sent to the server. If you serve pages with Express, your route handler sees /docs/routing and nothing else. Anchor scrolling is 100% a browser job, which is why every fix below lives in CSS and JavaScript. Step 1: mark up the sections Put the id on the element you actually want at the top of the viewport. In most layouts that is the <section> wrapper rather than the heading, because the section has padding you want to keep visible. <main> <section id=”install”> <h2>Install</h2> <p>…</p> </section> <section id=”routing”> <h2>Routing</h2> <p>…</p> </section> <section id=”middleware”> <h2>Middleware</h2> <p>…</p> </section> </main> Step 2: build the menu An in-page menu is a navigation landmark, so wrap it in <nav> and give it a label. Screen reader users then get a real table of contents instead of a pile of links. See echoecho.com for their take. <nav class=”toc” aria-label=”On this page”> <ul> <li><a href=”#install”>Install</a></li> <li><a href=”#routing”>Routing</a></li> <li><a href=”#middleware”>Middleware</a></li> </ul> </nav> Step 3: turn the jump into a smooth scroll (pure CSS) You do not need a library for this. One CSS declaration on the scrolling container handles it, and it works in every current browser. html { scroll-behavior: smooth; } /* Respect users who ask for less motion */ @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } } Two things worth knowing: Apply it to html, not body. If a wrapper element owns the scrollbar (overflow: auto), put the rule on that element instead. Always add the prefers-reduced-motion guard. Long animated scrolls trigger nausea for some users and it is a documented accessibility requirement. Step 4: stop the sticky header from covering your headings This is the number one complaint with anchor links html tutorials never mention. With a fixed or sticky header of 72px, the browser scrolls the target to y=0, which is behind the header. The fix is scroll-margin-top on the targets. :root { –header-height: 72px; } /* Any element that can be an anchor target */ [id] { scroll-margin-top: calc(var(–header-height) + 16px); } scroll-margin-top tells the browser to leave that much space above the element when it scrolls it into view. It works for clicks, for keyboard navigation and for a page loaded directly with a hash in the URL, which is exactly what the old JavaScript offset hacks failed to do. Do not use these old workarounds Old trick Why it fails Empty <span id=”x”> pushed up with negative margins Breaks layout, invisible targets confuse assistive tech padding-top + negative margin-top on every section Creates dead click zones over the previous section window.scrollTo(y – offset) on click Does nothing for direct hits on a URL that already contains a hash Header height that changes on mobile If your header shrinks on small screens, redeclare the variable in a media query. The scroll offset follows automatically. @media (max-width: 768px) { :root { –header-height: 56px; } } Step 5: highlight the link for the section being read An anchor menu without an active state is half a feature. Use IntersectionObserver rather than a scroll listener: it is cheaper, it does not fire hundreds of times per second and it does not jank on mobile. const links = Array.from(document.querySelectorAll(‘.toc a[href^=”#”]’)); const map = new Map(); links.forEach(link => { const target = document.querySelector(link.getAttribute(‘href’)); if (target) map.set(target, link); }); const setActive = (link) => { links.forEach(l => { l.classList.toggle(‘is-active’, l === link); if (l === link) { l.setAttribute(‘aria-current’, ‘true’); } else { l.removeAttribute(‘aria-current’); } }); }; const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) setActive(map.get(entry.target)); }); }, { /* Activate a section when it crosses the upper third of the screen */ rootMargin: ‘-25% 0px -65% 0px’, threshold: 0 }); map.forEach((link, section) => observer.observe(section)); The rootMargin value is the part you tune. It shrinks the detection box to a horizontal band near the top of the viewport, so a section becomes active when its content reaches reading position, not when one pixel of it appears at the bottom. The matching CSS: .toc

How to Create an Anchor Link Menu That Scrolls Smoothly (HTML, CSS, and JS) Read More »

How to Design an FAQ Page That Answers Questions and Ranks on Google

Most FAQ pages fail for the same reason: they were written by the marketing team to sound reassuring, not by the support team to solve problems. The result is a long wall of questions nobody asked, buried in a collapsed accordion that Google barely rewards and users abandon in seconds. This guide shows you how to design an FAQ page that does two jobs at once: it deflects support tickets and it earns visibility in search results and AI answers. We will cover question sourcing, grouping, accordion patterns, layout decisions, internal linking, FAQ schema in its current (much narrower) role, when to split FAQs across multiple pages, and the mistakes that make FAQ pages useless. What a good FAQ page is actually for Before touching layout, be clear about the job. A well-designed FAQ page serves four purposes: Self-service: a user finds the answer without contacting you. Objection handling: pricing, refunds, security, contracts, delivery times. The doubts that block a purchase. Discovery: long-tail queries that bring in people who did not know your brand yet. Routing: when the answer is complex, the FAQ sends people to the right documentation, pricing page or support form. If a question does not serve at least one of those goals, it does not belong on the page. Step 1: Source real questions, not imagined ones The single biggest quality difference between a great FAQ page and a filler one is where the questions came from. Pull them from data you already own. Source What to extract Why it matters Support tickets and email The 20 most repeated requests of the last 90 days Highest ticket-deflection value Live chat and phone logs Exact customer wording, including slang and typos Gives you natural-language phrasing Internal site search Queries with zero results or high exit rate Reveals content gaps Search Console Question-shaped queries with impressions but low CTR Demand you already almost rank for SERP features People Also Ask, related searches, autocomplete Confirms how the market phrases the problem Sales calls Objections raised before signing Directly affects conversion Rule: keep the customer’s words in the question, and put your terminology in the answer. A visitor searches “why is my invoice different from the price I saw”, not “proration policy overview”. Score each question before you publish it Give every candidate question a quick score from 1 to 5 on two axes: frequency (how often it is asked) and impact (does it block a purchase or generate a ticket). Publish anything scoring 7 or more. Park the rest in a backlog and revisit quarterly. The team at zendesk.com reached a similar conclusion. Step 2: Choose your FAQ architecture This is the layout decision that most teams get wrong. There are three viable models. Model Best when Structure Single FAQ page Fewer than about 25 questions, short answers One page, 3 to 6 categories, accordions with anchor links FAQ hub plus category pages 25 to 100 questions across distinct topics /faq/ hub linking to /faq/billing/, /faq/shipping/, etc. Knowledge base with dedicated answer pages 100+ questions, or answers needing screenshots and steps Searchable base, one URL per question, FAQ page as entry point When to split FAQs across pages Split when any of these is true: The answer needs more than 150 words or requires steps, screenshots or a video. It becomes a guide, and the FAQ entry becomes a two-line summary plus a link. Two audiences are mixed. Pre-sales questions and existing-customer troubleshooting have different intents. Separate them, or at minimum separate them into clearly labelled sections. The page passes roughly 3,000 words or 40 accordion items. Scannability collapses and the page stops being about anything specific. A single question has real search volume. If “how long does shipping take to Canada” gets searched hundreds of times a month, it deserves its own URL that can rank, not a hidden accordion row. Different teams own different sections. Splitting reduces edit conflicts and keeps content fresh. Keep questions on one page when they are short, closely related and mostly consumed as a group (returns, exchanges and refunds, for example). Step 3: Group questions the way users think Never publish a flat, ungrouped list. Group by the user’s mental model, not your internal org chart. By stage of journey: Before you buy, Setup, Daily use, Billing, Cancelling. By topic: Pricing, Security, Integrations, Shipping, Returns. By audience: Developers, Agencies, Enterprise buyers. By product: only if your products are genuinely different, otherwise it fragments the page. Practical rules that hold up in usability testing: Aim for 3 to 7 categories. More than that and the category nav becomes its own puzzle. Aim for 4 to 10 questions per category. A category with one question is not a category. Order categories by demand, not alphabetically. Put the most-asked group first. Inside a category, put the highest-volume question first. Do not save the good stuff for the bottom. Label categories with plain nouns (“Billing and invoices”), not clever headings (“The money bit”). Step 4: Layout and the accordion question Accordions are the default pattern for a reason: they let a user scan 30 questions in one screen. But they have real costs, and how you implement them decides whether your page works. When accordions are the right choice Answers are short and independent of each other. Users arrive knowing roughly what they want (“I need the refund policy”). You have more than about 8 questions in a section. When to skip accordions You have fewer than 6 questions. Just show the answers. Users need to compare answers side by side (use a table instead). The page is a landing page where reassurance needs to be visible without a click. Accordion rules that prevent damage Question text must be the full question, not a truncated label. “Refunds” is not a question. “Can I get a refund after 30 days?” is. Make the whole row clickable, not just the tiny chevron, and give it a minimum touch height of 44px. Allow multiple panels open at once.

How to Design an FAQ Page That Answers Questions and Ranks on Google Read More »

How to Add a Sticky Sidebar to a Blog With CSS position: sticky

A CSS sticky sidebar is one of those small touches that instantly makes a blog feel more polished. Your table of contents, author box, or newsletter form follows the reader as they scroll, without any JavaScript. The problem? Most tutorials show you two lines of CSS and stop there. In real projects, position: sticky silently fails because of a parent container, an overflow rule, or a flex height issue you didn’t see coming. In this tutorial, we’ll build a sticky sidebar for a blog layout using pure CSS, then walk through the real reasons it breaks and how to fix each one. We’ll also cover mobile behavior and a sensible fallback strategy. What a CSS Sticky Sidebar Actually Does A sticky element behaves like position: relative until its scroll offset threshold is crossed, then it behaves like position: fixed, but only within the bounds of its nearest scrolling ancestor. That last part is where most bugs live. Unlike position: fixed, a sticky sidebar: Stays inside its parent container and stops scrolling when the parent ends. Doesn’t require you to reserve space with margins or padding hacks. Works with normal document flow, so it plays well with responsive layouts. Step 1: The HTML Structure Keep it simple. A blog post typically has a content column and a sidebar column inside a wrapper. <div class=”post-layout”> <article class=”post-content”> <!– Long blog content here –> </article> <aside class=”post-sidebar”> <div class=”sidebar-inner”> <h3>Table of Contents</h3> <!– Links, author box, ads, newsletter… –> </div> </aside> </div> Step 2: The Core CSS (The Two Lines That Matter) .post-layout { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 3rem; align-items: start; /* critical, see below */ } .post-sidebar { position: sticky; top: 1.5rem; align-self: start; } That’s the whole trick. position: sticky plus a top value tells the browser when to pin the element. But if you stop here, you’ll hit the classic bugs. Let’s fix them. Step 3: Why Your Sticky Sidebar Isn’t Sticking If your sidebar refuses to stick, one of these four things is almost always the cause. 1. The parent container has the same height as the sidebar Sticky works while the parent is taller than the sticky element. In a flex or grid layout, children stretch to equal height by default. If your sidebar is stretched to match the article, there’s no room left to scroll past it, so it never sticks. Fix: use align-items: start on the container, or align-self: start on the sidebar. This lets the article stay tall while the sidebar keeps its natural height. 2. An ancestor has overflow: hidden, auto, or scroll This is the silent killer. If any ancestor up the DOM tree has an overflow value other than visible, sticky positions itself relative to that ancestor’s scroll container, which usually means it just doesn’t scroll at all. Fix: inspect every ancestor in DevTools and make sure none of them have overflow set. If you need overflow for another reason (like clipping shadows), move it to a sibling or child element instead. 3. No top, bottom, left, or right value position: sticky without a threshold does nothing. You must define at least one directional offset. 4. The parent is too short If the article is only slightly taller than the sidebar, there’s very little scroll distance where the sticky behavior is visible. This isn’t a bug, but it looks like one. Test with real content length. Step 4: Handling Tall Sidebars What if your sidebar is taller than the viewport? A pure top: 0 will cut off the bottom of the sidebar forever, since sticky only pins to one edge at a time. Originally covered on https://mimo.org. For tall sidebars, allow the sidebar itself to scroll internally: .post-sidebar { position: sticky; top: 1.5rem; align-self: start; max-height: calc(100vh – 3rem); overflow-y: auto; } Now the sidebar sticks, and if its contents exceed the viewport height, the user can scroll inside it. Step 5: Mobile Behavior On phones, a sticky sidebar next to content doesn’t make sense, the columns stack. You have two reasonable options: Disable sticky on mobile and let the sidebar sit naturally below the article. Keep sticky but pin a compact version, like a short table of contents at the top. Here’s the disable approach: @media (max-width: 900px) { .post-layout { grid-template-columns: 1fr; } .post-sidebar { position: static; max-height: none; overflow: visible; } } Step 6: Fallbacks for Old Browsers As of 2026, position: sticky is supported by every modern browser, including all evergreen versions of Chrome, Edge, Firefox, and Safari. Global support is above 97%. You almost never need a JavaScript polyfill anymore. That said, if you support very old enterprise browsers, the graceful fallback is simple: an unsupported browser will treat the property as invalid and the sidebar will scroll normally with the page. That’s an acceptable degradation for most blogs. Comparison: sticky vs fixed vs JavaScript Approach Pros Cons position: sticky No JS, respects parent bounds, responsive-friendly Breaks with overflow on ancestors position: fixed Always visible, predictable Ignores parent, overlaps footer, harder on mobile JavaScript Full control, works around edge cases Extra weight, scroll jank, more maintenance Bonus: A Smooth Highlight Effect for a Table of Contents If your sticky sidebar contains a table of contents, you can highlight the current section with a small scroll listener, or use the modern :target and scroll-behavior: smooth combo for a zero-JS version: html { scroll-behavior: smooth; scroll-padding-top: 2rem; } The scroll-padding-top keeps anchor jumps from landing behind a fixed header. github.io published something useful on the subject. Final Working Example .post-layout { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 3rem; align-items: start; max-width: 1200px; margin: 0 auto; padding: 2rem; } .post-sidebar { position: sticky; top: 1.5rem; align-self: start; max-height: calc(100vh – 3rem); overflow-y: auto; } @media (max-width: 900px) { .post-layout { grid-template-columns: 1fr; } .post-sidebar { position: static; max-height: none; overflow: visible; } } FAQ Why is my CSS sticky sidebar not working? Nine times out of ten it’s one of three things: an ancestor has

How to Add a Sticky Sidebar to a Blog With CSS position: sticky Read More »

How to Add a Progress Bar to a Multi-Step Form With CSS and JavaScript

Long forms scare users away. Research consistently shows that form abandonment rates climb dramatically when users can’t see how much effort is left. The fix is simple: split your form into digestible steps and give users a clear visual indicator of their progression. In this tutorial, we will build a multi-step form progress bar from scratch using only HTML, CSS, and JavaScript. No frameworks, no dependencies, just clean copy-paste code you can drop into any project. Why a Multi-Step Form Progress Bar Matters for UX Before writing a single line of code, let’s understand why this pattern works so well: Reduces cognitive load: Users focus on one small chunk at a time instead of a wall of inputs. Sets expectations: A progress indicator tells users exactly how many steps remain. Creates commitment: Once users complete step 1, they are more likely to finish (the sunk cost effect). Improves completion rates: Studies from Baymard Institute and Nielsen Norman Group show progress indicators can lift completion rates by 10 to 30%. Accessibility: A well-labeled stepper helps screen reader users understand where they are. What We Will Build A three-step signup form with: A horizontal step indicator with numbered circles. A dynamic progress bar that fills as the user advances. Working Next and Previous buttons. Smooth CSS transitions between steps. Basic validation before allowing progression. Step 1: The HTML Structure Start with semantic markup. Each step lives in its own container, and the progress bar sits at the top. <form id=”multiStepForm” class=”msf”> <div class=”msf-progress”> <div class=”msf-progress-bar” id=”progressBar”></div> <div class=”msf-step-circle active” data-step=”1″>1</div> <div class=”msf-step-circle” data-step=”2″>2</div> <div class=”msf-step-circle” data-step=”3″>3</div> </div> <fieldset class=”msf-step active”> <h3>Account</h3> <label>Email<input type=”email” name=”email” required></label> <label>Password<input type=”password” name=”password” required></label> </fieldset> <fieldset class=”msf-step”> <h3>Profile</h3> <label>Full name<input type=”text” name=”name” required></label> <label>Country<input type=”text” name=”country” required></label> </fieldset> <fieldset class=”msf-step”> <h3>Confirm</h3> <p>Review your details and submit.</p> </fieldset> <div class=”msf-nav”> <button type=”button” id=”prevBtn” disabled>Previous</button> <button type=”button” id=”nextBtn”>Next</button> </div> </form> Step 2: The CSS for the Progress Bar This is where the visual magic happens. We use position: absolute for the filling bar and CSS transitions for smooth animation. .msf { max-width: 560px; margin: 2rem auto; font-family: system-ui, sans-serif; } .msf-progress { position: relative; display: flex; justify-content: space-between; margin-bottom: 2rem; } .msf-progress::before { content: “”; position: absolute; top: 50%; left: 0; right: 0; height: 4px; background: #e0e0e0; transform: translateY(-50%); z-index: 1; } .msf-progress-bar { position: absolute; top: 50%; left: 0; height: 4px; width: 0; background: #2563eb; transform: translateY(-50%); transition: width 0.4s ease; z-index: 2; } .msf-step-circle { position: relative; z-index: 3; width: 36px; height: 36px; border-radius: 50%; background: #fff; border: 3px solid #e0e0e0; display: flex; align-items: center; justify-content: center; font-weight: 600; color: #999; transition: all 0.3s ease; } .msf-step-circle.active { border-color: #2563eb; color: #2563eb; } .msf-step-circle.completed { background: #2563eb; border-color: #2563eb; color: #fff; } .msf-step { display: none; border: none; padding: 0; } .msf-step.active { display: block; animation: fade 0.3s ease; } @keyframes fade { from { opacity: 0; transform: translateX(10px); } to { opacity: 1; transform: translateX(0); } } .msf-step label { display: block; margin-bottom: 1rem; } .msf-step input { display: block; width: 100%; padding: 0.6rem; margin-top: 0.3rem; border: 1px solid #ccc; border-radius: 6px; } .msf-nav { display: flex; justify-content: space-between; margin-top: 1.5rem; } .msf-nav button { padding: 0.6rem 1.4rem; border: none; border-radius: 6px; background: #2563eb; color: #fff; font-weight: 600; cursor: pointer; } .msf-nav button:disabled { background: #ccc; cursor: not-allowed; } Step 3: The JavaScript Logic The script controls three things: which step is visible, the width of the progress bar, and the state of the navigation buttons. phppot.com goes into the numbers. const steps = document.querySelectorAll(‘.msf-step’); const circles = document.querySelectorAll(‘.msf-step-circle’); const progressBar = document.getElementById(‘progressBar’); const nextBtn = document.getElementById(‘nextBtn’); const prevBtn = document.getElementById(‘prevBtn’); let current = 0; function updateUI() { steps.forEach((s, i) => s.classList.toggle(‘active’, i === current)); circles.forEach((c, i) => { c.classList.toggle(‘active’, i === current); c.classList.toggle(‘completed’, i < current); }); const percent = (current / (steps.length – 1)) * 100; progressBar.style.width = percent + ‘%’; prevBtn.disabled = current === 0; nextBtn.textContent = current === steps.length – 1 ? ‘Submit’ : ‘Next’; } function validateStep(index) { const inputs = steps[index].querySelectorAll(‘input[required]’); for (const input of inputs) { if (!input.checkValidity()) { input.reportValidity(); return false; } } return true; } nextBtn.addEventListener(‘click’, () => { if (!validateStep(current)) return; if (current < steps.length – 1) { current++; updateUI(); } else { document.getElementById(‘multiStepForm’).submit(); } }); prevBtn.addEventListener(‘click’, () => { if (current > 0) { current–; updateUI(); } }); updateUI(); Progress Bar vs Step Indicator: Which One to Use? Both patterns can coexist, as in our example, but here is a quick comparison to help you choose: Pattern Best For Drawback Progress bar only Long or variable-length forms No visibility on step names Numbered steps Short forms (3 to 5 steps) Cluttered on mobile if too many steps Labeled steps Complex processes (checkout, KYC) Requires more horizontal space Combined (our tutorial) Most desktop forms Slightly more code UX Best Practices for Multi-Step Forms in 2026 Keep steps between 3 and 5. More than that feels endless, even with a progress bar. Never lose user data. Store answers in localStorage or state so a back button never erases progress. Show inline validation. Validate on blur, not only on Next click. Make step circles clickable for completed steps so users can review. Announce step changes to screen readers with aria-live=”polite”. Optimize for mobile: stack step labels vertically or hide them below 480px. Avoid asking for optional information in the first step. Front-load easy questions to build momentum. Making It Accessible Add these attributes to make the component screen reader friendly: <div class=”msf-progress” role=”progressbar” aria-valuemin=”0″ aria-valuemax=”100″ aria-valuenow=”0″ id=”progressWrapper”> Then update aria-valuenow inside your updateUI function: document.getElementById(‘progressWrapper’) .setAttribute(‘aria-valuenow’, Math.round(percent)); FAQ How many steps should a multi-step form have? Aim for 3 to 5 steps. Fewer feels unnecessary, and more can feel overwhelming even with a visual progress bar. Group related fields together so each step has a clear purpose. The piece Using Progress Bars in Multi-Page Forms makes a good next read. Should the progress bar show percentages or step numbers? Step numbers are clearer for short forms (under

How to Add a Progress Bar to a Multi-Step Form With CSS and JavaScript Read More »

How to Add a Custom Cursor to Your Website With CSS and JavaScript

The default arrow cursor works, but it doesn’t leave a lasting impression. If you want your website to feel premium, interactive, and memorable, a custom cursor is one of the smallest visual changes you can make with the biggest perceived impact. In this tutorial, we’ll walk through how to build a smooth, animated custom cursor with CSS and a lightweight JavaScript script. We’ll also cover the things most tutorials skip: accessibility, performance, and what to do on touch devices. What Is a Custom Cursor? A custom cursor replaces the browser’s default mouse pointer with something you design yourself. It can be: A static image (PNG, SVG, or CUR file) loaded through the CSS cursor property. An animated HTML element that follows the pointer using JavaScript. A combination of both, with trailing effects or hover interactions. The first method is simple but limited. The second is what modern portfolio and agency websites use, and it’s what we’ll focus on here. Method 1: The Quick CSS-Only Custom Cursor If you just want to swap the arrow for a custom image, CSS alone is enough: body { cursor: url(‘/assets/cursor.png’) 16 16, auto; } a, button { cursor: url(‘/assets/cursor-pointer.png’) 16 16, pointer; } The two numbers after the URL define the hotspot coordinates (the exact pixel that acts as the click point). Always provide a fallback keyword like auto or pointer so the browser has something to fall back on if the image fails to load. Requirements and Limits Property Value Max size (Chrome/Firefox) 128 x 128 px Supported formats PNG, SVG, CUR, GIF (static) Animated cursors Not supported via CSS Fallback keyword Required If you need animation, scaling, or blend modes, you need JavaScript. Method 2: An Animated Custom Cursor With CSS and JavaScript Here’s the approach: we render a real HTML element on the page, hide the native cursor, and update the element’s position on every mouse move. Step 1: Add the HTML <div class=”cursor” id=”cursor”></div> <div class=”cursor-dot” id=”cursor-dot”></div> Two elements give us a layered effect: a large soft ring and a small precise dot. Step 2: Style It With CSS * { cursor: none; } .cursor { position: fixed; top: 0; left: 0; width: 40px; height: 40px; border: 2px solid #111; border-radius: 50%; pointer-events: none; transform: translate(-50%, -50%); transition: width 0.2s, height 0.2s, background-color 0.2s; z-index: 9999; } .cursor-dot { position: fixed; top: 0; left: 0; width: 6px; height: 6px; background: #111; border-radius: 50%; pointer-events: none; transform: translate(-50%, -50%); z-index: 9999; } .cursor.hover { width: 70px; height: 70px; background: rgba(0, 0, 0, 0.1); } A few things to note: pointer-events: none ensures the cursor element never blocks clicks. position: fixed keeps it aligned with the viewport, not the document. transform: translate(-50%, -50%) centers the element on the actual mouse position. Step 3: Add the JavaScript const cursor = document.getElementById(‘cursor’); const dot = document.getElementById(‘cursor-dot’); let mouseX = 0, mouseY = 0; let ringX = 0, ringY = 0; document.addEventListener(‘mousemove’, (e) => { mouseX = e.clientX; mouseY = e.clientY; dot.style.transform = `translate(${mouseX}px, ${mouseY}px) translate(-50%, -50%)`; }); function animate() { ringX += (mouseX – ringX) * 0.15; ringY += (mouseY – ringY) * 0.15; cursor.style.transform = `translate(${ringX}px, ${ringY}px) translate(-50%, -50%)`; requestAnimationFrame(animate); } animate(); document.querySelectorAll(‘a, button, [data-cursor-hover]’).forEach(el => { el.addEventListener(‘mouseenter’, () => cursor.classList.add(‘hover’)); el.addEventListener(‘mouseleave’, () => cursor.classList.remove(‘hover’)); }); The dot follows the mouse instantly, while the ring uses linear interpolation (lerp) for a smooth trailing effect. The requestAnimationFrame loop keeps the animation buttery smooth without hammering the browser. Handling Touch Devices Touch devices don’t have a cursor. Showing your custom element on mobile would be pointless and could even cause layout issues. Detect and disable it: const isTouch = window.matchMedia(‘(pointer: coarse)’).matches; if (isTouch) { cursor.style.display = ‘none’; dot.style.display = ‘none’; document.documentElement.style.cursor = ‘auto’; } The (pointer: coarse) media query is the most reliable way to detect touch-primary devices in 2026, better than the old ontouchstart sniffing. Accessibility Considerations This is where most custom cursor tutorials fail their users. Before shipping, consider the following: Never remove the cursor entirely without a replacement. Users with low vision rely on visible pointers. Respect reduced motion preferences. Disable trailing and animations if the user opts out. Maintain sufficient contrast. A pale gray cursor on a white background is invisible. Provide a way to disable it. Consider a toggle in your site settings. Keep the click target aligned. The visual cursor must match where clicks actually register. Here’s how to respect reduced motion: const prefersReducedMotion = window.matchMedia(‘(prefers-reduced-motion: reduce)’).matches; function animate() { if (prefersReducedMotion) { ringX = mouseX; ringY = mouseY; } else { ringX += (mouseX – ringX) * 0.15; ringY += (mouseY – ringY) * 0.15; } cursor.style.transform = `translate(${ringX}px, ${ringY}px) translate(-50%, -50%)`; requestAnimationFrame(animate); } Performance Tips Use transform, not top/left. Transforms are GPU-accelerated and avoid layout recalculations. Avoid box-shadow on the cursor. Large blurs cause paint bottlenecks during rapid movement. Throttle nothing on mousemove. Keep mousemove listeners minimal, let requestAnimationFrame handle the rendering. Add will-change: transform. Only if you notice jank, since overuse hurts memory. Skip the cursor on iframes and video overlays. They often break the illusion. Bonus: Cursor Trails If you want a trail effect, spawn multiple small dots on movement and fade them out with CSS transitions. Keep the total DOM elements below 20 to preserve performance on lower-end devices. There’s a good explainer over at css-tricks.com. Common Mistakes to Avoid Mistake Fix Forgetting pointer-events: none Cursor blocks clicks on links Using position: absolute Cursor drifts when scrolling Cursor image over 128px Browsers reject it silently No fallback keyword in cursor: url() CSS becomes invalid Ignoring touch devices Broken UX on mobile FAQ Can I use an animated GIF as a custom cursor in CSS? No. The CSS cursor property only supports static images. For animation, you must render an HTML element and animate it with JavaScript. What image format is best for a custom cursor? SVG for scalability and small file size, PNG when you need pixel-perfect raster art. Keep dimensions at or below 128 x 128 pixels. The point is broken down

How to Add a Custom Cursor to Your Website With CSS and JavaScript Read More »

How to Design a Feature Comparison Table for a SaaS Website (With CSS Examples)

If you run a SaaS website, your pricing page is probably one of the most visited and most decisive pages of your funnel. And at the heart of that page sits the feature comparison table. A good one guides prospects to the right plan in seconds. A bad one creates hesitation, confusion, and lost revenue. In this guide, we walk through a practical approach to feature comparison table design, with real HTML and CSS you can copy, adapt, and ship today. We will focus on clarity, mobile-friendliness, and one thing most tutorials forget: subtly nudging visitors toward the plan you actually want them to pick. You can read more here. Why Feature Comparison Table Design Matters for SaaS A comparison table is not just a visual summary. It is a decision-making tool. Visitors scan it to answer three questions: What do I get on each plan? Which plan is right for me? Is the upgrade worth the extra money? If your table cannot answer these questions in under 15 seconds, users bounce. The best SaaS pricing tables share a few traits: consistency in content, scannability, and a simple layout. Anything else is decoration. Step 1: Plan Before You Code Before touching HTML, decide the following: Audience: Is this for developers, marketers, or enterprise buyers? Feature naming should match their vocabulary. Number of plans: Three is the sweet spot. Four is the maximum before mobile breaks down. Recommended plan: Pick one. Usually the middle tier. Feature grouping: Group related features (Core, Collaboration, Security, Support) rather than dumping 40 rows in a row. Values: Use consistent value types per row. Do not mix “Unlimited”, “Yes”, and a checkmark in the same column. Step 2: Semantic HTML Structure Use a real <table>. It is the correct semantic choice, it is accessible by default, and screen readers understand it. Skip the div soup. <table class=”pricing-table”> <caption class=”sr-only”>SaaS plan comparison</caption> <thead> <tr> <th scope=”col”>Features</th> <th scope=”col”>Starter</th> <th scope=”col” class=”recommended”>Growth</th> <th scope=”col”>Enterprise</th> </tr> </thead> <tbody> <tr> <th scope=”row”>Projects</th> <td data-label=”Starter”>3</td> <td data-label=”Growth”>25</td> <td data-label=”Enterprise”>Unlimited</td> </tr> <tr> <th scope=”row”>Team members</th> <td data-label=”Starter”>1</td> <td data-label=”Growth”>10</td> <td data-label=”Enterprise”>Unlimited</td> </tr> <tr> <th scope=”row”>SSO</th> <td data-label=”Starter”>&mdash;</td> <td data-label=”Growth”>&#10003;</td> <td data-label=”Enterprise”>&#10003;</td> </tr> </tbody> </table> Notice the data-label attributes. We will use them for the mobile layout later. Step 3: Base CSS for a Clean Layout Start minimal. Whitespace, alignment, and typography do 80% of the visual work. .pricing-table { width: 100%; border-collapse: collapse; font-family: system-ui, sans-serif; font-size: 15px; color: #1f2937; } .pricing-table th, .pricing-table td { padding: 16px 20px; text-align: center; border-bottom: 1px solid #e5e7eb; } .pricing-table thead th { background: #f9fafb; font-weight: 600; font-size: 16px; } .pricing-table tbody th { text-align: left; font-weight: 500; color: #374151; } Step 4: Highlight the Recommended Plan This is where most comparison tables fail. They treat all plans equally and let the user do all the work. Instead, visually anchor the plan you want people to choose. .pricing-table .recommended { position: relative; background: #eef2ff; color: #4338ca; border-top: 3px solid #6366f1; } .pricing-table td.recommended-col { background: #f5f7ff; } .pricing-table .recommended::after { content: “Most popular”; position: absolute; top: -12px; left: 50%; transform: translateX(-50%); background: #6366f1; color: #fff; font-size: 11px; font-weight: 600; padding: 4px 10px; border-radius: 999px; letter-spacing: 0.5px; } Techniques that work well for the recommended plan: Slightly different background color (subtle, not neon) A small badge like Most popular or Best value Bolder CTA button in that column A colored top border to visually lift the column Step 5: Make It Mobile-Friendly Traditional tables break on mobile because they scroll horizontally or shrink text to unreadable sizes. Two solid strategies: Option A: Stacked Cards on Mobile Turn each column into a card by re-flowing the table with CSS. @media (max-width: 720px) { .pricing-table thead { display: none; } .pricing-table, .pricing-table tbody, .pricing-table tr, .pricing-table td, .pricing-table th { display: block; width: 100%; } .pricing-table tr { margin-bottom: 24px; border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; } .pricing-table tbody th { background: #f9fafb; padding: 12px 16px; } .pricing-table td { text-align: right; padding: 12px 16px; position: relative; } .pricing-table td::before { content: attr(data-label); float: left; font-weight: 600; color: #6b7280; } } Option B: Horizontal Scroll With a Sticky First Column Better for tables with many rows and technical audiences. .table-wrapper { overflow-x: auto; } .pricing-table th:first-child, .pricing-table td:first-child { position: sticky; left: 0; background: #fff; z-index: 1; } Step 6: Add the CTAs The table should end with a call-to-action row. Each button should match the tone of the plan. Plan CTA Style Label Starter Ghost button Start free Growth (recommended) Solid, brand color Try Growth free Enterprise Outline Talk to sales Conversion Tips That Actually Move the Needle Use consistent language: If one column says “Unlimited seats”, the others should not say “Users included: 5”. Explain jargon: Add a tooltip or a small info icon for technical features like SSO, SCIM, or audit logs. Order rows by importance: Put the features people care most about at the top. Compliance and admin stuff goes lower. Show, do not tell: Use checkmarks and dashes rather than the words “Yes” and “No”. Faster to scan. Anchor pricing above the table: The price should be visible without scrolling. Test annual vs monthly toggles: A simple toggle at the top can lift annual conversions significantly. Common Feature Comparison Table Design Mistakes Too many plans (five or more) that create decision paralysis. Overuse of color, causing visual noise instead of guidance. Features hidden behind “See all features” toggles when they should be visible. Inconsistent row heights when checkmarks and long text mix. Not testing the mobile view before shipping. Accessibility Checklist Use <th scope=”col”> and <th scope=”row”> properly. Provide a <caption>, even if visually hidden. Keep color contrast above 4.5:1 for text. Do not rely on color alone to indicate the recommended plan. Add a text badge. Make sure the table is keyboard-navigable and screen-reader friendly. FAQ Should I use a table or divs for a pricing comparison? Use a real HTML table. It is semantically correct, accessible, and easier

How to Design a Feature Comparison Table for a SaaS Website (With CSS Examples) Read More »

About Us

Express Jam Studio was founded in 2004 by John Smith. John had previously worked for a courier company, but he saw an opportunity to start his own business in the web design and development industry.

Contact Info

Copyright © 2022 Express Jam Studio. All Rights Reserved.