Skip to content

Instantly share code, notes, and snippets.

@ejabu
Created May 31, 2026 12:08
Show Gist options
  • Select an option

  • Save ejabu/1c6bf4c7e0b77fb73d7dda9d7c128bc5 to your computer and use it in GitHub Desktop.

Select an option

Save ejabu/1c6bf4c7e0b77fb73d7dda9d7c128bc5 to your computer and use it in GitHub Desktop.
CSS Scroll Masking 🤙
<div class="resizer">
<section class="scroller">
<article>
<h1>Scroll Masking<span>PRO</span></h1>
<p>As you scroll a container, animate its <code>mask-size</code> composited with <code>exclude</code>.
<p>Lorem ipsum dolor sit, amet consectetur adipisicing, elit. Non, earum omnis nihil, atque labore fugit provident minus voluptas a laboriosam amet quidem minima molestias ipsum eos ea dicta quod corrupti.</p>
<p>Aliquam dolores ipsum sit cum delectus voluptas, repellat obcaecati deleniti omnis alias sed sapiente debitis repudiandae sunt, autem quisquam. Corporis itaque, beatae dolorem repellendus! Alias nobis ipsum rem odit dicta.</p>
<p>Exercitationem voluptatem maxime quas facilis sequi repellendus laudantium, mollitia eaque cum cumque saepe magnam enim explicabo quia autem totam, placeat vitae voluptatibus ipsa accusantium distinctio, quibusdam eos iste molestiae! Quaerat!</p>
<svg
class="sig"
viewBox="0 0 271 209"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M40.3725 26.8984C58.6558 41.1564 141.659 43.1867 128.248 5.48254C127.911 4.53766 127.085 2.2403 125.938 2.0095C124.714 1.76297 121.929 6.39448 121.627 6.82375C100.965 36.1863 95.2641 73.5992 74.5923 102.644C63.7045 117.942 14.7891 145.678 5.55986 113.481C-17.5939 32.705 78.7483 76.0672 105.741 67.4678C119.757 63.0021 125.297 50.6825 132.831 39.1622C135.218 35.5126 137.628 24.6153 140.043 28.2467C144.771 35.3581 119.642 69.8761 115.559 78.4692C110.959 88.1482 129.228 46.7461 136.796 54.3333C146.229 63.7897 128.236 82.7359 153.367 61.6804C157.634 58.1059 166.582 46.4029 161.033 46.8455C153.977 47.4085 141.565 67.0198 151.685 70.0327C161.531 72.9635 176.039 38.7196 174.012 48.7901C173.009 53.769 168.343 67.3695 175.978 68.9069C186.537 71.0328 191.574 35.8659 197.537 44.8359C240.356 109.24 81.7126 283.324 50.2184 167.261C25.2159 75.1229 240.563 89.2082 268.88 137.08"
class="sign"
stroke="currentColor"
stroke-width="4"
stroke-miterlimit="10"
stroke-linecap="round"
stroke-linejoin="round"
class="head"
pathLength="1"
style="--path-speed: 2.1467741935483873"
></path>
<path
class="ear"
d="M187.183 101.246C182.107 82.5407 155.739 77.9455 151.5 99"
stroke="currentColor"
stroke-width="4"
stroke-miterlimit="10"
stroke-linecap="round"
stroke-linejoin="round"
pathLength="1"
style="
--path-speed: 0.08225806451612903;
--path-delay: 2.1467741935483873;
"
></path>
<path
class="ear"
d="M117.998 100.704C117.998 91.1516 103.912 87.3662 96.5585 89.3717C84.7816 92.5836 80.6315 99.053 80.6315 110.505"
stroke="currentColor"
stroke-width="4"
stroke-miterlimit="10"
stroke-linecap="round"
stroke-linejoin="round"
pathLength="1"
style="
--path-speed: 0.09193548387096774;
--path-delay: 2.229032258064516;
"
></path>
<path
class="eye"
d="M170.025 108.347C168.627 105.551 162.781 110.631 165.494 114.577C168.207 118.523 173.936 114.091 171.643 109.965C171.035 108.871 168.547 107.832 167.355 108.428"
stroke="currentColor"
stroke-width="4"
stroke-miterlimit="10"
stroke-linecap="round"
stroke-linejoin="round"
pathLength="1"
style="
--path-speed: 0.04516129032258064;
--path-delay: 2.3209677419354837;
"
></path>
<path
class="eye"
d="M102.952 112.797C97.2672 112.797 96.7371 120.527 102.224 119.917C108.363 119.235 105.409 110.012 100.363 113.04"
stroke="currentColor"
stroke-width="4"
stroke-miterlimit="10"
stroke-linecap="round"
stroke-linejoin="round"
pathLength="1"
style="
--path-speed: 0.041935483870967745;
--path-delay: 2.366129032258064;
"
></path>
<path
class="nose"
d="M144.745 123.82C146.652 122.562 141.479 121.621 140.561 121.402C136.485 120.429 124.736 118.793 124.42 125.721C123.695 141.628 160.767 131.457 140.492 121.735"
stroke="currentColor"
stroke-width="4"
stroke-miterlimit="10"
stroke-linecap="round"
stroke-linejoin="round"
pathLength="1"
style="
--path-speed: 0.1032258064516129;
--path-delay: 2.408064516129032;
"
></path>
</svg>
</article>
</div>
</section>
</div>
<a
class="bear-link"
href="https://twitter.com/intent/follow?screen_name=jh3yy"
target="_blank"
rel="noreferrer noopener"
>
<svg
class="w-9"
viewBox="0 0 969 955"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="161.191"
cy="320.191"
r="133.191"
stroke="currentColor"
stroke-width="20"
></circle>
<circle
cx="806.809"
cy="320.191"
r="133.191"
stroke="currentColor"
stroke-width="20"
></circle>
<circle
cx="695.019"
cy="587.733"
r="31.4016"
fill="currentColor"
></circle>
<circle
cx="272.981"
cy="587.733"
r="31.4016"
fill="currentColor"
></circle>
<path
d="M564.388 712.083C564.388 743.994 526.035 779.911 483.372 779.911C440.709 779.911 402.356 743.994 402.356 712.083C402.356 680.173 440.709 664.353 483.372 664.353C526.035 664.353 564.388 680.173 564.388 712.083Z"
fill="currentColor"
></path>
<rect
x="310.42"
y="448.31"
width="343.468"
height="51.4986"
fill="#FF1E1E"
></rect>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M745.643 288.24C815.368 344.185 854.539 432.623 854.539 511.741H614.938V454.652C614.938 433.113 597.477 415.652 575.938 415.652H388.37C366.831 415.652 349.37 433.113 349.37 454.652V511.741L110.949 511.741C110.949 432.623 150.12 344.185 219.845 288.24C289.57 232.295 384.138 200.865 482.744 200.865C581.35 200.865 675.918 232.295 745.643 288.24Z"
fill="currentColor"
></path>
</svg>
</a>
import { Pane } from 'https://esm.sh/tweakpane@4.0.4'
import gsap from 'https://esm.sh/gsap@3.12.0'
import ScrollTrigger from 'https://esm.sh/gsap@3.12.0/ScrollTrigger'
const CONFIG = {
size: 120,
trigger: false,
bar: true,
range: 120,
light: false,
}
const CTRL = new Pane({ title: 'Config', expanded: false })
CTRL.addBinding(CONFIG, 'size', { label: 'Mask Size', min: 1, max: 500, step: 1})
CTRL.addBinding(CONFIG, 'range', { label: 'Mask Range', min: 1, max: 500, step: 1})
CTRL.addBinding(CONFIG, 'bar', { label: 'Mask Bar' })
CTRL.addBinding(CONFIG, 'trigger', { label: 'Trigger'})
CTRL.addBinding(CONFIG, 'light', { label: 'Light Mode'})
const update = () => {
ScrollTrigger.refresh()
document.documentElement.dataset.light = CONFIG.light
document.documentElement.dataset.trigger = CONFIG.trigger
document.documentElement.dataset.maskBar = CONFIG.bar
document.documentElement.style.setProperty('--mask-size', CONFIG.size)
document.documentElement.style.setProperty('--mask-range', CONFIG.range)
}
CTRL.on('change', (event) => {
if (!document.startViewTransition || event.target.controller.view.labelElement.innerText !== 'Light Mode') return update()
document.startViewTransition(update)
})
update()
if (!CSS.supports('animation-timeline: scroll()')) {
gsap.registerPlugin(ScrollTrigger)
const scroller = document.querySelector('.scroller')
const sig = document.querySelector('.sig')
// In GSAP, jus' gonna do a scroll custom property based on the distance
ScrollTrigger.create({
scroller,
scrub: true,
start: 0,
end: () => CONFIG.range,
ease: 'none',
trigger: 'article',
onUpdate: (self) => {
scroller.style.setProperty('--scroll-progress-top', CONFIG.trigger ? Math.floor(self.progress) * 100 : self.progress * 100)
}
})
ScrollTrigger.create({
scroller,
trigger: 'article',
scrub: true,
ease: 'none',
start: () => {
return ScrollTrigger.maxScroll(scroller) - (CONFIG.range * 1)
},
end: () => {
return ScrollTrigger.maxScroll(scroller)
},
onUpdate: (self) => {
scroller.style.setProperty('--scroll-progress-bottom', CONFIG.trigger ? Math.ceil(self.progress) * 100 : self.progress * 100)
}
})
gsap.fromTo('.sig path', {
'--draw': 1.025,
}, {
'--draw': 0,
scrollTrigger: {
trigger: sig,
// scrub: true,
scroller,
toggleActions: 'play reset play reset',
// start: `top bottom-=${sig.getBoundingClientRect().height * 0.5}`,
start: `top bottom-=${sig.getBoundingClientRect().height * 0.5}`,
}
})
const obs = new ResizeObserver(ScrollTrigger.refresh)
obs.observe(scroller)
}
@import url('https://unpkg.com/normalize.css') layer(normalize);
@layer base {
* {
box-sizing: border-box;
}
:root {
color-scheme: light only;
--accent: hsl(10 90% 50%);
}
[data-light='false'] {
color-scheme: dark only;
}
body {
display: grid;
place-items: center;
min-height: 100vh;
accent-color: hsl(250 90% 70%);
background: var(--bg);
font-family: 'SF Pro Text', 'SF Pro Icons', 'AOS Icons', 'Helvetica Neue';
line-height: 1.6;
color: var(--color);
}
body::before {
--line: color-mix(in lch, canvasText, transparent 65%);
--size: 40px;
content: '';
height: 100vh;
width: 100vw;
position: fixed;
background: linear-gradient(
90deg,
var(--line) 1px,
transparent 1px var(--size)
)
0 -5vmin / var(--size) var(--size),
linear-gradient(var(--line) 1px, transparent 1px var(--size)) 0 -5vmin / var(
--size
) var(--size);
mask: linear-gradient(-15deg, transparent 80%, white);
top: 0;
z-index: -1;
}
p::selection {
background: var(--accent);
}
p:first-of-type {
font-weight: 600;
text-wrap: balance;
line-height: 1.2;
}
p:not(:first-of-type) {
opacity: .875;
font-weight: 300;
}
p {
padding: 0;
margin: 0;
}
}
h1 {
font-size: 1.5rem;
margin: 0;
position: relative;
display: inline-block;
align-self: start;
}
h1 span {
position: absolute;
left: calc(100% + 0.25ch);
bottom: 50%;
font-size: 0.5em;
color: var(--accent);
}
@layer scroller {
.resizer {
--bg: color-mix(in lch, canvas, canvasText 2%);
width: 36ch;
aspect-ratio: 3 / 4;
resize: both;
overflow: hidden;
min-width: 260px;
min-height: 320px;
max-height: calc(100vh - 4rem);
max-width: calc(100vw - 4rem);
background:
linear-gradient(var(--bg), var(--bg)) padding-box,
linear-gradient(
color-mix(in lch, canvasText, transparent 70%),
color-mix(in lch, canvasText, transparent 90%)
) border-box;
border-radius: 12px;
border: 1px solid transparent;
padding: 0.25rem;
}
.scroller {
width: 100%;
height: 100%;
overflow: auto;
position: relative;
overflow-x: hidden;
padding: 0.75rem;
scrollbar-width: thin;
scrollbar-color: var(--accent) transparent;
}
article {
display: flex;
gap: 1rem;
flex-direction: column;
}
}
@layer mask {
.scroller {
--mask-width: 100%;
mask: linear-gradient(#000, #0000)
0 0 / var(--mask-width) calc((var(--scroll-progress-top, 0) / 100) * (var(--mask-size) * 1px)) no-repeat,
linear-gradient(#000, #000) 0 50% / 100% 100% no-repeat,
linear-gradient(#0000, #000)
0 100% / var(--mask-width) calc(((100 - var(--scroll-progress-bottom, 100)) / 100) * (var(--mask-size) * 1px)) no-repeat;
mask-composite: exclude;
}
[data-mask-bar="false"] .scroller {
--mask-width: calc(100% - 10px);
}
[data-trigger="true"] .scroller {
transition: mask-size 0.25s linear(
0 0%, 0.0027 3.64%, 0.0106 7.29%,
0.0425 14.58%, 0.0957 21.87%,
0.1701 29.16%, 0.2477 35.19%,
0.3401 41.23%, 0.5982 55.18%,
0.7044 61.56%, 0.7987 68.28%, 0.875 75%,
0.9297 81.25%, 0.9687 87.5%,
0.9922 93.75%, 1 100%
);
}
@supports (animation-timeline: scroll()) {
.scroller {
scroll-timeline: --scroller;
mask: linear-gradient(white, transparent) 0 0 / var(--mask-width) 0 no-repeat,
linear-gradient(white, white) 0 50% / 100% 100% no-repeat,
linear-gradient(transparent, white) 0 100% / var(--mask-width) calc(var(--mask-size) * 1px) no-repeat;
mask-composite: exclude;
mask-repeat: none;
animation: mask-up both linear, mask-down both linear;
animation-timeline: scroll(self);
animation-range: 0 calc(var(--mask-range) * 1px), calc(100% - (var(--mask-range) * 1px)) 100%;
}
@keyframes mask-up {
100% {
mask-size: var(--mask-width) calc(var(--mask-size) * 1px), 100% 100%, var(--mask-width) calc(var(--mask-size) * 1px);
}
}
@keyframes mask-down {
100% {
mask-size: var(--mask-width) calc(var(--mask-size) * 1px), 100% 100%, var(--mask-width) 0;
}
}
@property --trigger-up {
initial-value: 0;
inherits: true;
syntax: '<integer>';
}
@property --trigger-down {
initial-value: 1;
inherits: true;
syntax: '<integer>';
}
[data-trigger=true] {
timeline-scope: --scroller;
}
[data-trigger=true] .resizer {
animation: trigger-up both linear, trigger-down both linear;
animation-timeline: --scroller;
animation-range: 0 calc(var(--mask-range) * 1px), calc(100% - (var(--mask-range) * 1px)) 100%;
}
[data-trigger=true] .scroller {
animation: none;
mask: linear-gradient(white, transparent) 0 0 / var(--mask-width) calc(var(--trigger-up, 0) * (var(--mask-size) * 1px)) no-repeat,
linear-gradient(white, white) 0 50% / 100% 100% no-repeat,
linear-gradient(transparent, white) 0 100% / var(--mask-width) calc(var(--trigger-down, 0) * (var(--mask-size) * 1px)) no-repeat;
mask-composite: exclude;
mask-repeat: none;
}
@keyframes trigger-up {
100% {
--trigger-up: 1;
}
}
@keyframes trigger-down {
100% {
--trigger-down: 0;
}
}
}
}
@layer sign-off {
.sig path {
--draw: 1.025;
--end: 1.025;
stroke-dasharray: var(--end);
stroke-dashoffset: var(--draw);
}
.sig :is(.eye, .nose) {
fill: transparent;
}
.sig {
width: max(100px, 20vmin);
max-width: 35%;
color: color-mix(in lch, canvasText, canvas 25%);
z-index: 10;
rotate: -15deg;
translate: -5% 0;
pointer-events: none;
align-self: flex-end;
margin: 1rem 0 2rem 0;
}
@supports (animation-timeline: scroll()) {
@property --sign {
initial-value: 0;
inherits: true;
syntax: '<integer>';
}
.sig {
animation: sign both steps(1, end);
animation-timeline: view();
animation-range: entry;
}
.sig path {
animation: none;
stroke-dashoffset: calc(1.025 - (var(--sign) * 1.025));
transition: stroke-dashoffset 1s 0.25s;
}
.sig :is(.eye, .nose) {
animation: none;
}
@keyframes sign {
to { --sign: 1; }
}
}
}
@layer socials {
.bear-link {
color: currentColor;
position: fixed;
top: 1rem;
left: 1rem;
width: 48px;
aspect-ratio: 1;
display: grid;
place-items: center;
opacity: 0.8;
}
:where(.x-link, .bear-link):is(:hover, :focus-visible) {
opacity: 1;
}
.bear-link svg {
width: 75%;
}
}
@layer transition {
@property --column-one {
inherits: true;
initial-value: 0;
syntax: '<number>';
}
@property --column-two {
inherits: true;
initial-value: 0;
syntax: '<number>';
}
@property --column-three {
inherits: true;
initial-value: 0;
syntax: '<number>';
}
@property --column-four {
inherits: true;
initial-value: 0;
syntax: '<number>';
}
@property --column-five {
inherits: true;
initial-value: 0;
syntax: '<number>';
}
@keyframes one { from { --column-one: 100; }}
@keyframes two { from { --column-two: 100; }}
@keyframes three { from { --column-three: 100; }}
@keyframes four { from { --column-four: 100; }}
@keyframes five { from { --column-five: 100; }}
::view-transition-new(root) {
clip-path: polygon(
/* 1st column */
0 100%, 0 calc(var(--column-one) * 1%), 20% calc(var(--column-one) * 1%), 20% 100%,
/* 2nd column */
20% 100%, 20% calc(var(--column-two) * 1%), 40% calc(var(--column-two) * 1%), 40% 100%,
/* 3rd column */
40% 100%, 40% calc(var(--column-three) * 1%), 60% calc(var(--column-three) * 1%), 60% 100%,
/* 4th column */
60% 100%, 60% calc(var(--column-four) * 1%), 80% calc(var(--column-four) * 1%), 80% 100%,
/* 5th column */
80% 100%, 80% calc(var(--column-five) * 1%), 100% calc(var(--column-five) * 1%), 100% 100%
);
}
::view-transition-new(root) {
--speed: 0.625;
--columns: 5;
animation:
one calc(var(--speed) * 1s) calc(sin((0 / 5) * 45deg) * var(--speed) * 1s),
two calc(var(--speed) * 1s) calc(sin((1 / 5) * 45deg) * var(--speed) * 1s),
three calc(var(--speed) * 1s) calc(sin((2 / 5) * 45deg) * var(--speed) * 1s),
four calc(var(--speed) * 1s) calc(sin((3 / 5) * 45deg) * var(--speed) * 1s),
five calc(var(--speed) * 1s) calc(sin((4 / 5) * 45deg) * var(--speed) * 1s);
animation-fill-mode: both;
animation-timing-function: linear(
0 0%, 0.0027 3.64%, 0.0106 7.29%,
0.0425 14.58%, 0.0957 21.87%,
0.1701 29.16%, 0.2477 35.19%,
0.3401 41.23%, 0.5982 55.18%,
0.7044 61.56%, 0.7987 68.28%, 0.875 75%,
0.9297 81.25%, 0.9687 87.5%,
0.9922 93.75%, 1 100%
);
z-index: 2;
}
::view-transition-old(root) {
animation: none;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment