RJLCustom404 logo RJLCustom404

Customize your 404 page

Start with the options; they cover colors, layout, radius, shadow and the button. When you need more, the library leaves stable class names on everything it renders, so your own CSS can take it the rest of the way.

Recipes

Each recipe is a complete call. Every one links to the playground with the same configuration loaded, so you can see it before you copy it. Replace /img/404-{n}.png with images you host.

Dark mode

javascript
RJLCustom404({
  bodyBackgroundColor: "#0b1220",
  bodyFontColor: "#e5e7eb",
  subHeaderTextColor: "#94a3b8",
  btnColor: "#00949e",
  imgPattern: "/img/404-{n}.png",
  imgCount: 6
});

Open in the playground

Text left, image right

javascript
RJLCustom404({ headerTextPosition: "left", imgPattern: "/img/404-{n}.png", imgCount: 6 });

Open in the playground

Stacked (text above the image)

javascript
RJLCustom404({ headerTextPosition: "top", maxImgWidth: "480px", imgPattern: "/img/404-{n}.png", imgCount: 6 });

Open in the playground

javascript
RJLCustom404({ actionIsBtn: false, btnDisplayText: "Take me back to", imgPattern: "/img/404-{n}.png", imgCount: 6 });

Open in the playground

Corporate, no motion

javascript
RJLCustom404({
  headerText: "Page not found",
  subHeaderText: "The page you requested may have been moved or removed.",
  btnDisplayText: "Return to",
  btnPulsate: false,
  imgBorderRadius: "8px",
  imgFileNames: ["/img/404.png"]
});

Open in the playground

Playful with autoText

javascript
RJLCustom404({ autoText: true, imgBoxShadow: true, imgBoxShadowSize: "15px", imgPattern: "/img/404-{n}.png", imgCount: 6 });

Open in the playground

Zero-padded file names

javascript
RJLCustom404({ imgPattern: "/img/404-{nn}.png", imgCount: 12 }); // 404-01.png ... 404-12.png

Square image with hover shadow

javascript
RJLCustom404({ imgBorderRadius: "0", imgBoxShadow: true, imgBoxShadowColor: "rgba(0, 68, 148, 0.35)", imgFileNames: ["/img/404.png"] });

Open in the playground

Send visitors somewhere more useful than the root

javascript
RJLCustom404({ homeUrl: "/docs/", btnDisplayText: "Browse the docs", btnDisplayHostName: false, imgFileNames: ["/img/404.png"] });

Open in the playground

Use your site's font and tweak the button (CSS)

The library sets no font-family, so the page inherits whatever you put on body. It injects its own stylesheet when you call it, which lands after any static <style> in your page; use a slightly more specific selector to override the properties it sets.

html
<style>
  body { font-family: Inter, system-ui, sans-serif; }
  body .custom404-button { border-radius: 999px; font-weight: 600; }
  body .custom404-header { font-size: 2.5em; letter-spacing: -0.02em; }
</style>

CSS hooks

Selector Element Notes
.custom404-containerOuter flex wrapper appended to <body>max-width: 1200px; direction follows headerTextPosition
.custom404-img-containerImage columnmax-width = maxImgWidth
#goHomeLink<a> wrapping the imagePoints at homeUrl; only present when an image is configured
.custom404-imgThe <img>border-radius set inline from imgBorderRadius; alt from imgAlt
.custom404-img-missingBracketed placeholder <div>Also carries data-custom404-missing="true"
.custom404-text-containerText columnCentered flex column
.custom404-header<h3> headingfont-size: 2em (1.8em at 768px and below, 1.5em at 480px and below)
.custom404-subheader<p> sub-headingOnly when subHeaderText is non-empty
.custom404-button<button>Only when actionIsBtn: true; the plain link has no class
style[data-custom404]The injected stylesheetIncludes a global * { box-sizing: border-box }

DOM structure

What the library appends, with the default headerTextPosition ("right": image first, then text). With "top" the text container comes first in the DOM; with "left" the DOM order stays image-then-text and flex-direction: row-reverse swaps them visually.

html
<head>
  <style data-custom404="true">/* injected at call time */</style>
</head>
<body style="background-color: white; color: black; margin: 0; padding: 0; width: 100%; box-sizing: border-box;">
  <div class="custom404-container">
    <div class="custom404-img-container">
      <a id="goHomeLink" href="/" title="Go to your-site.com">
        <img class="custom404-img" src="/img/404-3.png" alt="404 Error" style="border-radius: 50%;">
      </a>
      <!-- when no image is configured, or the file fails to load: -->
      <div class="custom404-img-missing" data-custom404-missing="true">[[ No 404 image configured ]]</div>
    </div>
    <div class="custom404-text-container">
      <h3 class="custom404-header">404 - File not found</h3>
      <p class="custom404-subheader">Only rendered when subHeaderText is set</p>
      <button class="custom404-button" title="Go to your-site.com">Back to your-site.com</button>
      <!-- with actionIsBtn: false -->
      <a href="/" title="Go to your-site.com">Back to your-site.com</a>
    </div>
  </div>
</body>

Right-click is disabled on the container and dragging is disabled on the image. If that matters to you, override it with your own listeners after the call.

JavaScript API

RJLCustom404(options?)
Renders the page. Calling it again removes the previous container and stylesheet and renders fresh, which is exactly how the playground re-renders on every change.
RJLCustom404.refreshImage()
Swaps in the next image from the shuffle queue. Does nothing while the placeholder is showing.
RJLCustom404.version since 1.3.0
The library version string, currently 1.3.0. Handy for support questions and for checking that a ?v= bump actually took.
javascript
RJLCustom404({ imgPattern: "/img/404-{n}.png", imgCount: 6 });

// press "n" for the next image
document.addEventListener("keydown", (e) => {
  if (e.key === "n") RJLCustom404.refreshImage();
});

console.log(RJLCustom404.version); // "1.3.0"

localStorage

Key Written when Value
rjl404_<hostname>_imageTwo or more images configured{ "queue": [remaining paths], "last": "<path>", "src": "<sorted path fingerprint>" } - the queue is discarded when the image set changes
rjl404_<hostname>_textautoText: true{ "titles": [], "headers": [], "subHeaders": [], "btnTexts": [] } - the remaining shuffled pools

No cookies. Nothing is sent anywhere. If storage is unavailable (private mode, blocked) the shuffle still works within a page load; it just does not remember across reloads.

Browser support

Every evergreen browser: Chrome, Edge, Firefox and Safari, on desktop, iOS and Android. The source uses ES2017 features (let/const, template literals, spread, String.prototype.padStart), so Internet Explorer is not supported.

Changelog and migrating from 1.x

1.2.0 removed the bundled images (see Getting started: Upgrading). 1.3.0 added homeUrl, imgAlt and RJLCustom404.version without changing anything that already worked. The full release history is in CHANGELOG.md on GitHub.