Author name: Carrie Carlson

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 »

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 »

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.