September 2026

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 »

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.