<div class="container">
<p>We want to indicate if there's more to scroll by adding a decreased opacity on the top or bottom element.</p>
<p>However, if we're at the top, we don't want to decrease the opacity of the first element. And if we're at the bottom, we don't want to decrease the opacity of the last element.</p>
<p>We'll use <a href="https://caniuse.com/mdn-css_properties_animation-timeline">Scroll Driven Animations</a> for this usecase and <a href="https://caniuse.com/mdn-css_at-rules_property">@property</a>. Chrome only for now. Do not use in production.</p>
<div class="scroll">
<p class="message me">Hey!</p>
<p class="message you">Hey!</p>
<p class="message me">What's up?</p>
<p class="message you">All good, you?</p>
<p class="message me">All good.</p>
<p class="message you">Cool.</p>
<p class="message me">Cool.</p>
<p class="message you">Cool.</p>
<p class="message me">Cool.</p>
<p class="message you">Cool.</p>
<p class="message me">Cool.</p>
<p class="message you">Cool.</p>
<p class="message me">Cool.</p>
<p class="message you">Cool.</p>
<p class="message me">Cool.</p>
<p class="message you">👋</p>
<p class="message me">👋</p>
</div>
</div>
@supports (animation-timeline: view()) {
/* The opacity of the element depending on its position in the scroll (near a border : 0.3, far from a border: 1) */
@property --opacity {
syntax: "<number>";
inherits: true;
initial-value: 1;
}
/* Depends on the proximity with the top of the scroll container (nothing to scroll top: 0, more than 3rem to scroll top: 1) */
@property --top-min-opacity {
syntax: "<number>";
inherits: true;
initial-value: 1;
}
/* Depends on the proximity with the top of the scroll container (nothing to scroll bottom: 0, more than 3rem to scroll bottom: 1) */
@property --bottom-min-opacity {
syntax: "<number>";
inherits: true;
initial-value: 1;
}
/*
* Indicates if the element is in the top half or bottom half of the view (top: 1, bottom: 0)
* This allows to indicate if it's the top-proximity or the bottom-proximity that should be used
* to compute the opacity
*/
@property --is-top {
syntax: "<number>";
inherits: true;
initial-value: 0;
}
/* y-distribution is at 0% when element above the scroll view, 100% when below the scroll view */
@keyframes y-distribution {
0% { --opacity: 0.3; --is-top: 1 }
25% { --opacity: 1 }
50% { --is-top: 1 }
51% { --is-top: 0 }
75% { --opacity: 1 }
100% { --opacity: 0.3; --is-top: 0 }
}
/* Based on the first 150px of the scroll */
@keyframes top-proximity {
0% { --top-min-opacity: 0 }
100% { --top-min-opacity: 1 }
}
/* Based on the last 150px of the scroll */
@keyframes bottom-proximity {
0% { --bottom-min-opacity: 1 }
100% { --bottom-min-opacity: 0 }
}
.message {
--min-opacity: var(--is-top) * var(--top-min-opacity)
+ (1 - var(--is-top)) * var(--bottom-min-opacity);
opacity: max(
var(--opacity),
var(--min-opacity)
);
animation: y-distribution ease-in-out reverse both,
top-proximity ease-in-out reverse both,
bottom-proximity ease-in-out reverse both;
animation-timeline: view(),
scroll(),
scroll();
animation-range: normal,
0% 3rem,
calc(100% - 3rem) 100%;
}
}
/* Global styles */
body {
min-height: 100vh;
min-height: 100dvh;
background: #f0f0f0;
font-family: system-ui, sans-serif;
padding-top: 1rem;
}
.container {
max-width: 30rem;
margin: 0 auto;
}
.scroll {
max-height: 500px;
overflow-y: auto;
margin-top: 3rem;
}
.message {
padding: 1rem;
border-radius: 1rem;
}
.message + .message {
margin-top: 1rem;
}
.message.you {
margin-right: 2rem;
background: white;
}
.message.me {
margin-left: 2rem;
background: rgb(207 58 0);
color: white;
text-align: right;
}
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.