Copied!
Hero

Circle reveal intro

A gate button opens a circle from the click point that grows to reveal the page.

← Library

Element · Vanilla JS + CSSUpdated November 5, 2026

HeroElementVanilla JS + CSS

Live preview needs JavaScript. The code below works without any dependencies.

Prompt for your AI assistant

Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.

Build "Circle reveal intro" for my site using the Enrichr Library element at https://enrichr.dev/elements/circle-reveal-intro.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/circle-reveal-intro.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.

Settings:
- title: Welcome
- label: Enter
- origin: click
- duration: 1100
- auto: 0
- coverColor: #10261f
- contentColor: #c6f26b
- textColor: #10261f

Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.

The same spec as JSON, for tools that read it directly: /elements/circle-reveal-intro.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-circle">
  <div class="ef-circle__cover"><button class="ef-circle__go" type="button">Enter</button></div>
  <div class="ef-circle__content"><h3 class="ef-circle__title"></h3></div>
</div>

CSS

.ef-circle { position: relative; min-height: 16rem; overflow: hidden; border-radius: 1rem; --cx: 50%; --cy: 50%; --ef-dur: 1100ms; }
.ef-circle__cover, .ef-circle__content { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
.ef-circle__go { appearance: none; border: 1px solid currentColor; background: transparent; color: inherit; font: inherit; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; padding: 0.8rem 1.6rem; border-radius: 100rem; cursor: pointer; }
.ef-circle__go:focus-visible { outline: 2px solid currentColor; outline-offset: 3px; }
.ef-circle__content { clip-path: circle(0% at var(--cx) var(--cy)); transition: clip-path var(--ef-dur) cubic-bezier(0.7, 0, 0.2, 1); }
.ef-circle.is-open .ef-circle__content { clip-path: circle(150% at var(--cx) var(--cy)); }
.ef-circle.is-done .ef-circle__content { clip-path: none; transition: none; }
.ef-circle__title { margin: 0; font-size: clamp(2rem, 7vw, 4.5rem); font-weight: 800; letter-spacing: -0.03em; text-align: center; padding: 0 1rem; }
@media (prefers-reduced-motion: reduce) { .ef-circle__content { transition: none; } }

JavaScript

// Circle reveal intro: a gate (button or automatic) opens a circle that grows from the click point and reveals the page.
// mount(root, { title, label, origin ("click" or "center"), duration (ms), auto (1 = reveal when scrolled into view, 0 = wait for the button),
//               coverColor, contentColor, textColor }) -> { update, replay, destroy }
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var box = root.querySelector(".ef-circle");
  var cover = box.querySelector(".ef-circle__cover");
  var content = box.querySelector(".ef-circle__content");
  var btn = box.querySelector(".ef-circle__go");
  var title = box.querySelector(".ef-circle__title");
  var opts = {}, opened = false, io = null, timer = 0;

  function lock(on) { content.inert = on; content.setAttribute("aria-hidden", on ? "true" : "false"); }
  function finish() { box.classList.add("is-done"); }
  function open(e) {
    if (opened) return;
    opened = true;
    var click = opts.origin === "click" && e && e.detail > 0 && typeof e.clientX === "number";
    if (click) {
      var r = box.getBoundingClientRect();
      box.style.setProperty("--cx", ((e.clientX - r.left) / r.width) * 100 + "%");
      box.style.setProperty("--cy", ((e.clientY - r.top) / r.height) * 100 + "%");
    } else {
      box.style.setProperty("--cx", "50%");
      box.style.setProperty("--cy", "50%");
    }
    lock(false);
    box.classList.add("is-open");
    if (reduce) finish(); else timer = setTimeout(finish, opts.duration + 50);
  }
  function replay() {
    clearTimeout(timer);
    opened = false;
    box.classList.remove("is-open", "is-done");
    void box.offsetWidth;
    lock(true);
    if (opts.auto && visible) timer = setTimeout(open, 350);
  }
  var visible = false;
  function update(next) {
    opts = Object.assign({ title: "Welcome", label: "Enter", origin: "click", duration: 1100, auto: 0, coverColor: "#10261f", contentColor: "#c6f26b", textColor: "#10261f" }, opts, next || {});
    cover.style.background = opts.coverColor;
    cover.style.color = opts.contentColor;
    content.style.background = opts.contentColor;
    content.style.color = opts.textColor;
    title.textContent = opts.title;
    btn.textContent = opts.label;
    btn.hidden = !!opts.auto;
    box.style.setProperty("--ef-dur", opts.duration + "ms");
    if (!opened) lock(true);
    if (opts.auto && !opened && visible) { clearTimeout(timer); timer = setTimeout(open, 350); }
  }
  update(o);
  btn.addEventListener("click", open);
  if ("IntersectionObserver" in window) {
    io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; if (visible && opts.auto && !opened) timer = setTimeout(open, 350); }, { threshold: 0.3 });
    io.observe(root);
  }
  return { update: update, replay: replay, destroy: function () { clearTimeout(timer); if (io) io.disconnect(); btn.removeEventListener("click", open); } };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"title":"Welcome","label":"Enter","origin":"click","duration":1100,"auto":0,"coverColor":"#10261f","contentColor":"#c6f26b","textColor":"#10261f"});

How it works

The revealed layer sits on top of the cover, clipped to a circle of zero size at a point set by two CSS variables. Opening adds a class that transitions the clip-path to a circle of 150% radius, which is enough to cover the box from any starting point. When the transition ends the clip is removed so nothing stays masked. Until it opens the content is inert and hidden from assistive technology, so nothing behind the gate can be focused.

Accessibility and performance

The gate is a real button with a visible focus style, and the content is inert until it opens. With reduced motion the reveal is instant. The automatic mode is for pages where a gate would only add friction.

A single clip-path transition on one layer. No JavaScript runs during the animation, and the clip is removed afterward.

SectionHero
TechniquesReveal and intro
StackVanilla JS, CSS
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested

Find this technique on a real site

Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.

Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns

Licence and provenance

Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.

This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.

Mask shape sectionsThe same image revealed through a circle, a diamond and a band as each section scrolls into view. Marquee tickerEndless horizontal text that pauses on hover, with speed, gap and direction controls. Split text revealA headline whose words rise out of a mask one after another when it scrolls into view.

FAQ

Why gate the intro at all?

A gate gives a page a moment of control: sound, heavy assets or an animation can start only after the visitor opts in. Use the automatic mode when you only want the visual.

Can I start the circle from the pointer?

Yes. With the click-point origin it starts where the button was pressed. Pressing it with the keyboard starts from the centre.

Will it block search engines?

Keep the real page content in the revealed layer as HTML. It is hidden by clip-path and inert, not removed, so it stays in the document.