Author name: Carrie Carlson

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 »

How to Add Breadcrumbs to WordPress Without a Plugin (PHP and Schema)

If you are tired of installing bulky SEO plugins just to display a simple navigation trail, you are in the right place. In this tutorial, we will show you exactly how to add WordPress breadcrumbs without a plugin, using only PHP inside your theme’s functions.php file, plus proper JSON-LD schema markup so Google can display them in search results. This method is lightweight, fast, and gives you full control over the HTML output. No plugin bloat, no unnecessary database queries, just clean code that works. Why Code Breadcrumbs Instead of Using a Plugin? Plugins like Yoast or RankMath do offer breadcrumb features, but they come with a full ecosystem you might not need. Here is a quick comparison: Aspect Plugin Method Custom PHP Method Performance Extra queries and assets loaded Minimal footprint Customization Limited to plugin options Full control over HTML and CSS Schema Markup Often included but generic Tailored JSON-LD output Dependencies Tied to plugin updates Independent, portable code Step 1: Add the Breadcrumb Function to functions.php Open your child theme’s functions.php file (never edit the parent theme directly) and paste the following function. It handles posts, pages, categories, tags, custom post types, author archives, search results, and 404 pages. function express_custom_breadcrumbs() { $separator = ‘»’; $home_title = ‘Home’; $home_url = home_url(); global $post; $output = ‘<nav class=”express-breadcrumbs” aria-label=”Breadcrumb”>’; $output .= ‘<a href=”‘ . $home_url . ‘”>’ . $home_title . ‘</a> ‘ . $separator . ‘ ‘; if ( is_category() ) { $output .= single_cat_title(”, false); } elseif ( is_tag() ) { $output .= single_tag_title(”, false); } elseif ( is_author() ) { $output .= ‘Author: ‘ . get_the_author(); } elseif ( is_search() ) { $output .= ‘Search results for: ‘ . get_search_query(); } elseif ( is_404() ) { $output .= ‘Page not found’; } elseif ( is_single() ) { $post_type = get_post_type(); if ( $post_type != ‘post’ ) { $pt_obj = get_post_type_object($post_type); $output .= ‘<a href=”‘ . get_post_type_archive_link($post_type) . ‘”>’ . $pt_obj->labels->singular_name . ‘</a> ‘ . $separator . ‘ ‘; } else { $categories = get_the_category(); if ( $categories ) { $cat = $categories[0]; $output .= ‘<a href=”‘ . get_category_link($cat->term_id) . ‘”>’ . $cat->name . ‘</a> ‘ . $separator . ‘ ‘; } } $output .= get_the_title(); } elseif ( is_page() ) { if ( $post->post_parent ) { $ancestors = array_reverse(get_post_ancestors($post->ID)); foreach ( $ancestors as $ancestor ) { $output .= ‘<a href=”‘ . get_permalink($ancestor) . ‘”>’ . get_the_title($ancestor) . ‘</a> ‘ . $separator . ‘ ‘; } } $output .= get_the_title(); } $output .= ‘</nav>’; return $output; } Step 2: Display the Breadcrumbs in Your Theme Now you need to call this function where you want the breadcrumbs to appear. Typical locations include: header.php – right below the main navigation single.php – above the post title page.php – above the page content archive.php – above the archive listing Add this line where you want the trail to appear: <?php echo express_custom_breadcrumbs(); ?> Using a Shortcode Instead If you prefer inserting breadcrumbs via the block editor or a widget, register a shortcode: function express_breadcrumbs_shortcode() { return express_custom_breadcrumbs(); } add_shortcode(‘breadcrumbs’, ‘express_breadcrumbs_shortcode’); Then simply use [breadcrumbs] anywhere in your content. Step 3: Add Schema Markup for SEO This is where most tutorials stop, but Google needs structured data to actually display breadcrumbs in the search snippet. We will use JSON-LD BreadcrumbList schema, the format Google officially recommends. Add this function to your functions.php: function express_breadcrumbs_schema() { if ( is_front_page() ) return; $items = array(); $position = 1; $items[] = array( ‘@type’ => ‘ListItem’, ‘position’ => $position++, ‘name’ => ‘Home’, ‘item’ => home_url() ); if ( is_single() ) { $categories = get_the_category(); if ( $categories ) { $cat = $categories[0]; $items[] = array( ‘@type’ => ‘ListItem’, ‘position’ => $position++, ‘name’ => $cat->name, ‘item’ => get_category_link($cat->term_id) ); } $items[] = array( ‘@type’ => ‘ListItem’, ‘position’ => $position++, ‘name’ => get_the_title(), ‘item’ => get_permalink() ); } elseif ( is_page() ) { global $post; if ( $post->post_parent ) { $ancestors = array_reverse(get_post_ancestors($post->ID)); foreach ( $ancestors as $ancestor ) { $items[] = array( ‘@type’ => ‘ListItem’, ‘position’ => $position++, ‘name’ => get_the_title($ancestor), ‘item’ => get_permalink($ancestor) ); } } $items[] = array( ‘@type’ => ‘ListItem’, ‘position’ => $position++, ‘name’ => get_the_title(), ‘item’ => get_permalink() ); } elseif ( is_category() ) { $items[] = array( ‘@type’ => ‘ListItem’, ‘position’ => $position++, ‘name’ => single_cat_title(”, false), ‘item’ => get_category_link(get_queried_object_id()) ); } $schema = array( ‘@context’ => ‘https://schema.org’, ‘@type’ => ‘BreadcrumbList’, ‘itemListElement’ => $items ); echo ‘<script type=”application/ld+json”>’ . wp_json_encode($schema) . ‘</script>’; } add_action(‘wp_head’, ‘express_breadcrumbs_schema’); Step 4: Style Your Breadcrumbs Add this to your theme’s style.css file to make the breadcrumbs look clean and readable: .express-breadcrumbs { font-size: 14px; color: #666; padding: 12px 0; margin-bottom: 20px; } .express-breadcrumbs a { color: #0073aa; text-decoration: none; } .express-breadcrumbs a:hover { text-decoration: underline; } Step 5: Test Your Breadcrumbs Before considering the job done, run through these checks: Visit a post, a page, a category archive, and a 404 page. Confirm the trail matches the actual hierarchy. Open the page source and verify the JSON-LD script is present in the <head>. Use the Google Rich Results Test at search.google.com/test/rich-results to validate the BreadcrumbList schema. Check the Schema Markup Validator at validator.schema.org for any warnings. Submit the updated pages via Google Search Console to speed up re-indexing. Handling Custom Post Types and Taxonomies The function above already handles custom post types by pulling the archive link. If your CPT uses a custom taxonomy (like a WooCommerce product with product categories), extend the is_single() block like this: if ( is_singular(‘product’) ) { $terms = get_the_terms(get_the_ID(), ‘product_cat’); if ( $terms && !is_wp_error($terms) ) { $term = array_shift($terms); $output .= ‘<a href=”‘ . get_term_link($term) . ‘”>’ . $term->name . ‘</a> ‘ . $separator . ‘ ‘; } } Common Mistakes to Avoid Editing the parent theme directly. Your changes will be lost on the next update. Always use a child theme. Duplicating schema. If another plugin already outputs BreadcrumbList schema, remove one of them to avoid conflicts. Forgetting the aria-label. Accessibility matters, and screen readers rely on

How to Add Breadcrumbs to WordPress Without a Plugin (PHP and Schema) Read More »

How to Create a Sortable and Filterable Image Gallery With JavaScript

A filterable image gallery in JavaScript is one of those UI patterns that looks simple on the surface but hides a lot of small decisions: how to structure your tags, how to animate transitions, how to keep it accessible on mobile, and how to add sorting without pulling in a heavy library. In this tutorial we will build one from scratch using vanilla JavaScript, CSS Grid and a few modern browser features. No jQuery, no Isotope, no framework. By the end you will have a gallery that filters by category, sorts by date or name, animates smoothly, and works well on phones. What we are building A responsive image grid powered by CSS Grid Category filter buttons (with support for multiple tags per image) A sort dropdown (newest, oldest, alphabetical) Smooth fade and scale transitions when items enter or leave A mobile-friendly layout with touch-safe controls Unlike the classic W3Schools portfolio gallery, this version uses data attributes for tags, supports combined filters, and adds sorting, which most tutorials skip. Step 1: The HTML structure We start with a clean semantic structure. Each image card carries its metadata inside data attributes, which is what makes filtering and sorting trivial later. <div class=”gallery-controls”> <div class=”filters”> <button class=”filter-btn active” data-filter=”all”>All</button> <button class=”filter-btn” data-filter=”nature”>Nature</button> <button class=”filter-btn” data-filter=”city”>City</button> <button class=”filter-btn” data-filter=”people”>People</button> <button class=”filter-btn” data-filter=”food”>Food</button> </div> <select id=”sort” class=”sort-select”> <option value=”newest”>Newest first</option> <option value=”oldest”>Oldest first</option> <option value=”az”>A to Z</option> <option value=”za”>Z to A</option> </select> </div> <div class=”gallery” id=”gallery”> <figure class=”item” data-tags=”nature” data-date=”2026-03-14″ data-title=”Forest”> <img src=”forest.jpg” alt=”Forest”> <figcaption>Forest</figcaption> </figure> <figure class=”item” data-tags=”city people” data-date=”2026-05-02″ data-title=”Street”> <img src=”street.jpg” alt=”Street”> <figcaption>Street</figcaption> </figure> <!– more items –> </div> Notice how data-tags can hold multiple values separated by spaces. That is the key to letting one image belong to several categories. Step 2: The CSS Grid layout CSS Grid handles the responsive layout with a single line. No media queries required for the basic flow. .gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; } .item { margin: 0; overflow: hidden; border-radius: 12px; background: #111; position: relative; transition: transform .35s ease, opacity .35s ease; } .item img { width: 100%; height: 220px; object-fit: cover; display: block; } .item.hide { opacity: 0; transform: scale(.85); pointer-events: none; position: absolute; width: 0; height: 0; } .filter-btn { padding: 8px 16px; border-radius: 999px; border: 1px solid #ddd; background: #fff; cursor: pointer; } .filter-btn.active { background: #111; color: #fff; } The .hide class is what animates items out. We do not use display: none because it kills transitions. (via https://dev.to) Step 3: The filtering logic in vanilla JavaScript Here is the core filter function. It reads the active filter, checks each item’s tag list, and toggles the hide class. const gallery = document.getElementById(‘gallery’); const items = Array.from(gallery.querySelectorAll(‘.item’)); const buttons = document.querySelectorAll(‘.filter-btn’); let currentFilter = ‘all’; function applyFilter(filter) { currentFilter = filter; items.forEach(item => { const tags = item.dataset.tags.split(‘ ‘); const match = filter === ‘all’ || tags.includes(filter); item.classList.toggle(‘hide’, !match); }); } buttons.forEach(btn => { btn.addEventListener(‘click’, () => { buttons.forEach(b => b.classList.remove(‘active’)); btn.classList.add(‘active’); applyFilter(btn.dataset.filter); }); }); Step 4: Adding the sort feature Sorting is where most tutorials stop short. We simply reorder the DOM nodes based on the selected criteria. const sortSelect = document.getElementById(‘sort’); function applySort(mode) { const sorted = […items].sort((a, b) => { if (mode === ‘newest’) return b.dataset.date.localeCompare(a.dataset.date); if (mode === ‘oldest’) return a.dataset.date.localeCompare(b.dataset.date); if (mode === ‘az’) return a.dataset.title.localeCompare(b.dataset.title); if (mode === ‘za’) return b.dataset.title.localeCompare(a.dataset.title); }); sorted.forEach(node => gallery.appendChild(node)); } sortSelect.addEventListener(‘change’, e => applySort(e.target.value)); Because CSS Grid respects source order, re-appending the nodes is enough. No manual positioning needed. Step 5: Smooth transitions with the FLIP technique If you want the items to animate to their new positions when sorting, use the FLIP pattern (First, Last, Invert, Play): First: record each item’s current bounding rect. Last: reorder the DOM, then read the new rects. Invert: apply a transform that moves each item back to where it started. Play: remove the transform on the next frame so CSS transitions animate it to place. function flipSort(mode) { const firstRects = new Map(items.map(i => [i, i.getBoundingClientRect()])); applySort(mode); requestAnimationFrame(() => { items.forEach(item => { const first = firstRects.get(item); const last = item.getBoundingClientRect(); const dx = first.left – last.left; const dy = first.top – last.top; item.style.transform = `translate(${dx}px, ${dy}px)`; item.style.transition = ‘none’; requestAnimationFrame(() => { item.style.transform = ”; item.style.transition = ‘transform .4s ease’; }); }); }); } Step 6: Mobile-friendly interactions Phones need a bit of extra care. Here are the tweaks that make a real difference: Make filter buttons at least 44 by 44 pixels to meet touch target guidelines. Allow horizontal scroll on the filter bar with overflow-x: auto and scroll-snap-type: x mandatory. Use loading=”lazy” on images to save bandwidth. Add decoding=”async” so the browser can decode images off the main thread. .filters { display: flex; gap: 8px; overflow-x: auto; scroll-snap-type: x mandatory; padding-bottom: 8px; } .filter-btn { scroll-snap-align: start; min-height: 44px; } Comparison with common approaches Approach Weight Multi-tag Sort Animation W3Schools portfolio Light Limited No Basic Isotope.js Heavy Yes Yes Advanced This tutorial Zero deps Yes Yes FLIP smooth Bonus: combining filter and sort Because filtering and sorting are separate functions that both operate on the same node list, combining them is a one-liner: function refresh() { applyFilter(currentFilter); applySort(sortSelect.value); } Call refresh() whenever the user changes either control. Accessibility checklist Use real <button> elements for filters, not divs. Add aria-pressed on the active filter button. Provide meaningful alt text on every image. Ensure focus outlines remain visible on keyboard navigation. FAQ Do I need a library like Isotope or MixItUp? No. For most portfolios and product grids, vanilla JavaScript with CSS Grid is enough and ships zero kilobytes of dependencies. mozilla.org has a solid rundown on this. How do I support multiple filters at once? Turn filter buttons into toggles and keep an array of active filters. Then in applyFilter check whether the item’s tags include every selected filter. Can I load images dynamically from an API? Yes. Fetch the JSON, build the figure elements with the same data-tags, data-date and data-title attributes,

How to Create a Sortable and Filterable Image Gallery With JavaScript Read More »

How to Add Hover Effects to Buttons and Images With CSS (10 Examples)

Hover effects are one of the fastest ways to make a website feel alive. A subtle color shift, a smooth zoom, or a floating shadow can turn a static page into an interactive experience. The best part? You don’t need JavaScript or heavy libraries to build them. Plain CSS hover effects using :hover, transition, and transform are all you need. In this tutorial, we’ll walk through 10 practical examples you can copy, paste, and tweak for your own projects. Whether you’re building buttons, image galleries, or product cards, you’ll find something here to use right away. What Is a CSS Hover Effect? A hover effect is a visual change that happens when a user moves their mouse pointer over an element. In CSS, this is done with the :hover pseudo-class, which targets an element only while the pointer is on top of it. Here’s the simplest possible example: button:hover { background-color: #2563eb; color: white; } That’s it. When the user hovers over the button, the background and text color change instantly. To make the change smooth instead of abrupt, we add a transition. The 3 Building Blocks You Need to Know :hover – the pseudo-class that triggers styles on mouse over. transition – controls the speed and easing of the change. transform – lets you scale, rotate, translate, or skew elements without affecting layout. Combine these three and you can build almost any hover effect you see on the web. 10 CSS Hover Effects With Code Snippets 1. Smooth Color Fade Button The classic. Great for primary call-to-action buttons. .btn-fade { background: #6366f1; color: #fff; padding: 12px 28px; border: none; border-radius: 6px; cursor: pointer; transition: background 0.3s ease; } .btn-fade:hover { background: #4338ca; } 2. Lift-Up Button With Shadow Adds depth and makes the button feel clickable. .btn-lift { background: #10b981; color: #fff; padding: 12px 28px; border: none; border-radius: 6px; cursor: pointer; transition: transform 0.25s ease, box-shadow 0.25s ease; } .btn-lift:hover { transform: translateY(-3px); box-shadow: 0 10px 20px rgba(0,0,0,0.15); } 3. Border Slide-In Button A minimalist effect where a border animates in from one side. Source: https://w3schools.com. .btn-border { position: relative; background: transparent; color: #111; padding: 12px 28px; border: 2px solid #111; overflow: hidden; cursor: pointer; z-index: 1; } .btn-border::before { content: “”; position: absolute; top: 0; left: -100%; width: 100%; height: 100%; background: #111; transition: left 0.3s ease; z-index: -1; } .btn-border:hover { color: #fff; } .btn-border:hover::before { left: 0; } 4. Gradient Shift Button A modern effect using a background gradient that moves on hover. .btn-gradient { background: linear-gradient(90deg, #f472b6, #8b5cf6, #f472b6); background-size: 200% 100%; background-position: 0% 0%; color: #fff; padding: 12px 28px; border: none; border-radius: 30px; cursor: pointer; transition: background-position 0.5s ease; } .btn-gradient:hover { background-position: 100% 0%; } 5. Image Zoom Effect Ideal for galleries, portfolios, and product thumbnails. .img-zoom { overflow: hidden; border-radius: 8px; } .img-zoom img { display: block; width: 100%; transition: transform 0.5s ease; } .img-zoom:hover img { transform: scale(1.1); } Important: the overflow: hidden on the parent is what stops the image from spilling out when it scales. 6. Image Grayscale to Color Great for team member photos or partner logos. .img-gray { filter: grayscale(100%); transition: filter 0.4s ease; } .img-gray:hover { filter: grayscale(0%); } 7. Image Overlay With Text Reveal A caption slides in over the image on hover. .img-overlay { position: relative; overflow: hidden; border-radius: 8px; } .img-overlay img { display: block; width: 100%; } .img-overlay .caption { position: absolute; inset: 0; background: rgba(0,0,0,0.6); color: #fff; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.35s ease; } .img-overlay:hover .caption { opacity: 1; } 8. Card Tilt Effect A subtle 3D tilt gives cards a playful feel. .card-tilt { background: #fff; padding: 24px; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); transition: transform 0.4s ease, box-shadow 0.4s ease; } .card-tilt:hover { transform: perspective(600px) rotateX(4deg) rotateY(-4deg) translateY(-4px); box-shadow: 0 20px 40px rgba(0,0,0,0.12); } 9. Underline Grow Link A clean alternative to the default underline on text links. .link-grow { position: relative; color: #111; text-decoration: none; } .link-grow::after { content: “”; position: absolute; left: 0; bottom: -3px; width: 100%; height: 2px; background: currentColor; transform: scaleX(0); transform-origin: right; transition: transform 0.3s ease; } .link-grow:hover::after { transform: scaleX(1); transform-origin: left; } 10. Glowing Neon Button Perfect for dark backgrounds and gaming or tech themes. .btn-neon { background: transparent; color: #22d3ee; padding: 12px 28px; border: 2px solid #22d3ee; border-radius: 6px; cursor: pointer; transition: box-shadow 0.3s ease, color 0.3s ease, background 0.3s ease; } .btn-neon:hover { background: #22d3ee; color: #0f172a; box-shadow: 0 0 8px #22d3ee, 0 0 20px #22d3ee, 0 0 40px #22d3ee; } Understanding the Transition Property The transition shorthand takes four values in this order: Value Purpose Example property Which CSS property to animate background, transform, all duration How long the animation lasts 0.3s, 500ms timing-function The easing curve ease, linear, ease-in-out delay Wait time before starting 0s, 200ms For most hover effects, a duration between 0.2s and 0.4s with ease or ease-out feels natural. Anything longer than 0.5s starts to feel sluggish. Best Practices for Hover Effects Always add a transition. Instant style changes feel jarring. Animate transform and opacity, not width or height. These properties are GPU-accelerated and much smoother. Keep durations short. Users hover fast, so keep effects snappy. Provide a focus state too. Add :focus-visible alongside :hover so keyboard users get the same feedback. Respect reduced motion. Wrap large animations in a media query for accessibility: @media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } } Mobile Considerations Touch devices don’t have a real hover state. When a user taps an element on mobile, the hover style may stick until they tap elsewhere. To avoid this, you can use the hover media feature: @media (hover: hover) { .btn-lift:hover { transform: translateY(-3px); } } This ensures hover styles only apply on devices that actually support hovering. FAQ Can I use CSS hover effects on any HTML element? Yes. The :hover pseudo-class works on virtually all elements, not just links and buttons. You can apply it to

How to Add Hover Effects to Buttons and Images With CSS (10 Examples) Read More »

How to Add a Countdown Timer to a Website With HTML, CSS, and JavaScript

A well-placed countdown timer can boost conversions on product launch pages, add urgency to promotional offers, and keep event attendees engaged. The good news? You don’t need a plugin or a third-party library to build one. With a bit of HTML, CSS, and vanilla JavaScript, you can ship a clean, brand-matching timer in under 15 minutes. In this tutorial, we’ll walk through building a fully functional countdown timer in JavaScript from scratch, then cover styling tips, edge cases, and optimization strategies that most tutorials skip. What You’ll Build By the end of this guide, you’ll have a responsive countdown timer that: There’s a fuller breakdown if you want the detail. Displays days, hours, minutes, and seconds Updates in real time every second Shows a custom message when the timer hits zero Handles timezone consistency Is fully customizable to match any brand Step 1: The HTML Structure Start with a simple, semantic structure. Each unit of time gets its own container so we can style them independently. <div class=”countdown” id=”countdown”> <div class=”unit”> <span class=”number” id=”days”>00</span> <span class=”label”>Days</span> </div> <div class=”unit”> <span class=”number” id=”hours”>00</span> <span class=”label”>Hours</span> </div> <div class=”unit”> <span class=”number” id=”minutes”>00</span> <span class=”label”>Minutes</span> </div> <div class=”unit”> <span class=”number” id=”seconds”>00</span> <span class=”label”>Seconds</span> </div> </div> <p class=”message” id=”message”></p> Step 2: Style It With CSS Here’s a clean, modern base you can easily adapt. Change the colors, radius, and font to align with your brand identity. .countdown { display: flex; gap: 1rem; justify-content: center; font-family: ‘Inter’, sans-serif; } .unit { background: #111; color: #fff; padding: 1.5rem 1.25rem; border-radius: 12px; min-width: 90px; text-align: center; box-shadow: 0 4px 12px rgba(0,0,0,0.1); } .number { display: block; font-size: 2.5rem; font-weight: 700; line-height: 1; } .label { display: block; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.1em; margin-top: 0.5rem; opacity: 0.7; } .message { text-align: center; font-size: 1.25rem; font-weight: 600; margin-top: 1rem; } Step 3: The JavaScript Logic This is where the magic happens. We calculate the difference between the target date and the current time, then break it down into days, hours, minutes, and seconds. // Set your target date (ISO format recommended) const targetDate = new Date(‘2026-12-31T23:59:59’).getTime(); const daysEl = document.getElementById(‘days’); const hoursEl = document.getElementById(‘hours’); const minutesEl = document.getElementById(‘minutes’); const secondsEl = document.getElementById(‘seconds’); const messageEl = document.getElementById(‘message’); const countdownEl = document.getElementById(‘countdown’); function pad(num) { return String(num).padStart(2, ‘0’); } function updateCountdown() { const now = new Date().getTime(); const distance = targetDate – now; if (distance < 0) { clearInterval(timer); countdownEl.style.display = ‘none’; messageEl.textContent = “We’re live!”; return; } const days = Math.floor(distance / (1000 * 60 * 60 * 24)); const hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60)); const seconds = Math.floor((distance % (1000 * 60)) / 1000); daysEl.textContent = pad(days); hoursEl.textContent = pad(hours); minutesEl.textContent = pad(minutes); secondsEl.textContent = pad(seconds); } updateCountdown(); const timer = setInterval(updateCountdown, 1000); That’s the entire timer. Under 30 lines of readable code, no dependencies, no bloat. How the Code Works Target date: We convert the end date to a Unix timestamp using getTime(). Distance calculation: Subtracting the current time from the target gives us milliseconds remaining. Time unit extraction: Using modulo and division, we split milliseconds into days, hours, minutes, and seconds. Padding: The pad() helper ensures single digits show as “05” instead of “5” for a cleaner look. Interval: setInterval re-runs the update every second. When the timer expires, we clear the interval to save resources. Common Pitfalls and How to Fix Them Pitfall Solution Timer shows wrong time for users in other timezones Use UTC or ISO date strings with timezone offset (e.g., 2026-12-31T23:59:59Z) Timer drifts when tab is inactive Recalculate from the target date each tick instead of decrementing counters Flicker on first load Call updateCountdown() once immediately, before starting the interval Memory leak on SPA navigation Always call clearInterval() when the component unmounts Making It Match Your Brand The base timer is intentionally minimal so you can adapt it. Here are quick brand-matching tweaks: Rounded pill style: Change border-radius to 999px and adjust padding. Gradient backgrounds: Replace background: #111 with linear-gradient(135deg, #667eea, #764ba2). Flip animation: Add a CSS keyframe on number change to create a mechanical flip effect. Custom typography: Load your brand’s display font via @font-face or Google Fonts. Compact mode: Hide days and only show hours/minutes/seconds for flash sales under 24 hours. Use Cases Where This Timer Shines Product Launches Place the timer above the fold on your landing page. Combine it with an email capture form so visitors get notified the second the product goes live. Limited-Time Promotions Flash sales convert better with visible urgency. A timer that counts down to the end of a discount period consistently outperforms static “sale ends soon” banners. Event Registration Pages Webinars, conferences, and product demos benefit from a persistent countdown that tells attendees exactly when the event starts. Coming Soon Pages Building hype before a launch? A full-screen countdown paired with an email signup can gather leads long before you have a finished product. There’s a good explainer over at w3schools.com. Bonus: A Reusable Countdown Function If you need multiple timers on the same page, wrap the logic in a reusable factory function: function createCountdown(selector, targetDateStr) { const root = document.querySelector(selector); const target = new Date(targetDateStr).getTime(); const tick = () => { const distance = target – Date.now(); if (distance < 0) { root.innerHTML = ‘<strong>Time is up!</strong>’; clearInterval(id); return; } const d = Math.floor(distance / 86400000); const h = Math.floor((distance % 86400000) / 3600000); const m = Math.floor((distance % 3600000) / 60000); const s = Math.floor((distance % 60000) / 1000); root.textContent = `${d}d ${h}h ${m}m ${s}s`; }; tick(); const id = setInterval(tick, 1000); return () => clearInterval(id); } // Usage: createCountdown(‘#launch-timer’, ‘2026-09-01T10:00:00Z’); Performance Tips Use requestAnimationFrame instead of setInterval only if you need sub-second precision. For standard countdowns, setInterval is more than enough and easier on the CPU. Avoid updating the DOM if the value hasn’t changed. Cache the previous seconds value and skip the

How to Add a Countdown Timer to a Website With HTML, CSS, and JavaScript Read More »

How to Add a Live Chat Widget to a Website Without Slowing It Down

Live chat is one of the fastest ways to turn visitors into customers, but most chat widgets ship 200KB to 500KB of JavaScript that fires on every page load. That is a direct hit to your Largest Contentful Paint, Interaction to Next Paint and overall Core Web Vitals scores. In this guide, we compare three lightweight live chat solutions (Tawk.to, Crisp and Tidio) and show you exactly how to add live chat to a website using lazy loading, so your visitors get the feature without the performance penalty. Why the default install method hurts your website Most vendors give you a copy-paste snippet that loads their SDK synchronously in the <head>. Even with async, the browser still has to parse, compile and execute a large bundle before the widget shows up. On mobile 4G connections, this can add 1 to 3 seconds to your Time to Interactive. sinch.com has a solid rundown on this. The good news: nobody clicks a chat bubble in the first 100 milliseconds of a page load. That means we can safely defer loading until the user actually needs it. Comparing lightweight live chat tools Here is a practical comparison of the three most efficient options in 2026: Feature Tawk.to Crisp Tidio Free plan Yes, unlimited agents Yes, 2 seats Yes, limited chats Widget size (gzipped) ~90KB ~110KB ~180KB AI chatbot included Paid add-on Paid plan Yes, Lyro AI Self-hosted option No No No Best for Budget teams, agencies SaaS, developer-friendly E-commerce, Shopify Our take Tawk.to is the lightest and fully free, best if you just need a chat channel. Crisp has the cleanest API and a modern SDK, ideal for SaaS products. Tidio shines on e-commerce with built-in AI, but ships the heaviest bundle so lazy loading is a must. The lazy-loading strategy Instead of loading the vendor script on page load, we replace the chat widget with a lightweight placeholder button (about 2KB of HTML and CSS). The real script only loads when: The user hovers or clicks the placeholder button, or The browser goes idle after the page has fully loaded, or The user scrolls past a certain threshold. This gives you the best of both worlds: zero impact on initial load and a chat widget that appears instantly when needed. Step 1: Create a fake chat button <button id=”chat-launcher” aria-label=”Open chat”> <svg width=”24″ height=”24″ viewBox=”0 0 24 24″ fill=”white”> <path d=”M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z”/> </svg> </button> <style> #chat-launcher { position: fixed; bottom: 20px; right: 20px; width: 60px; height: 60px; border-radius: 50%; background: #0084ff; border: none; cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 9999; } </style> Step 2: Lazy-load Tawk.to on interaction <script> (function() { var loaded = false; function loadTawk() { if (loaded) return; loaded = true; document.getElementById(‘chat-launcher’).style.display = ‘none’; var s = document.createElement(‘script’); s.async = true; s.src = ‘https://embed.tawk.to/YOUR_PROPERTY_ID/default’; s.charset = ‘UTF-8’; s.setAttribute(‘crossorigin’, ‘*’); document.head.appendChild(s); s.onload = function() { if (window.Tawk_API && window.Tawk_API.maximize) { window.Tawk_API.maximize(); } }; } var btn = document.getElementById(‘chat-launcher’); btn.addEventListener(‘mouseenter’, loadTawk, { once: true }); btn.addEventListener(‘click’, loadTawk, { once: true }); // Fallback: load when browser is idle if (‘requestIdleCallback’ in window) { requestIdleCallback(loadTawk, { timeout: 8000 }); } else { setTimeout(loadTawk, 6000); } })(); </script> Step 3: The same pattern for Crisp <script> function loadCrisp() { window.$crisp = []; window.CRISP_WEBSITE_ID = “YOUR_WEBSITE_ID”; var d = document, s = d.createElement(“script”); s.src = “https://client.crisp.chat/l.js”; s.async = 1; d.getElementsByTagName(“head”)[0].appendChild(s); } document.getElementById(‘chat-launcher’) .addEventListener(‘click’, loadCrisp, { once: true }); </script> Step 4: The same pattern for Tidio <script> function loadTidio() { var s = document.createElement(‘script’); s.src = ‘//code.tidio.co/YOUR_PUBLIC_KEY.js’; s.async = true; document.body.appendChild(s); } document.getElementById(‘chat-launcher’) .addEventListener(‘click’, loadTidio, { once: true }); </script> Measuring the impact Before and after our lazy-loading approach on a real client site running Tidio: This guide goes deeper on it. Metric Default install Lazy-loaded LCP 3.4s 1.8s Total Blocking Time 640ms 90ms JS transferred on load 312KB 2KB PageSpeed score (mobile) 62 94 Extra performance tips Add <link rel=”preconnect” href=”https://embed.tawk.to”> so the DNS handshake is ready when the user clicks. Never load chat on transactional pages like checkout unless it is critical for conversion. Skip loading the widget for bots by checking navigator.userAgent. This keeps Lighthouse audits clean. If you use a consent banner, gate the chat loader behind user consent to stay GDPR-compliant. FAQ Does lazy loading hurt chat conversion rates? No. In our tests, conversion actually improved because pages load faster, bounce rates drop, and the chat button still appears instantly thanks to the CSS placeholder. Which live chat is best for a small business in 2026? If budget is your priority, Tawk.to remains unbeatable with unlimited free agents. For a modern feel and better developer tools, Crisp is worth its price. For online stores, Tidio with Lyro AI handles most tier-1 questions automatically. Can I use this method with WordPress? Yes. Paste the placeholder button and lazy-loading script into your theme footer or use a plugin like WPCode to inject it. Avoid using the official Tawk.to or Tidio plugins, which load the script immediately. Will Google penalize me for loading third-party scripts? Google does not penalize scripts directly, but Core Web Vitals affect ranking. A heavy synchronous chat widget can push your LCP and INP into the red. Lazy loading solves this cleanly. Should I use a self-hosted open-source chat instead? Solutions like Chatwoot or Papercups are great if you have DevOps resources. For most teams, a lazy-loaded SaaS widget delivers 95% of the value with none of the maintenance. There’s a fuller breakdown if you want the detail. Wrapping up Adding live chat to your website should not cost you a full second of load time. By deferring the vendor SDK behind a lightweight placeholder and loading it on interaction or during idle time, you keep your Core Web Vitals green while still giving customers a fast way to reach you. Pick the tool that matches your use case, drop in the snippet above, and measure the difference in your next Lighthouse audit.

How to Add a Live Chat Widget to a Website Without Slowing It Down Read More »

How to Add a Favicon to a WordPress Website (3 Methods Explained)

A favicon is that tiny icon displayed in your browser tab, bookmarks bar and search results next to your site name. It’s a small detail, but it plays a big role in branding, trust and recognition. In this practical guide, we’ll walk you through three proven methods to add a favicon to WordPress, cover the correct sizes and formats, and show you how to verify everything works properly. What Is a Favicon and Why Does It Matter? A favicon (short for “favorite icon”) is a small square image that represents your website across browsers and devices. In WordPress, it’s officially called a Site Icon. A well-designed favicon helps visitors: Identify your site among dozens of open tabs Recognize your brand in bookmarks and history Trust your website as a professional entity Favicon Requirements: Size and Format Before uploading your favicon, make sure it meets WordPress requirements. Here’s a quick reference table: Attribute Recommended Value Minimum Size 512 x 512 pixels Shape Square (1:1 ratio) Accepted Formats .png, .jpg, .ico, .gif Best Format PNG (transparent background) Max File Size Under 100 KB Tip: If you don’t have a favicon yet, you can create one using a free tool like Favicon.io, Canva, or RealFaviconGenerator. Method 1: Add a Favicon via the WordPress Customizer (Easiest) This is the native WordPress method and works with almost every theme. No code, no plugins. Log in to your WordPress admin dashboard. Go to Appearance > Customize. Click on Site Identity. Scroll down to the Site Icon section. Click Select site icon and upload your image (or choose one from the Media Library). Crop the image if prompted, then click Publish to save your changes. If you’re using a block theme (WordPress 6.0 and above), the path may be slightly different: Go to Appearance > Editor Select Styles or Settings > Site Identity Click Site Icon and upload Method 2: Add a Favicon Manually Through Theme Files Want full control? You can add the favicon directly to your theme’s header.php file. This method is ideal for developers or for adding multiple icon variants (Apple Touch Icon, Android Chrome icon, etc.). Step 1: Upload Your Favicon Files Using FTP or your hosting file manager, upload your favicon files (for example favicon.ico, apple-touch-icon.png) to your theme’s root folder: /wp-content/themes/your-theme/ Step 2: Edit header.php Open header.php and add the following lines inside the <head> tag: <link rel=”icon” type=”image/png” href=”<?php echo get_stylesheet_directory_uri(); ?>/favicon.png”> <link rel=”apple-touch-icon” href=”<?php echo get_stylesheet_directory_uri(); ?>/apple-touch-icon.png”> <link rel=”shortcut icon” href=”<?php echo get_stylesheet_directory_uri(); ?>/favicon.ico”> Important: Always use a child theme when editing theme files. Otherwise, your changes will be lost the next time your theme updates. Step 3: Save and Clear Cache Save the file, clear any caching plugins, and refresh your site. Method 3: Add a Favicon Using a Plugin If you’re not comfortable editing files and want more control than the Customizer offers, a plugin is a great middle-ground option. Recommended Plugins RealFaviconGenerator – generates all icon variants for every device Heroic Favicon Generator – simple and beginner-friendly Favicon by RealFaviconGenerator – popular and well maintained Installation Steps Go to Plugins > Add New. Search for your favorite favicon plugin. Click Install Now, then Activate. Navigate to Appearance > Favicon (or the plugin’s settings page). Upload your master image (at least 512×512 px). Customize the appearance for iOS, Android, Windows and macOS. Click Generate favicon and let the plugin handle the rest. How to Verify Your Favicon Is Working Once your favicon is added, you’ll want to make sure it appears correctly: Open your site in an incognito window to bypass browser cache. Check the browser tab. Your icon should appear next to the page title. Bookmark the page and verify the icon shows in your bookmarks bar. Test on multiple browsers: Chrome, Firefox, Safari, Edge. Check on mobile by adding your site to the home screen. Use realfavicongenerator.net/favicon_checker for a full audit. Common Issues and Fixes Favicon Not Showing Up? Clear your browser cache – old icons are often cached for days. Clear your WordPress cache (WP Rocket, W3 Total Cache, etc.). Check the file path – make sure the URL to the favicon is correct. Test in incognito mode to bypass all cache layers. Confirm the file exists by visiting yourdomain.com/favicon.ico directly. Favicon Not Updating? This is almost always a cache issue. Force a refresh by adding a version parameter to your favicon URL, for example: favicon.png?v=2 Which Method Should You Choose? Method Best For Difficulty Customizer Most users, quick setup Easy Theme Files Developers, custom setups Advanced Plugin Multi-device optimization Easy FAQ Why is my favicon not showing up in WordPress? The most common reason is browser or plugin caching. Clear your cache, test in incognito mode, and make sure the favicon file actually exists at the expected URL. Where is the favicon in WordPress? WordPress calls it the Site Icon. You’ll find it under Appearance > Customize > Site Identity, or in the block-based Editor under Styles > Site Identity. How do I insert a favicon? Upload a square image (at least 512×512 px) to the Site Icon section of the WordPress Customizer, or add it manually via header.php, or use a dedicated plugin. How do I add a favicon in WooCommerce? WooCommerce uses the same favicon as your WordPress installation. Simply set your Site Icon via Appearance > Customize > Site Identity and it will appear across all WooCommerce pages. What is the ideal favicon size for WordPress? WordPress requires a minimum of 512 x 512 pixels and will automatically generate smaller versions (16×16, 32×32, 180×180) for different devices. Can I change the favicon without a plugin? Yes. The WordPress Customizer allows you to change the Site Icon without any plugin or code. It’s the recommended method for most users. Conclusion Adding a favicon to WordPress is a small task with a big visual impact. Whether you prefer the built-in Customizer, hands-on theme file editing, or the flexibility of a plugin, you now have three reliable methods to choose from. Pick

How to Add a Favicon to a WordPress Website (3 Methods Explained) 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.