Author name: Carrie Carlson

How to Design a Squeeze Page That Captures Emails (Structure, Copy, and CSS Layout)

A squeeze page is a single-purpose landing page built to do exactly one thing: collect an email address. No navigation bar, no pricing table, no blog links, no footer sitemap. One offer, one form, one decision. Most articles on squeeze page design show you screenshots and tell you to “keep it simple.” This guide does something different: it walks through the page one element at a time, explains the conversion logic behind each decision, and ends with a complete HTML and CSS layout you can copy, paste and ship today, plus the Express route that receives the submission. What is a squeeze page (and how it differs from a landing page) A squeeze page is a subset of landing pages. Every squeeze page is a landing page, but not every landing page is a squeeze page. The difference is the conversion goal and the amount of information requested. Aspect Squeeze page Standard landing page Goal Capture an email address Demo request, purchase, trial, booking Form fields 1 (sometimes 2) 4 to 10 Page length One screen, rarely more Long form, multiple sections Navigation Removed entirely Often reduced, sometimes kept Typical offer Checklist, template, mini-course, waitlist Product, service, webinar, consultation Success metric Opt-in rate Cost per lead, revenue per visitor Practical consequence: every design choice on a squeeze page is judged against a single question. Does this element make typing an email address more likely, or less? If the answer is “less” or “neutral,” delete it. The anatomy of a high-converting squeeze page Here is the full element stack, in the order a visitor processes it. Build in this order too. # Element Job it does 1 Eyebrow label Categorises the offer in two words (“Free download”) 2 Headline Promises one specific outcome 3 Subheadline Names the deliverable and who it is for 4 3 benefit bullets Makes the value concrete and scannable 5 Email field The only input on the page 6 CTA button Restates the reward, not the action 7 Privacy microcopy Removes the “will I get spammed” objection 8 Proof element Subscriber count, logo row or one short quote 9 Visual of the asset Makes an intangible file feel real 10 Thank-you page Confirms delivery and sets the next step 1. One offer, and only one The fastest way to kill a squeeze page is to offer a checklist and a newsletter and a webinar seat. Each extra option forces a comparison, and comparison costs attention. Pick one asset that solves a single, narrow problem your traffic already has. Narrow beats broad every time: Weak: “Subscribe to our developer newsletter” Strong: “The 12-point Node.js performance checklist we run before every deploy” The second version works because the visitor can picture the file, estimate the time cost of reading it, and judge the trade against one email address in about three seconds. 2. Remove the navigation A header menu on a squeeze page is an exit ramp. Every link competes with the form. Strip the following: Primary navigation and dropdowns Footer sitemap and category lists Sidebar widgets, recent posts, search box Social sharing icons that point away from the page Chat bubbles that cover the mobile CTA Keep only what the law and trust require: a logo (unlinked or linked to the same page), a privacy policy link and, if relevant, your cookie notice. If your legal team insists on a terms link, put it in small text under the form, not in a header. 3. Put the form above the fold Visitors should never scroll to find the input. On a 667px tall mobile viewport, the headline, the email field and the button must all be visible at once. That constraint shapes everything else: it is why benefit bullets are three lines and not eight, and why the hero image is small or absent on mobile. A reliable test: load the page on a phone, and if your thumb has to move before you can tap the field, the layout is too tall. 4. Ask for one field Every additional field reduces completion. First name feels harmless, but it adds a decision (“do I give my real name?”) and a second tap on mobile. If you need a name for personalisation, collect it on the thank-you page or in the first email, after commitment already exists. Field rules that matter more than people think: Use type=”email” so mobile keyboards show the @ key Add autocomplete=”email” so browsers autofill in one tap Keep the label present for screen readers, visually hidden if space is tight Validate on submit, not on every keystroke, and never with a red border before the user has finished typing Use a hidden honeypot field instead of a CAPTCHA, which can cost double-digit percentages of conversions 5. Write copy that reduces friction, not copy that sells harder On a squeeze page you are not selling a product, you are lowering the perceived cost of handing over an email. Friction comes in four flavours: effort, risk, doubt and delay. Each has a copy antidote. Friction Visitor thought Copy antidote Effort “How long will this take me?” “A 2-page checklist. Readable in 4 minutes.” Risk “Am I signing up for daily spam?” “One email. No sequence unless you ask for it.” Doubt “Is this actually any good?” “Used by 4,100 engineering teams” or one short quote Delay “When do I get it?” “Delivered instantly, no confirmation hoops” Headline formulas that hold up Outcome + timeframe: “Cut your API response time by 40% in one afternoon” Asset + audience: “The deployment checklist for teams shipping Node.js on Fridays” Problem removal: “Stop guessing which middleware is slowing your app down” Curiosity with specifics: “The 4 Express mistakes that add 200ms to every request” Button copy Never write “Submit.” The button should describe what the visitor receives, in first person or imperative, and should match the headline promise: “Send me the checklist” “Get the template” “Start lesson 1” Avoid “Sign up” and “Register.” Both imply ongoing obligation, which is

How to Design a Squeeze Page That Captures Emails (Structure, Copy, and CSS Layout) Read More »

How to Add a Mobile Hamburger Menu With HTML, CSS, and JavaScript (Accessible Version)

