Quickly navigate through the dashboard with your keyboard using the command menu.
A staggered two-column editorial gallery whose photos wipe open like curtains as they scroll into view — the clip-path reveal alternates direction per image while the photo inside settles from a slow zoom, and a tiny mono series number fades in after.
Curtain Image Reveal is a staggered two-column editorial gallery whose photos wipe open like curtains as they scroll into view. Each frame is clipped shut, then uncovers itself — the direction alternates per image — while the photo inside settles from a slow zoom and a tiny mono series number fades in a beat later. Reach for it on a photo index, a case-study grid, or any long page where images should arrive with a little editorial drama instead of just appearing.
This gem is CSS transitions plus a tiny observer script — no libraries, no build step.
index.html, assets/style.css, and assets/script.js into one folder.assets/medias/ folder along with them (it holds 01.webp through
06.webp, the six gallery photos).index.html in any modern browser.The demo also links assets/byq-brand.css, which supplies the BYQ brand tokens
(colors, fonts, radii) used throughout. Keep that stylesheet on the page so tokens
like --byq-accent-lime and --byq-radius resolve.
The whole effect lives inside one namespaced <section>. Inside it are two columns,
each holding three <figure> items. Every figure gets modifier classes that pick its
wipe direction and aspect ratio.
<section class="byq-gem byq-gem--curtain-image-reveal-01" aria-label="Photo series index">
<div class="byq-gem--curtain-image-reveal-01__columns">
<div class="byq-gem--curtain-image-reveal-01__col byq-gem--curtain-image-reveal-01__col--a">
<figure class="byq-gem--curtain-image-reveal-01__item byq-gem--curtain-image-reveal-01__item--wipe-lr byq-gem--curtain-image-reveal-01__item--r45">
<div class="byq-gem--curtain-image-reveal-01__frame">
<img class="byq-gem--curtain-image-reveal-01__img"
src="assets/medias/01.webp" width="900" height="1100" alt="" decoding="async" />
</div>
<figcaption class="byq-gem--curtain-image-reveal-01__caption">
<span class="byq-gem--curtain-image-reveal-01__no">NO. 003</span>
<span class="byq-gem--curtain-image-reveal-01__name">Half Frame</span>
</figcaption>
</figure>
<!-- two more figures … -->
</div>
<div class="byq-gem--curtain-image-reveal-01__col byq-gem--curtain-image-reveal-01__col--b">
<!-- three more figures … -->
</div>
</div>
</section>The key parts: __frame is the clipping window (the curtain), the __img sits inside
it, and __caption carries the series number and name. The modifiers do the styling
work — --wipe-lr, --wipe-tb, or --wipe-rl choose the reveal direction, and
--r45, --r34, --r11, --r78 set the rendered aspect ratio. Column B is marked
--col--b so it can be dropped lower and interleave with column A.
Every selector is prefixed with .byq-gem--curtain-image-reveal-01 so nothing leaks
into your page. The two columns are a flex row, and the second one starts lower so
figures alternate as you scroll.
.byq-gem--curtain-image-reveal-01__columns {
display: flex;
align-items: flex-start;
gap: clamp(1.25rem, 4vw, 3.5rem);
}
.byq-gem--curtain-image-reveal-01__col--b {
margin-top: clamp(5rem, 24vh, 11rem);
}The curtain itself is the __frame. It clips to an inset() that hides the image
entirely, and animates clip-path on a long, soft cubic-bezier. Each wipe class closes
a different edge, so revealing later means collapsing that one inset side back to 0.
.byq-gem--curtain-image-reveal-01__frame {
overflow: hidden;
border-radius: var(--byq-radius);
transition: clip-path 0.9s cubic-bezier(0.16, 1, 0.3, 1) var(--d);
}
.byq-gem--curtain-image-reveal-01__item--wipe-lr .byq-gem--curtain-image-reveal-01__frame {
clip-path: inset(0 100% 0 0); /* opens left → right */
}
.byq-gem--curtain-image-reveal-01__item--wipe-rl .byq-gem--curtain-image-reveal-01__frame {
clip-path: inset(0 0 0 100%); /* opens right → left */
}The photo starts pushed in at scale(1.25) and settles to scale(1). Because the
curtain opens while the photo shrinks, the two opposing motions read as parallax.
.byq-gem--curtain-image-reveal-01__img {
object-fit: cover;
transform: scale(1.25);
transition: transform 1.05s cubic-bezier(0.16, 1, 0.3, 1) var(--d);
}The whole thing is driven by a single .is-revealed class. When it lands, the frame's
clip-path opens fully, the image returns to scale(1), and the caption fades up — its
transition delay is calc(var(--d) + 0.7s), so the number arrives a beat after the
wipe. The --d custom property is the per-item delay the script sets. A
prefers-reduced-motion block forces everything visible with no transitions.
.byq-gem--curtain-image-reveal-01__item.is-revealed .byq-gem--curtain-image-reveal-01__frame {
clip-path: inset(0 0 0 0);
}
.byq-gem--curtain-image-reveal-01__item.is-revealed .byq-gem--curtain-image-reveal-01__img {
transform: scale(1);
}The script never animates anything — it only decides when each figure gets
.is-revealed. It's scoped to the gem root and touches nothing on window.
First it grabs the root, collects every __item, and bails early. If the reader prefers
reduced motion, it marks all items revealed and skips the observer.
const root = document.querySelector(".byq-gem--curtain-image-reveal-01");
const items = Array.from(root.querySelectorAll(".byq-gem--curtain-image-reveal-01__item"));
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
items.forEach((item) => item.classList.add("is-revealed"));
return;
}Next it splits the figures into two groups by measuring each one's
getBoundingClientRect(): those already on screen at load (aboveFold) versus those
still below (belowFold).
const aboveFold = [], belowFold = [];
items.forEach((item) => {
const r = item.getBoundingClientRect();
const visible = r.top < window.innerHeight * 0.92 && r.bottom > 0;
(visible ? aboveFold : belowFold).push(item);
});The already-visible figures reveal immediately in a top-to-bottom cascade. It sorts them
by vertical position and writes an increasing --d delay (i * 0.16s) onto each, so the
page never sits blank yet the curtains still open one beat apart.
aboveFold
.sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top)
.forEach((item, i) => {
item.style.setProperty("--d", `${(i * 0.16).toFixed(2)}s`);
reveal(item);
});Everything below the fold is handed to an IntersectionObserver with a threshold of
0.25. When a figure crosses ~25% into the viewport it's revealed once, then
unobserved so the curtain never re-closes.
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
reveal(entry.target);
observer.unobserve(entry.target);
});
}, { threshold: 0.25 });
belowFold.forEach((item) => observer.observe(item));01.webp–06.webp in assets/medias/ and update each
<img src>. All six are cropped from a 900×1100 source, so any portrait image works.--wipe-lr, --wipe-tb, and
--wipe-rl to flip which edge its curtain opens from.--r45, --r34, --r11, --r78 on a figure to re-crop it
(4/5, 3/4, 1/1, 7/8). Add --narrow or --end for the flush-right, off-width rhythm.0.9s / 1.05s transition durations on __frame and
__img, or the shared cubic-bezier(0.16, 1, 0.3, 1) ease, to make wipes snappier
or slower.i * 0.16 in script.js for a tighter or looser load
cascade, and the threshold: 0.25 for how far a figure must enter before it opens.transform: scale(1.25) sets the parallax pull —
lower it for a subtler settle.var(--byq-accent-lime) and the mono
var(--byq-font-mono); the frame corner is var(--byq-radius). Swap those tokens to
re-skin it.