Most hamburger menu CSS snippets you find online look great in a demo and fall apart the moment someone uses a keyboard or a screen reader. The icon animates, the panel slides in, and then the user presses Tab and the focus disappears behind the overlay with no way out. This tutorial fixes that. We will build a hamburger menu from scratch: the toggle button markup, the three-bar icon in pure CSS, the slide-in panel, and the JavaScript that opens and closes it properly. Everything is accessible by default: aria-expanded, focus trapping, focus return, Escape key support and reduced-motion handling. What we are building A 44x44px toggle button that animates from three bars into an X. A slide-in panel from the right with a dimmed overlay behind it. A desktop layout where the button disappears and the links become a normal horizontal nav. Full keyboard support: Tab stays inside the open menu, Escape closes it, focus returns to the button. No framework, no dependency, around 60 lines of JavaScript. The accessibility checklist most tutorials skip Before writing a single line, here is what separates a decorative hamburger from a usable one. Requirement How we solve it The control must be announced as a button Use a real <button type=”button”>, not a div or a label hack State must be exposed aria-expanded=”false” toggled to true in JS Relationship between button and panel aria-controls=”primary-menu” The icon needs a name aria-label on the button, aria-hidden on the bars Keyboard users must not get lost Focus trap while open, focus returned to the toggle on close Escape should dismiss keydown listener on document Hidden content must really be hidden visibility: hidden on the closed panel, not just transform Motion sensitivity @media (prefers-reduced-motion: reduce) Touch target size Minimum 44×44 CSS pixels Step 1: The HTML markup Keep the structure flat and semantic. The button sits in the header, the panel is a <nav> with an id, and the overlay is a sibling element. <header class=”site-header”> <a class=”logo” href=”/”>Express</a> <button type=”button” class=”hamburger” id=”menu-toggle” aria-expanded=”false” aria-controls=”primary-menu” aria-label=”Open menu”> <span class=”hamburger__box” aria-hidden=”true”> <span class=”hamburger__bar”></span> <span class=”hamburger__bar”></span> <span class=”hamburger__bar”></span> </span> </button> <nav id=”primary-menu” class=”nav-panel” aria-label=”Main”> <ul> <li><a href=”/docs”>Docs</a></li> <li><a href=”/guides”>Guides</a></li> <li><a href=”/api”>API</a></li> <li><a href=”/blog”>Blog</a></li> <li><a href=”/contact”>Contact</a></li> </ul> </nav> </header> <div class=”nav-overlay” hidden></div> Why these attributes matter type=”button”: without it, a button inside a form submits the form. aria-expanded: this is the single most important attribute. A screen reader announces “Open menu, button, collapsed” and then “expanded” after the click. aria-hidden on the bars: the three spans are decoration. They must never be read out. aria-label on the nav: when a page has several nav landmarks, labels let users tell them apart. Step 2: The hamburger icon in pure CSS Three absolutely positioned bars inside a relative box. Opening the menu adds a class to <body>, which rotates bar 1 and 3 and fades out bar 2. :root { –nav-width: min(78vw, 20rem); –bar-color: #111827; –accent: #2563eb; } .site-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.75rem 1rem; position: relative; z-index: 40; } .hamburger { display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 44px; padding: 0; border: 0; border-radius: 8px; background: transparent; cursor: pointer; } .hamburger:focus-visible { outline: 3px solid var(–accent); outline-offset: 2px; } .hamburger__box { position: relative; display: block; width: 24px; height: 18px; } .hamburger__bar { position: absolute; left: 0; width: 100%; height: 2px; border-radius: 2px; background: var(–bar-color); transition: top 0.25s ease, transform 0.25s ease, opacity 0.15s ease; } .hamburger__bar:nth-child(1) { top: 0; } .hamburger__bar:nth-child(2) { top: 8px; } .hamburger__bar:nth-child(3) { top: 16px; } .nav-open .hamburger__bar:nth-child(1) { top: 8px; transform: rotate(45deg); } .nav-open .hamburger__bar:nth-child(2) { opacity: 0; } .nav-open .hamburger__bar:nth-child(3) { top: 8px; transform: rotate(-45deg); } Tip: the bars are 2px tall with 8px gaps, which keeps the icon readable at any device pixel ratio. Avoid using the &#9776; character as your icon: its rendering changes wildly between fonts and platforms. Step 3: The slide-in panel CSS The panel is fixed to the right edge and pushed off screen with translateX(100%). The key accessibility detail is visibility: hidden, which removes the closed links from the tab order and from the accessibility tree. A transform alone leaves them focusable off screen. .nav-panel { position: fixed; inset: 0 0 0 auto; width: var(–nav-width); padding: 5rem 1.5rem 2rem; background: #ffffff; box-shadow: -8px 0 24px rgba(15, 23, 42, 0.15); transform: translateX(100%); visibility: hidden; overflow-y: auto; z-index: 30; transition: transform 0.3s ease, visibility 0.3s; } .nav-panel ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.25rem; } .nav-panel a { display: block; padding: 0.85rem 0.5rem; font-size: 1.125rem; text-decoration: none; color: #111827; border-radius: 6px; } .nav-panel a:hover { background: #f3f4f6; } .nav-panel a:focus-visible { outline: 3px solid var(–accent); outline-offset: 2px; } .nav-open .nav-panel { transform: translateX(0); visibility: visible; } .nav-overlay { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.5); z-index: 25; } .nav-overlay[hidden] { display: none; } /* Stop the page behind from scrolling while the menu is open */ body.nav-open { overflow: hidden; } @media (prefers-reduced-motion: reduce) { .nav-panel, .hamburger__bar { transition: none; } } Desktop layout Above the breakpoint, hide the button and the overlay and turn the panel back into a plain horizontal list. Everything is reset so that no mobile state can leak into the desktop view. @media (min-width: 48em) { .hamburger, .nav-overlay { display: none; } .nav-panel { position: static; width: auto; padding: 0; background: none; box-shadow: none; transform: none; visibility: visible; overflow: visible; } .nav-panel ul { display: flex; gap: 1.5rem; } body.nav-open { overflow: auto; } } Step 4: The JavaScript (toggle, Escape, focus trap) This is the part copy-paste snippets usually reduce to a single classList.toggle(). Here is the complete version. const toggle = document.getElementById(“menu-toggle”); const panel = document.getElementById(“primary-menu”); const overlay = document.querySelector(“.nav-overlay”); const desktop = window.matchMedia(“(min-width: 48em)”); const FOCUSABLE = ‘a[href], button:not([disabled]), input, select, textarea, [tabindex]:not([tabindex=”-1″])’; function isOpen() { return toggle.getAttribute(“aria-expanded”) === “true”; } function openMenu() { document.body.classList.add(“nav-open”); toggle.setAttribute(“aria-expanded”, “true”); toggle.setAttribute(“aria-label”, “Close menu”); overlay.hidden = false; const first = panel.querySelector(FOCUSABLE); if (first) first.focus(); document.addEventListener(“keydown”, onKeydown); } function closeMenu(returnFocus) {

How to Add a Mobile Hamburger Menu With HTML, CSS, and JavaScript (Accessible Version) Read More »

How to Add a Custom Favicon and App Icons to a Website (HTML Code for Every Device)

Adding a favicon should take two minutes, yet most tutorials still hand you a wall of fifteen <link> tags copied from 2014. Half of those tags target browsers that no longer exist. This guide gives you one copy-paste favicon HTML code snippet, a table of the file sizes that are actually required in 2026, and the Express configuration to serve them correctly. The short answer: the favicon HTML code you need Place this inside the <head> of every page (or in your layout template). Four lines, four files, every modern browser and device covered. <!– Classic favicon for browser tabs and bookmarks –> <link rel="icon" href="/favicon.ico" sizes="32×32"> <!– Modern scalable icon (supports dark mode) –> <link rel="icon" href="/icon.svg" type="image/svg+xml"> <!– iOS / iPadOS home screen –> <link rel="apple-touch-icon" href="/apple-touch-icon.png"> <!– Android, Chrome, PWA install prompts –> <link rel="manifest" href="/manifest.webmanifest"> Three things to check before you move on: The tags must be inside <head>, not <body>. Use absolute paths starting with a slash (/favicon.ico), not favicon.ico. Relative paths break on nested URLs such as /blog/post/. The four files live at the root of your public directory, so they resolve at https://example.com/favicon.ico. What each line actually does 1. favicon.ico The ICO file is still the most reliable fallback. Browsers request /favicon.ico automatically even if you never declare it, and some crawlers, RSS readers, feed aggregators and email clients only look for that file. Keep it as a multi-resolution ICO containing 16×16, 32×32 and 48×48 layers. The sizes="32×32" hint tells Chrome and Firefox which layer to prefer, which avoids blurry upscaled tabs on high-DPI screens. 2. icon.svg An SVG favicon scales perfectly on every pixel density and supports a dark mode variant inside the file itself. Chrome, Edge, Firefox, Opera and Safari all support it. Browsers that do not will silently fall back to the ICO, which is why both lines belong in your snippet. 3. apple-touch-icon.png This is the icon iOS uses when a visitor taps Add to Home Screen. One 180×180 PNG is enough. iOS downscales it for smaller devices. Do not add transparency: iOS fills transparent pixels with black. Use a solid background and keep the corners square, because the system applies its own rounding. 4. manifest.webmanifest Android and Chromium-based browsers read the manifest instead of <link> tags for home screen and install icons. This is also what makes your site installable as a PWA. Favicon sizes: the only files you need in 2026 File Size Format Used by Required? favicon.ico 16×16, 32×32, 48×48 in one file ICO Browser tabs, bookmarks, history, crawlers, feed readers Yes icon.svg Any (vector), design on a 32×32 grid SVG All modern desktop and mobile browsers, dark mode Recommended apple-touch-icon.png 180×180 PNG (no alpha) iOS and iPadOS home screen, Safari shared links Yes icon-192.png 192×192 PNG Android home screen, manifest Yes icon-512.png 512×512 PNG PWA splash screens, app listings, install dialog Yes icon-maskable-512.png 512×512 with 20% safe padding PNG Android adaptive icons (circle, squircle, teardrop masks) Optional but strongly advised for PWAs That is six files maximum, and realistically four if you are not shipping a PWA. Anything beyond this list is legacy baggage. The web app manifest Create manifest.webmanifest at the root of your public folder: { "name": "Your Site Name", "short_name": "YourSite", "start_url": "/", "display": "standalone", "background_color": "#ffffff", "theme_color": "#0b5fff", "icons": [ { "src": "/icon-192.png", "type": "image/png", "sizes": "192×192" }, { "src": "/icon-512.png", "type": "image/png", "sizes": "512×512" }, { "src": "/icon-maskable-512.png", "type": "image/png", "sizes": "512×512", "purpose": "maskable" } ] } Notes that save debugging time: The manifest must be served with the application/manifest+json MIME type. Express does this automatically for the .webmanifest extension in recent versions of mime-db, but check your response headers. If your manifest sits on another origin or you fetch it with credentials, add crossorigin="use-credentials" to the link tag. A maskable icon needs its artwork inside the central 80% circle, otherwise Android will crop your logo. Dark mode favicons the clean way You do not need extra HTML. Put a media query inside the SVG file itself, and the icon reacts to the operating system theme: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> <style> circle { fill: #0b1220; } @media (prefers-color-scheme: dark) { circle { fill: #ffffff; } } </style> <circle cx="16" cy="16" r="14"/> </svg> Keep SVG favicons extremely simple. At 16 pixels, gradients, thin strokes and text turn into mush. One shape, one or two colors, high contrast. Tags you can delete in 2026 If your <head> contains any of the following, remove them. They add bytes and parsing work for zero benefit. Legacy tag or file Why it is obsolete rel="shortcut icon" Invalid keyword kept alive only for Internet Explorer. rel="icon" is the standard. rel="mask-icon" (Safari pinned tab SVG) Deprecated since Safari 12. Safari now uses your regular icons. msapplication-TileImage, browserconfig.xml Windows 8 and 10 live tiles no longer exist. The 10 apple-touch-icon sizes (57, 60, 72, 76, 114, 120, 144, 152 and so on) iOS downscales a single 180×180 PNG perfectly. apple-touch-icon-precomposed iOS stopped applying gloss effects years ago. android-chrome-192×192.png declared with a link tag Android reads the manifest, not link tags. Serving your favicon correctly with Express Favicon problems are very often server problems, not HTML problems. In an Express app, put the icon files in your static directory root: const express = require(‘express’); const path = require(‘path’); const app = express(); app.use(express.static(path.join(__dirname, ‘public’), { maxAge: ’30d’, etag: true })); app.listen(3000); With public/favicon.ico, public/icon.svg, public/apple-touch-icon.png, public/icon-192.png, public/icon-512.png and public/manifest.webmanifest in place, every path in the snippet resolves. (via https://coddy.tech) Avoiding the 404 flood in your logs Browsers request /favicon.ico on their own. If the file is missing, your logs fill with 404 lines and your error handler may be triggered on every page view. Two options: Ship the file. The simplest and best fix. Return a 204 explicitly while you are still designing the icon: app.get(‘/favicon.ico’, (req, res) => res.status(204).end()); Register that route before your catch-all handlers so it is not swallowed by a wildcard route. Caching strategy Give favicon.ico and icon.svg a moderate cache (a

How to Add a Custom Favicon and App Icons to a Website (HTML Code for Every Device) Read More »

How to Create a Custom WordPress Login Page Without a Plugin (Code Guide)

Every agency hits the same moment: the site is beautiful, the dashboard is clean, and then the client logs in and sees a plain blue-grey screen with the WordPress logo on top. If you want a genuinely white-labeled handover, you need to customize the WordPress login page itself. Most tutorials answer that with “install a login customizer plugin”. That works, but it also means one more plugin to update, one more vendor to trust, and one more thing that can break on a client site you maintain. This guide takes the other route: a few well-chosen hooks and a single CSS file, deployed the way a professional team would deploy it. The reasoning is set out in this piece. What you will build By the end of this tutorial, the wp-login.php screen for your client will have: The client logo instead of the WordPress logo, linking to the client’s homepage A branded background (image, gradient or solid colour) Restyled form fields, labels and submit button matching the brand A custom welcome message above the form Generic error messages that do not leak whether a username exists A role-based redirect after login (editors to Posts, clients to the dashboard, etc.) A discreet agency credit in the footer, if you want one Total: roughly 60 lines of PHP and 70 lines of CSS. No plugin. Before you start: never edit wp-login.php This is the single most important rule. The file wp-login.php lives in the WordPress core directory and is overwritten on every core update. Any change you make there disappears silently, usually at the worst possible moment. WordPress exposes a full set of hooks specifically so you never have to touch it. Use those instead. Where should the code live? Location Best for Survives theme switch? Child theme functions.php One-off sites, branding tied to the theme No Custom plugin in /wp-content/plugins/ Reusable agency boilerplate Yes (can be deactivated) Must-use plugin in /wp-content/mu-plugins/ White-label work the client should not switch off Yes (cannot be deactivated from admin) Our recommendation for agency work: the mu-plugin. The branding stays in place even if the client swaps the theme or a junior dev deactivates something, and it keeps the login logic separate from presentation code. Create /wp-content/mu-plugins/client-login-branding.php and start with this header: <?php /** * Plugin Name: Client White Label Login * Description: Rebrands the wp-login.php screen for this client. * Version: 1.0.0 * Author: Your Agency */ if ( ! defined( ‘ABSPATH’ ) ) { exit; } If you prefer the child theme route, drop everything below into functions.php instead and swap plugin_dir_url( __FILE__ ) for get_stylesheet_directory_uri(). Step 1: Load a dedicated login stylesheet The hook you need is login_enqueue_scripts. It fires only on the login, registration and lost password screens, so your CSS never loads on the front end or in the admin. function agency_login_assets() { $css_path = __DIR__ . ‘/assets/login.css’; $css_url = plugin_dir_url( __FILE__ ) . ‘assets/login.css’; wp_enqueue_style( ‘agency-login’, $css_url, array( ‘login’ ), file_exists( $css_path ) ? filemtime( $css_path ) : ‘1.0.0’ ); } add_action( ‘login_enqueue_scripts’, ‘agency_login_assets’ ); Two details that separate sloppy code from clean code: array( ‘login’ ) declares a dependency on the core login stylesheet, so yours always loads after it and your rules win without !important spam. filemtime() gives you automatic cache busting. Edit the CSS, the version string changes, the browser and any CDN pick it up immediately. Prefer no extra HTTP request? For very small amounts of CSS, you can print it inline on the same hook: function agency_login_inline_css() { $logo = plugin_dir_url( __FILE__ ) . ‘assets/client-logo.svg’; ?> <style> .login h1 a { background-image: url(‘<?php echo esc_url( $logo ); ?>’); background-size: contain; width: 100%; height: 90px; } </style> <?php } add_action( ‘login_enqueue_scripts’, ‘agency_login_inline_css’ ); Always run dynamic URLs through esc_url(). It costs nothing and it is the kind of detail a code review will catch. Step 2: Replace the WordPress logo with the client logo The logo is an <h1> containing a link, and WordPress renders it as a background image. Target it like this in assets/login.css: .login h1 a { background-image: url(‘../assets/client-logo.svg’); background-size: contain; background-position: center center; background-repeat: no-repeat; width: 100%; height: 90px; margin-bottom: 24px; } Use an SVG or a 2x PNG. The login logo renders on retina screens and a 300px-wide JPEG will look amateurish on the one screen your client sees every single day. Fix the logo link and tooltip By default the logo links to wordpress.org with the title “Powered by WordPress”. That is the fastest way to blow your white-label cover. Two filters solve it: add_filter( ‘login_headerurl’, function () { return home_url( ‘/’ ); } ); add_filter( ‘login_headertext’, function () { return get_bloginfo( ‘name’ ); } ); Note: login_headertext replaced the old login_headertitle filter. If you are copying code from a 2016 tutorial, that is why your tooltip never changed. Step 3: Brand the background The login screen body carries the class .login, which makes full-screen backgrounds trivial: body.login { background: #0f172a url(‘../assets/login-bg.jpg’) no-repeat center center fixed; background-size: cover; } /* Gradient alternative, no image weight */ body.login { background: linear-gradient(135deg, #0f172a 0%, #2563eb 100%); } Keep the hero image under 250 KB and serve WebP where you can. The login page is not the place to ship a 3 MB photograph. Add a per-client body class If you maintain a multisite or a shared boilerplate, the login_body_class filter lets one stylesheet handle many brands: add_filter( ‘login_body_class’, function ( $classes ) { $classes[] = ‘brand-acme’; return $classes; } ); Step 4: Style the form, fields and button This is where the page stops looking like “WordPress with a logo swapped” and starts looking like a product. Here is a solid base to adapt: #login { width: 400px; padding: 6% 0 0; } .login form { border: 0; border-radius: 14px; padding: 32px 28px 26px; box-shadow: 0 24px 50px rgba(15, 23, 42, .28); } .login label { color: #334155; font-size: 14px; font-weight: 600; } .login input[type=’text’], .login input[type=’password’], .login input[type=’email’] { border: 1px solid #cbd5e1; border-radius: 8px; padding: 10px 12px; font-size: 15px;

How to Create a Custom WordPress Login Page Without a Plugin (Code Guide) Read More »

How to Create an Interactive Map for a Website With Leaflet (No Google Maps API Key)

Short answer: you can build a custom interactive map for a website in roughly 20 lines of code using Leaflet.js and OpenStreetMap tiles. No Google Maps API key, no billing account, no credit card on file, and no third party tracking cookies loaded on your pages. There is more on it in Free Map Maker & Generator. Most articles on this topic push you toward a hosted map builder or toward the Google Maps Platform, where you have to enable billing before a single tile renders. That is fine for some projects, but it is overkill when all you need is a map of your offices, your store locations, your delivery zones or your event venues. This tutorial shows the developer route: a lightweight open source library, open data tiles, and full control over the markup. What You Will Build A full width, responsive map block that works on mobile and desktop Custom branded markers (your own PNG or SVG pin) Clickable popups with a title, address and link Multiple locations loaded from a JSON array or from an Express API route Auto zoom that fits every marker in the viewport A map that does not hijack page scrolling on mobile Total page weight: about 42 KB of JavaScript plus CSS, gzipped, versus several hundred KB for the typical commercial map SDK. Why Leaflet and OpenStreetMap Instead of Google Maps Leaflet is an open source mapping library (BSD licensed) that renders map tiles and vector overlays in the browser. OpenStreetMap is a community built, openly licensed map database. Together they give you a production grade map with zero account setup. Solution API key / billing Customisation Typical weight Privacy Leaflet + OpenStreetMap None Full (your HTML and CSS) ~42 KB JS No ad cookies Google Maps JS API Key + billing account required Good, via cloud styling 200 KB+ Consent banner usually needed Mapbox GL JS Key required, usage based pricing Excellent (vector styles) 200 KB+ Telemetry by default No code embeds (map builders) Account, often paid plan Limited to their editor Heavy iframe Third party scripts If you have a marketing team that needs to drag pins around without touching code, a hosted builder is a reasonable choice. If you own the codebase, Leaflet wins on speed, cost and control. The team at flourish.studio reached a similar conclusion. Before You Start A page you can edit (static HTML, a template, a CMS block or a React/Vue component) The latitude and longitude of each location. Right click a point on openstreetmap.org and choose Show address to read the coordinates, or geocode your addresses once with Nominatim and store the results. Optional: a marker image, ideally a 32×42 px PNG or an SVG Important: store your coordinates in your own database or JSON file. Geocoding on every page load is slow, and public geocoding services rate limit that behaviour. Step 1: Add the Leaflet CSS and JS Use the current stable 1.9.x line from a CDN, or install it locally with npm install leaflet if you bundle your assets. Self hosting the two files is the privacy friendly option and removes a third party request. <link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css"> <script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script> The CSS must load before the map is initialised, otherwise tiles stack in a broken column. If you are serving files yourself, copy leaflet.css, leaflet.js and the images/ folder from the package into your public directory. Note on Leaflet 2.x Leaflet 2 moves to native ES modules and drops the global L object in favour of named imports. The concepts in this tutorial are identical, only the import syntax changes: import { Map, TileLayer, Marker } from 'leaflet'; If you are on a classic script tag setup, the 1.9.x line remains the safest pick today. Step 2: Create the Map Container and Give It a Height This is where 90 percent of blank map bugs come from. A div with no height renders nothing. <div id="map" role="region" aria-label="Map of our locations"></div> <style> #map { width: 100%; height: 60vh; min-height: 320px; border-radius: 12px; z-index: 0; } @media (max-width: 600px) { #map { height: 55vh; min-height: 280px; } } </style> Using vh with a min-height keeps the map usable on small screens without pushing the rest of the page below the fold. The z-index: 0 prevents Leaflet panes from floating above sticky headers and modals. Step 3: Initialise the Map and Load Tiles const map = L.map('map', { scrollWheelZoom: false, // avoids hijacking page scroll zoomControl: true }).setView([48.8584, 2.2945], 13); L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' }).addTo(map); // let users zoom with the wheel only after they click the map map.on('click', () => map.scrollWheelZoom.enable()); map.on('mouseout', () => map.scrollWheelZoom.disable()); setView takes [latitude, longitude] and a zoom level: 3 for a continent, 12 to 14 for a city, 16 and above for a street. Keeping the attribution line is a licence requirement, not a suggestion. Step 4: Add Markers and Popups L.marker([48.8584, 2.2945]) .addTo(map) .bindPopup( '<strong>Paris office</strong><br>5 Avenue Anatole France<br>' + '<a href="/contact/paris">Opening hours</a>' ); Popups accept raw HTML, so you can drop in an image, a phone link or a button. Add .openPopup() if you want one marker open on load. For permanent labels, use bindTooltip('Paris', { permanent: true }) instead. Step 5: Use Your Own Branded Pin const brandPin = L.icon({ iconUrl: '/img/pin.svg', iconSize: [32, 42], // width, height iconAnchor: [16, 42], // the tip of the pin popupAnchor: [0, -38], // where the popup opens shadowUrl: null }); L.marker([48.8584, 2.2945], { icon: brandPin, title: 'Paris office' }) .addTo(map); iconAnchor is the detail everyone gets wrong. It defines which pixel of your image sits on the coordinate. For a classic teardrop pin, that is the horizontal centre and the bottom edge. Step 6: Loop Through Multiple Locations and Auto Fit the View Hard coding one marker is fine for a contact page. For a store locator, drive the map from data: const locations = [ { name: 'Paris office', lat: 48.8584, lng: 2.2945, url: '/contact/paris' }, { name: 'Lisbon

How to Create an Interactive Map for a Website With Leaflet (No Google Maps API Key) Read More »

How to Create a Parallax Scrolling Effect With CSS Only (No JavaScript)

A CSS parallax effect happens when the background of a page moves slower than the content in front of it while you scroll, which tricks the eye into seeing depth on a flat screen. Most tutorials solve this with a JavaScript library that listens to every scroll event, recalculates positions and quietly destroys your Interaction to Next Paint score. See geeksforgeeks.org for their take. You do not need any of that. In 2026, the browser can handle parallax natively with three techniques: background-attachment, 3D transforms with perspective and transform-style, and scroll-driven animations. This guide gives you working code for each one, plus the mobile fallbacks and accessibility rules that almost every other tutorial skips. The three CSS-only parallax methods compared Method Best for Difficulty Support Performance background-attachment: fixed Hero banners, section dividers Beginner Universal on desktop, broken on iOS Can trigger repaints on large images 3D transforms (perspective + translateZ) Multi-layer scenes, different speeds per layer Intermediate Every modern browser Excellent, GPU composited Scroll-driven animations (animation-timeline) Precise control, any property, modern sites Intermediate Chromium, Safari 26+, Firefox (recent versions) Excellent, runs off the main thread Method 1: the two-line parallax hero with background-attachment This is the fastest way to get a parallax hero online. The background image stays locked to the viewport while the section scrolls over it, so the image appears to move slower than the page. HTML <section class=”parallax-hero”> <div class=”parallax-hero__content”> <h1>Build faster interfaces</h1> <p>No JavaScript, no scroll listeners, no layout thrashing.</p> </div> </section> <section class=”content”> <p>Your regular page content goes here.</p> </section> CSS .parallax-hero { min-height: 100svh; display: grid; place-items: center; background-image: linear-gradient(rgba(0,0,0,.45), rgba(0,0,0,.45)), url(“/img/hero-1920.webp”); background-size: cover; background-position: center; background-repeat: no-repeat; background-attachment: fixed; } .parallax-hero__content { color: #fff; text-align: center; max-width: 40rem; padding: 2rem; } /* Fallback: iOS Safari and touch devices */ @media (max-width: 768px), (hover: none) and (pointer: coarse) { .parallax-hero { background-attachment: scroll; } } /* Accessibility: respect motion preferences */ @media (prefers-reduced-motion: reduce) { .parallax-hero { background-attachment: scroll; } } Why the fallback matters: iOS Safari has never implemented background-attachment: fixed the way desktop browsers do. Without the media query above, the image is scaled to the full document height and looks zoomed in or jumps as the address bar collapses. Switching to scroll on touch devices gives you a clean static background instead of a broken one. Bonus: a parallax content divider The same idea works beautifully as a narrow band between two text sections. Keep it short so the page keeps its rhythm. .parallax-divider { min-height: clamp(220px, 45vh, 420px); background-image: url(“/img/divider-1600.webp”); background-size: cover; background-position: center; background-attachment: fixed; display: grid; place-items: center; } .parallax-divider blockquote { color: #fff; font-size: clamp(1.25rem, 3vw, 2rem); text-shadow: 0 2px 12px rgba(0,0,0,.6); max-width: 45ch; text-align: center; } @media (max-width: 768px), (prefers-reduced-motion: reduce) { .parallax-divider { background-attachment: scroll; } } Method 2: true multi-layer parallax with perspective and transform-style If you want several layers moving at different speeds (a sky, mountains, trees, foreground text), background-attachment is not enough. The classic pure CSS solution uses a 3D scene: you push layers away from the camera on the Z axis, and because they are further away, they appear to move slower when the scroller moves. The math you need When you push an element back with translateZ, it also shrinks. You compensate with scale(): scale = 1 + (translateZ * -1) / perspective perspective: 10px and translateZ(-10px) gives scale(2) and a layer moving at roughly half speed perspective: 10px and translateZ(-20px) gives scale(3) and a layer moving at roughly one third speed translateZ(0) means normal speed, which is your foreground HTML <div class=”parallax”> <section class=”parallax__group”> <div class=”parallax__layer parallax__layer–back”> <img src=”/img/mountains.webp” alt=”” width=”1920″ height=”1080″> </div> <div class=”parallax__layer parallax__layer–base”> <h1>Depth without a single line of JS</h1> </div> </section> <section class=”parallax__group parallax__group–plain”> <p>Normal scrolling content.</p> </section> </div> CSS .parallax { height: 100svh; overflow-x: hidden; overflow-y: auto; perspective: 10px; perspective-origin: 0 0; /* keeps native scroll snapping and momentum intact */ scroll-behavior: smooth; } .parallax__group { position: relative; height: 100svh; transform-style: preserve-3d; } .parallax__layer { position: absolute; inset: 0; transform-origin: 0 0; } .parallax__layer–back { transform: translateZ(-10px) scale(2); z-index: 1; } .parallax__layer–base { transform: translateZ(0); z-index: 2; display: grid; place-items: center; color: #fff; } .parallax__layer img { width: 100%; height: 100%; object-fit: cover; } @media (prefers-reduced-motion: reduce) { .parallax { perspective: none; } .parallax__layer–back { transform: none; } } Things to watch with the 3D method The scroll container is the element with perspective, not the body. Anchor links, sticky headers and position: fixed elements behave differently inside it, so test your navigation. Stacking order is controlled by z-index inside each group. A layer pushed back on Z will still paint above another layer if its z-index is higher. Do not nest transforms on parents. Any ancestor with its own transform, filter or will-change creates a new containing block and flattens your 3D scene. Method 3: scroll-driven animations, the modern way Scroll-driven animations are the cleanest option available today. You write a normal @keyframes rule and then tell the browser to drive it with scroll progress instead of time, using animation-timeline. The animation runs on the compositor, so it does not block the main thread the way a JavaScript scroll handler does. Parallax hero with view() @keyframes parallax-shift { from { transform: translate3d(0, -12%, 0); } to { transform: translate3d(0, 12%, 0); } } .hero { position: relative; min-height: 100svh; overflow: hidden; isolation: isolate; } .hero__bg { position: absolute; inset: -15% 0; z-index: -1; background: url(“/img/hero-1920.webp”) center / cover no-repeat; } @supports (animation-timeline: view()) { .hero__bg { animation: parallax-shift linear both; animation-timeline: view(); animation-range: cover 0% cover 100%; } } @media (prefers-reduced-motion: reduce) { .hero__bg { animation: none; } } Whole-page background driven by scroll() Use scroll() when you want an element to react to the progress of the nearest scroller rather than its own visibility. @keyframes drift { to { background-position: center 70%; } } .page-bg { position: fixed; inset: 0; z-index: -1; background: url(“/img/texture.webp”) center 30% / cover no-repeat; animation: drift linear both; animation-timeline: scroll(root block); } Wrapping the rules in

How to Create a Parallax Scrolling Effect With CSS Only (No JavaScript) Read More »

How to Add a Wishlist to WooCommerce Without a Plugin (Code Tutorial)

Short answer: you do not need a wishlist plugin. With roughly 150 lines of PHP, a small vanilla JavaScript file and one shortcode, you can ship a save-for-later wishlist in WooCommerce that stores data in user meta for logged-in customers and in a cookie for guests. No extra database tables, no jQuery dependency, no third-party scripts loading on every page of your store. This tutorial gives you the complete, production-ready code: the button, the AJAX toggle, the storage layer, the wishlist page template, the menu counter and the trick that keeps everything working behind full page caching. Why build a WooCommerce wishlist without a plugin? Most wishlist plugins do far more than you need. A typical free wishlist plugin will: Enqueue its own CSS and JS on every single page, including the checkout and blog posts Create custom database tables and cron jobs for price-drop emails you will never enable Register dozens of options, admin pages and upsell notices Add render-blocking requests that hurt your Largest Contentful Paint and Interaction to Next Paint scores Lock your data in a proprietary structure that is painful to migrate later A custom implementation loads two small files only where they are needed, stores product IDs in a format you control, and takes about 30 minutes to install. You own the code, so nothing breaks on the next plugin update. The point is broken down further in this analysis. What we are going to build Feature How it works Save / remove button Hooked into the shop loop and the single product summary Logged-in storage A single _exp_wishlist user meta row containing an array of product IDs Guest storage A JSON cookie, merged into the account on login No page reload admin-ajax.php endpoint protected by a nonce Wishlist page [exp_wishlist] shortcode with add-to-cart and remove actions Header counter [exp_wishlist_count] shortcode updated live in JavaScript Cache friendly A tiny REST endpoint that rehydrates the button state after a cached HTML response Prerequisites WooCommerce installed and active, running on a recent WordPress release (tested on current versions as of September 2026) A child theme or, even better, a site-specific plugin in wp-content/mu-plugins/ so the code survives a theme change FTP or file manager access, plus a staging site if the store is live Basic comfort with PHP and browser dev tools Step 1: Create a safe place for the code Do not paste this into a parent theme functions.php. Create the file wp-content/mu-plugins/exp-wishlist.php and start it with a header: <?php /** * Plugin Name: EXP Lightweight Wishlist * Description: WooCommerce save-for-later wishlist with no third-party plugin. * Version: 1.0.0 */ if ( ! defined( ‘ABSPATH’ ) ) { exit; } define( ‘EXP_WISHLIST_META’, ‘_exp_wishlist’ ); define( ‘EXP_WISHLIST_COOKIE’, ‘exp_wishlist’ ); Everything below goes into that same file. If you prefer a child theme, drop the header comment and paste the functions into functions.php instead. Step 2: The storage layer (user meta plus guest cookie) Two helpers do all the work: one reads the list, one writes it. Product IDs are always cast to integers and deduplicated, which removes an entire class of security and data-integrity bugs. /** * Return the current wishlist as an array of product IDs. */ function exp_get_wishlist() { if ( is_user_logged_in() ) { $ids = get_user_meta( get_current_user_id(), EXP_WISHLIST_META, true ); } elseif ( ! empty( $_COOKIE[ EXP_WISHLIST_COOKIE ] ) ) { $ids = json_decode( wp_unslash( $_COOKIE[ EXP_WISHLIST_COOKIE ] ), true ); } else { $ids = array(); } if ( ! is_array( $ids ) ) { $ids = array(); } $ids = array_values( array_unique( array_filter( array_map( ‘absint’, $ids ) ) ) ); return apply_filters( ‘exp_wishlist_items’, $ids ); } /** * Persist the wishlist and return the cleaned array. */ function exp_save_wishlist( $ids ) { $ids = array_values( array_unique( array_filter( array_map( ‘absint’, (array) $ids ) ) ) ); $ids = array_slice( $ids, 0, 100 ); // Hard cap, protects the cookie size. if ( is_user_logged_in() ) { update_user_meta( get_current_user_id(), EXP_WISHLIST_META, $ids ); } else { $value = wp_json_encode( $ids ); setcookie( EXP_WISHLIST_COOKIE, $value, time() + ( 90 * DAY_IN_SECONDS ), COOKIEPATH ? COOKIEPATH : ‘/’, COOKIE_DOMAIN, is_ssl(), false ); $_COOKIE[ EXP_WISHLIST_COOKIE ] = $value; } do_action( ‘exp_wishlist_saved’, $ids ); return $ids; } Why user meta and not a custom table? A wishlist is a short list of integers attached to one user. User meta is already cached by WordPress object caching, requires zero migration code, and is included in every standard WordPress backup or export. Merge the guest list at login A shopper who saves three products as a guest and then logs in should keep those products. One hook handles it: add_action( ‘wp_login’, ‘exp_wishlist_merge_on_login’, 10, 2 ); function exp_wishlist_merge_on_login( $user_login, $user ) { if ( empty( $_COOKIE[ EXP_WISHLIST_COOKIE ] ) ) { return; } $guest = json_decode( wp_unslash( $_COOKIE[ EXP_WISHLIST_COOKIE ] ), true ); if ( ! is_array( $guest ) || empty( $guest ) ) { return; } $saved = get_user_meta( $user->ID, EXP_WISHLIST_META, true ); if ( ! is_array( $saved ) ) { $saved = array(); } $merged = array_values( array_unique( array_map( ‘absint’, array_merge( $saved, $guest ) ) ) ); update_user_meta( $user->ID, EXP_WISHLIST_META, $merged ); setcookie( EXP_WISHLIST_COOKIE, ”, time() – 3600, COOKIEPATH ? COOKIEPATH : ‘/’, COOKIE_DOMAIN ); } Step 3: Output the save-for-later button WooCommerce gives you clean hooks for both the archive and the product page. The same function is reused later inside the wishlist table, which is why it accepts an optional product ID. A comparable breakdown sits on 10web.io. add_action( ‘woocommerce_after_shop_loop_item’, ‘exp_wishlist_button’, 15 ); add_action( ‘woocommerce_single_product_summary’, ‘exp_wishlist_button’, 35 ); function exp_wishlist_button( $product_id = 0 ) { if ( ! $product_id ) { global $product; if ( ! $product instanceof WC_Product ) { return; } $product_id = $product->get_id(); } $in_list = in_array( (int) $product_id, exp_get_wishlist(), true ); printf( ‘<button type=”button” class=”exp-wishlist-btn%1$s” data-product-id=”%2$d” aria-pressed=”%3$s”><span class=”exp-wishlist-icon” aria-hidden=”true”>&#9825;</span> <span class=”exp-wishlist-label”>%4$s</span></button>’, $in_list ? ‘ is-active’ : ”, (int) $product_id, $in_list ? ‘true’ : ‘false’, esc_html( $in_list ? __( ‘Saved’, ‘exp’ ) : __( ‘Save for later’, ‘exp’ ) ) ); } Hook priorities you

How to Add a Wishlist to WooCommerce Without a Plugin (Code Tutorial) Read More »

How to Add a Sticky Add to Cart Bar in WooCommerce Without a Plugin

A WooCommerce sticky add to cart bar keeps the price and the buy button permanently in view while shoppers scroll through descriptions, galleries, tabs and reviews. On mobile, where the real add to cart button disappears after two swipes, that single UI element often does more for conversions than a redesign of the whole product page. Most articles on this topic end with the same advice: install a plugin. This tutorial does the opposite. You will build the bar yourself in a child theme with about 60 lines of PHP, a small CSS file and a lightweight JavaScript scroll listener. No extra plugin, no bloated settings panel, no render-blocking assets, and full control over what the bar shows. What you are going to build A fixed bar that slides in only after the original add to cart form scrolls out of view. Product thumbnail, truncated product name, live price and one clear call to action. Variable product support: the price updates when a variation is selected, and the button switches from Choose options to Add to cart. Zero cumulative layout shift, correct behaviour inside the iPhone safe area, and 44px tap targets. Compatibility with both classic themes and block-based (FSE) product templates. Files you will create in your child theme functions.php (add the two snippets below) assets/css/sticky-atc.css assets/js/sticky-atc.js Plugin or custom code: which one should you pick? Plugins such as Sticky Cart for WooCommerce, Simple Sticky Add To Cart or the various builder add-ons are fine if you want a five minute setup. They are not fine if you care about page weight and Core Web Vitals on product pages. Here is an honest comparison. Criteria Custom code (this tutorial) Sticky add to cart plugin Extra front-end weight About 2 KB CSS + 2 KB JS, product pages only Often 20 to 150 KB, sometimes loaded site-wide Control over markup Total, you own every element Limited to the options exposed Layout shift risk None, the bar is server rendered and animated with transform Common, many inject the bar with JS after load Setup time 20 to 30 minutes 5 minutes Maintenance You maintain it, it never breaks on plugin updates Vendor dependent, can break after WooCommerce updates Cost Free Free to around 79 USD per year Rule of thumb: if your store is above 1,000 sessions a day, the custom version pays for itself in Core Web Vitals alone. Before you start An active child theme. Never edit a parent theme, your changes will be wiped on the next update. FTP, SFTP or the file manager of your host. The theme file editor works too, but a backup first is mandatory. A staging copy if the store is live. WooCommerce with jQuery available (WooCommerce still loads it on product pages, and we use it only for variation events). Step 1: Enqueue the CSS and JS on product pages only Add this to the bottom of your child theme functions.php. The conditional check means the assets never touch your home page, your blog or your checkout. /** * Load sticky add to cart assets on single product pages only. */ add_action( ‘wp_enqueue_scripts’, ‘exp_sticky_atc_assets’ ); function exp_sticky_atc_assets() { if ( ! function_exists( ‘is_product’ ) || ! is_product() ) { return; } wp_enqueue_style( ‘exp-sticky-atc’, get_stylesheet_directory_uri() . ‘/assets/css/sticky-atc.css’, array(), ‘1.0.0’ ); wp_enqueue_script( ‘exp-sticky-atc’, get_stylesheet_directory_uri() . ‘/assets/js/sticky-atc.js’, array( ‘jquery’ ), ‘1.0.0’, true ); } Step 2: Output the bar with a hook Many tutorials hook into woocommerce_after_single_product. That hook does not fire on block-based product templates, which more and more stores now use. Hooking wp_footer with an is_product() check works everywhere, classic or FSE, and it keeps the bar at the end of the DOM where a fixed element belongs. /** * Render the sticky add to cart bar in the footer of single product pages. */ add_action( ‘wp_footer’, ‘exp_sticky_atc_bar’, 20 ); function exp_sticky_atc_bar() { if ( ! function_exists( ‘is_product’ ) || ! is_product() ) { return; } global $product; if ( ! is_a( $product, ‘WC_Product’ ) ) { $product = wc_get_product( get_the_ID() ); } // Skip products nobody can buy from the bar. if ( ! $product || ! $product->is_purchasable() || ! $product->is_in_stock() ) { return; } $is_variable = $product->is_type( ‘variable’ ); $add_text = $product->single_add_to_cart_text(); $options_text = __( ‘Choose options’, ‘child-theme’ ); $price_html = $product->get_price_html(); $image = $product->get_image( ‘woocommerce_gallery_thumbnail’, array( ‘loading’ => ‘eager’, ‘width’ => 48, ‘height’ => 48, ‘alt’ => ”, ) ); ?> <div id=”exp-sticky-atc” class=”exp-sticky-atc” aria-hidden=”true” data-add-text=”<?php echo esc_attr( $add_text ); ?>” data-options-text=”<?php echo esc_attr( $options_text ); ?>” data-default-price=”<?php echo esc_attr( $price_html ); ?>”> <div class=”exp-sticky-atc__inner”> <div class=”exp-sticky-atc__media” aria-hidden=”true”> <?php echo $image; // phpcs:ignore WordPress.Security.EscapeOutput ?> </div> <div class=”exp-sticky-atc__info”> <span class=”exp-sticky-atc__title”><?php echo esc_html( $product->get_name() ); ?></span> <span class=”exp-sticky-atc__price”><?php echo wp_kses_post( $price_html ); ?></span> </div> <button type=”button” class=”button exp-sticky-atc__button” data-product-id=”<?php echo esc_attr( $product->get_id() ); ?>” <?php echo $is_variable ? ‘data-needs-options=”1″‘ : ”; ?>> <?php echo esc_html( $is_variable ? $options_text : $add_text ); ?> </button> </div> </div> <?php } Because the bar is printed by PHP, it exists in the HTML the browser receives. That is the first half of the no-layout-shift strategy: nothing is injected after load. Step 3: The CSS (mobile-first, safe area aware) Create assets/css/sticky-atc.css in your child theme and paste the following. :root { –exp-atc-height: 64px; –exp-atc-offset: 0px; /* raise the bar if you have a chat widget */ } /* Reserve the space at CSS parse time so the footer is never hidden and nothing moves after load. */ @media (max-width: 991px) { body.single-product { padding-bottom: calc(var(–exp-atc-height) + env(safe-area-inset-bottom, 0px)); } } .exp-sticky-atc { position: fixed; left: 0; right: 0; bottom: var(–exp-atc-offset); z-index: 9990; background: #fff; border-top: 1px solid #e5e7eb; box-shadow: 0 -2px 14px rgba(0, 0, 0, .08); padding: 8px 12px calc(8px + env(safe-area-inset-bottom, 0px)); transform: translateY(110%); transition: transform .22s ease; will-change: transform; pointer-events: none; contain: layout paint; } .exp-sticky-atc.is-visible { transform: translateY(0); pointer-events: auto; } .exp-sticky-atc__inner { display: flex; align-items: center; gap: 10px; max-width: 1200px; margin: 0 auto; min-height: 48px; } .exp-sticky-atc__media img { display: block; width: 48px; height: 48px; object-fit: cover; border-radius: 6px; } .exp-sticky-atc__info

How to Add a Sticky Add to Cart Bar in WooCommerce Without a Plugin Read More »

How to Design a Newsletter Signup Form That Converts (Placement, Copy, and CSS)

Most articles about newsletter signup form design stop at inspiration galleries. This one goes further: you get the placement data, the field rules, the copy patterns, and the actual markup, CSS and Express.js endpoint you need to ship a form that converts today. The short answer A high converting newsletter signup form follows six rules: One field (email) unless personalisation genuinely drives revenue. Placed where attention already exists: mid-article inline, end of post, and exit intent beat the footer every time. A value headline, not a label. Replace “Subscribe to our newsletter” with what the reader receives. A specific CTA button: “Send me the weekly tips” outperforms “Submit”. Visible states: focus, error, loading and success, all announced to screen readers. Trust microcopy: frequency, format and a one click unsubscribe promise. Step 1: Choose the right placement Placement affects opt-in rate more than colours, borders or illustrations. The same form can convert ten times better simply by moving it. Here is how the common placements compare in practice. Placement Typical opt-in rate Intrusiveness Best for Inline, mid or end of article 1% to 3% Low Blogs, docs, tutorials Exit intent modal (desktop) 2% to 5% High High traffic pages, ecommerce Two step popup (click a link, then form) 3% to 8% of clickers Low Lead magnets, checklists, templates Sticky bar (top or bottom) 0.5% to 1.5% Medium Site wide, always on offer Sidebar widget 0.3% to 1% Low Desktop heavy audiences Footer 0.1% to 0.5% None Baseline coverage, brand sites Dedicated landing page 10% to 25% None Paid traffic, social bio links Treat those numbers as ranges, not promises. They depend on traffic quality, offer strength and industry. What holds true everywhere is the ordering: intent driven placements beat passive ones. The placement stack that works on most sites Always on: a footer form (cheap, zero friction, catches people who go looking). Primary driver: an inline form after the third or fourth section of every article, plus one at the end. Safety net: an exit intent modal on desktop only, capped at once per 30 days per visitor. Campaign specific: a two step popup attached to a lead magnet link. Never stack a sticky bar, a modal and a slide-in on the same page. Competing prompts kill trust and hurt Core Web Vitals through layout shift. Mobile rules No full screen interstitials on entry. Google treats intrusive interstitials as a ranking negative on mobile. Exit intent does not exist on touch devices. Use scroll depth (60% to 70%) or a time delay instead. Keep the input at least 44px tall and set inputmode=”email” so the keyboard shows the @ key. Step 2: Decide how many fields Every extra field costs conversions. The trade-off is data you can use for segmentation. Form Relative conversion When to use it Email only Baseline (best) Default for content newsletters Email + first name Roughly 5% to 10% lower Only if you actually personalise subject lines Email + topic checkboxes Roughly 10% to 20% lower Multi-product sites where relevance lifts engagement Email + company + role 30%+ lower B2B lead gen, never a plain newsletter Better approach: ask for the email first, then collect the rest on the confirmation page or in the welcome email. The subscriber is already committed, so completion rates are high and your form stays a single input. Step 3: Write copy that earns the address Design gets attention. Copy gets the submit. Three blocks matter: headline, button, reassurance. Headline formulas Outcome + cadence: “One practical Node.js tip, every Friday.” Audience + benefit: “For backend developers who ship: performance patterns explained in 3 minutes.” Curiosity + proof: “The bugs we fixed this week, read by 12,480 developers.” Exclusive access: “Release notes and breaking changes before they hit the docs.” Avoid “Sign up for our newsletter” as a headline. It describes the mechanism, not the reward. Button copy Weak Stronger Why Submit Send me the weekly tips First person, restates the value Subscribe Join 12,480 developers Adds social proof at the click point Sign up Get the first issue Immediate, concrete reward Reassurance microcopy Put one short line directly under the button. Keep it factual: “Two emails a month. Unsubscribe in one click.” “We never share your address. See our privacy policy.” “Double opt-in: you will get a confirmation email first.” The word “spam” can backfire, since it plants the idea. Describe the frequency instead. Newsletter Signup Form Template is a useful companion to this. Step 4: Build the form (HTML) Semantic, accessible markup with a honeypot field for bots and explicit state containers for errors and success. <form class=”nl-form” id=”nl-form” action=”/api/subscribe” method=”post” novalidate> <h2 class=”nl-form__title”>One practical Node.js tip, every Friday</h2> <p class=”nl-form__sub”>Two minute read, code first, no fluff. Join 12,480 developers.</p> <div class=”nl-field”> <label class=”nl-label” for=”nl-email”>Email address</label> <input class=”nl-input” id=”nl-email” name=”email” type=”email” inputmode=”email” autocomplete=”email” placeholder=”[email protected]” required aria-describedby=”nl-error nl-note”> <p class=”nl-error” id=”nl-error” hidden>Please enter a valid email address.</p> </div> <!– Honeypot: hidden from humans, filled by bots –> <div class=”nl-hp”> <label for=”nl-company”>Company</label> <input id=”nl-company” name=”company” type=”text” tabindex=”-1″ autocomplete=”off”> </div> <button class=”nl-button” type=”submit”> <span class=”nl-button__label”>Send me the weekly tips</span> <span class=”nl-spinner” aria-hidden=”true”></span> </button> <p class=”nl-note” id=”nl-note”>Four emails a month. Unsubscribe in one click.</p> <p class=”nl-success” role=”status” hidden>Almost done. Check your inbox and confirm your subscription.</p> </form> Accessibility checklist Always ship a real <label>. A placeholder is not a label and disappears on typing. Link the error message with aria-describedby and toggle aria-invalid=”true”. Give the success message role=”status” so screen readers announce it without stealing focus. Keep contrast at 4.5:1 for text and 3:1 for the input border. Do not hide the honeypot with display:none only; use off-screen positioning so it stays plausible to bots but invisible to users. Step 5: Style it (responsive CSS with validation states) This CSS uses custom properties so you can theme the form in one place, switches to a single row layout on wider screens, and respects reduced motion preferences. .nl-form { –nl-bg: #ffffff; –nl-fg: #111827; –nl-muted: #6b7280; –nl-border: #d1d5db; –nl-accent: #2563eb; –nl-error: #dc2626; –nl-success: #047857; –nl-radius: 10px;

How to Design a Newsletter Signup Form That Converts (Placement, Copy, and CSS) Read More »

How to Create a CSS-Only Accordion Without JavaScript (Details and Summary Tags)

A CSS only accordion is one of the easiest performance wins available on a modern site. No JavaScript bundle, no hydration delay, no ARIA wiring to maintain, and keyboard support that works the second the HTML is parsed. The native <details> and <summary> elements do the heavy lifting, and modern CSS now handles the part that used to require scripting: a smooth open and close animation from zero to auto height. This tutorial gives you the full recipe, plus two copy-ready blocks you can paste straight into a page today: an FAQ accordion and a product specs accordion. We also cover the parts most tutorials skip, such as what actually happens in browsers that do not support the newest animation properties, and when the old checkbox hack is still the right call. Quick answer: what is a CSS-only accordion? A CSS only accordion is an expand and collapse UI built with HTML and CSS alone, with no JavaScript event listeners. There are two ways to build one: The native way (recommended): a series of <details> elements, each with a <summary> header. The browser handles open and close state, focus, keyboard, and screen reader semantics. The checkbox or radio hack (legacy): hidden <input> elements paired with <label> headers and the :checked selector. It animates well but it lies to assistive technology, because a checkbox is not a disclosure widget. Unless you have a very specific reason, use <details>. Here is how the two compare. Criteria details / summary Checkbox hack Keyboard support Built in (Tab, Enter, Space) Needs tabindex tricks Screen reader semantics Announced as an expandable group Announced as a checkbox Find in page (Ctrl+F) Opens the matching panel Fails on hidden content One panel open at a time Yes, with the name attribute Yes, with radio inputs Close animation Yes in modern browsers Yes everywhere Markup weight Minimal Extra inputs and labels Step 1: the markup Start with clean, semantic HTML. Each accordion item is one <details> element, the header is the <summary>, and everything after it is the panel. <div class=”accordion”> <details class=”accordion__item” open> <summary class=”accordion__header”>What is included in the free plan?</summary> <div class=”accordion__panel”> <p>Unlimited projects, 2 GB of storage and community support.</p> </div> </details> <details class=”accordion__item”> <summary class=”accordion__header”>Can I cancel at any time?</summary> <div class=”accordion__panel”> <p>Yes. Cancel in one click from your dashboard, no phone call needed.</p> </div> </details> <details class=”accordion__item”> <summary class=”accordion__header”>Do you offer invoices for companies?</summary> <div class=”accordion__panel”> <p>Every payment generates a PDF invoice with your VAT number.</p> </div> </details> </div> Three rules to remember: The <summary> must be the first child of <details>, otherwise the browser generates an implicit one. Wrap the panel content in a single element. That wrapper is what you will pad and animate. Add open to the item you want expanded on load. Opening the first item is good for engagement, but leave all closed if the list is long. Step 2: remove the default marker Browsers draw a small triangle before the summary text. Remove it so you can design your own icon. .accordion__header { list-style: none; /* Chrome, Firefox, Safari 18+ */ } .accordion__header::-webkit-details-marker { display: none; /* older Safari */ } If you like the native triangle and just want it recoloured or resized, keep it and style the ::marker pseudo element instead: .accordion__header::marker { color: #2563eb; font-size: 1.1em; } Step 3: base styles .accordion { –acc-border: #e5e7eb; –acc-accent: #2563eb; –acc-hover: #f8fafc; max-width: 44rem; border: 1px solid var(–acc-border); border-radius: 12px; overflow: hidden; background: #fff; font-family: system-ui, -apple-system, “Segoe UI”, sans-serif; } .accordion__item + .accordion__item { border-top: 1px solid var(–acc-border); } .accordion__header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem 1.25rem; font-weight: 600; line-height: 1.4; cursor: pointer; user-select: none; list-style: none; } .accordion__header:hover { background: var(–acc-hover); } .accordion__header:focus-visible { outline: 3px solid var(–acc-accent); outline-offset: -3px; } .accordion__panel { padding: 0 1.25rem 1.25rem; color: #475569; line-height: 1.65; } Note the :focus-visible rule. Never delete the focus ring on a summary, it is the only visual cue a keyboard user gets. Step 4: a custom marker that rotates A chevron built from two borders costs zero extra requests and inherits the text colour. .accordion__header::after { content: “”; flex: none; width: 0.6rem; height: 0.6rem; margin-inline-end: 0.2rem; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(45deg) translateY(-2px); transition: transform 220ms ease; } .accordion__item[open] > .accordion__header::after { transform: rotate(-135deg) translateY(-2px); } Prefer a plus and minus sign? Swap the pseudo element content: .accordion__header::after { content: “+”; font-size: 1.4rem; line-height: 1; border: 0; transform: none; } .accordion__item[open] > .accordion__header::after { content: “\2212″; } Step 5: make it an exclusive accordion (one panel at a time) This used to be the number one reason people reached for JavaScript. It is now a single HTML attribute. Give every <details> in the group the same name value and the browser closes the others automatically. <details class=”accordion__item” name=”faq”> … </details> <details class=”accordion__item” name=”faq”> … </details> <details class=”accordion__item” name=”faq”> … </details> Two details worth knowing: Only one item in a named group may carry the open attribute in the source, otherwise the last one wins. Grouped items do not have to be siblings, although keeping them together is far easier to reason about. Leave name off if you want visitors to open several sections side by side, which is usually better for comparison-style content such as product specs. Step 6: animate the open state with pure CSS Animating from height 0 to auto was impossible for years. Three modern features solve it together: interpolate-size: allow-keywords lets the browser interpolate to and from auto. ::details-content targets the hidden content wrapper the browser creates inside <details>. transition-behavior: allow-discrete keeps the content visible while the closing transition plays. :root { interpolate-size: allow-keywords; } .accordion__item::details-content { block-size: 0; overflow: hidden; opacity: 0; transition: block-size 280ms ease, opacity 220ms ease, content-visibility 280ms allow-discrete; } .accordion__item[open]::details-content { block-size: auto; opacity: 1; } @media (prefers-reduced-motion: reduce) { .accordion__item::details-content, .accordion__header::after { transition: none; } } That is the whole animation. No scrollHeight measurement, no requestAnimationFrame, no layout thrashing. What happens in browsers

How to Create a CSS-Only Accordion Without JavaScript (Details and Summary Tags) 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.