Skip to content

Commit aea6034

Browse files
committed
✨ feat(scroll-area): add global styles and scroll-driven fade effect
- Introduced `ScrollAreaGlobalStyle` to register custom properties for scroll-driven animations. - Updated `ScrollAreaViewport` to include global styles and enhance the fade effect using `mask-image`. - Modified styles to support scroll-driven animations, improving the visual experience of the scroll area. - Added a new demo showcasing the gradient background with scroll fade effect. This update enhances the ScrollArea component by providing a more dynamic and visually appealing scrolling experience. Signed-off-by: Innei <tukon479@gmail.com>
1 parent 68ea5bb commit aea6034

File tree

5 files changed

+234
-55
lines changed

5 files changed

+234
-55
lines changed

src/ScrollArea/atoms.tsx

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ScrollArea as BaseScrollArea } from '@base-ui/react/scroll-area';
44
import { cx } from 'antd-style';
55
import type React from 'react';
66

7+
import ScrollAreaGlobalStyle from './globalStyle';
78
import { styles } from './style';
89

910
const mergeStateClassName = <TState,>(
@@ -41,15 +42,18 @@ export const ScrollAreaViewport = ({
4142
...rest
4243
}: ScrollAreaViewportProps) => {
4344
return (
44-
<BaseScrollArea.Viewport
45-
{...rest}
46-
className={
47-
mergeStateClassName(
48-
cx(styles.viewport, scrollFade && styles.viewportFade),
49-
className,
50-
) as any
51-
}
52-
/>
45+
<>
46+
<ScrollAreaGlobalStyle />
47+
<BaseScrollArea.Viewport
48+
{...rest}
49+
className={
50+
mergeStateClassName(
51+
cx(styles.viewport, scrollFade && styles.viewportFade),
52+
className,
53+
) as any
54+
}
55+
/>
56+
</>
5357
);
5458
};
5559

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { ScrollArea } from '@lobehub/ui';
2+
3+
const blocks = [
4+
{
5+
accent: 'linear-gradient(135deg, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0.1))',
6+
bg: 'rgba(255, 255, 255, 0.55)',
7+
desc: 'A translucent block — the fade should reveal the gradient behind, not a fixed background color.',
8+
title: 'Glass / Light',
9+
},
10+
{
11+
accent: 'linear-gradient(135deg, rgba(34, 197, 94, 0.55), rgba(34, 197, 94, 0.1))',
12+
bg: 'rgba(34, 197, 94, 0.16)',
13+
desc: 'Different block color. The mask fade remains consistent.',
14+
title: 'Mint',
15+
},
16+
{
17+
accent: 'linear-gradient(135deg, rgba(59, 130, 246, 0.55), rgba(59, 130, 246, 0.1))',
18+
bg: 'rgba(59, 130, 246, 0.16)',
19+
desc: 'Scroll a bit — the top fade ramps in, bottom ramps out near the end.',
20+
title: 'Azure',
21+
},
22+
{
23+
accent: 'linear-gradient(135deg, rgba(236, 72, 153, 0.55), rgba(236, 72, 153, 0.1))',
24+
bg: 'rgba(236, 72, 153, 0.16)',
25+
desc: 'Every block has its own tint; the background stays a multi-stop gradient.',
26+
title: 'Rose',
27+
},
28+
{
29+
accent: 'linear-gradient(135deg, rgba(245, 158, 11, 0.55), rgba(245, 158, 11, 0.1))',
30+
bg: 'rgba(245, 158, 11, 0.16)',
31+
desc: 'This demo highlights that the scroll fade is a mask on content, not an overlay gradient.',
32+
title: 'Amber',
33+
},
34+
];
35+
36+
export default () => {
37+
return (
38+
<ScrollArea
39+
contentProps={{ style: { padding: 16 } }}
40+
scrollFade
41+
style={{
42+
background:
43+
'radial-gradient(1200px 240px at 20% 0%, rgba(59, 130, 246, 0.28), transparent 55%), radial-gradient(900px 240px at 85% 10%, rgba(236, 72, 153, 0.22), transparent 50%), linear-gradient(135deg, rgba(16, 24, 40, 0.05), rgba(16, 24, 40, 0.02))',
44+
height: 280,
45+
maxWidth: 'calc(100vw - 8rem)',
46+
width: '100%',
47+
}}
48+
viewportProps={{
49+
// Make the viewport background transparent so the root gradient is visible.
50+
style: { background: 'transparent' },
51+
}}
52+
>
53+
{blocks.map((item) => (
54+
<section
55+
key={item.title}
56+
style={{
57+
background: item.bg,
58+
border: '1px solid rgba(16, 24, 40, 0.06)',
59+
borderRadius: 12,
60+
overflow: 'hidden',
61+
}}
62+
>
63+
<div
64+
style={{
65+
background: item.accent,
66+
borderBottom: '1px solid rgba(16, 24, 40, 0.06)',
67+
padding: '10px 12px',
68+
}}
69+
>
70+
<div style={{ fontSize: 13, fontWeight: 600 }}>{item.title}</div>
71+
</div>
72+
<p style={{ color: 'var(--lobe-color-text-secondary)', margin: 0, padding: 12 }}>
73+
{item.desc}
74+
</p>
75+
</section>
76+
))}
77+
</ScrollArea>
78+
);
79+
};

src/ScrollArea/globalStyle.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
'use client';
2+
3+
import { createGlobalStyle, css } from 'antd-style';
4+
5+
/**
6+
* Register animatable custom properties used by scroll-driven animations.
7+
*
8+
* Without @property registration, custom properties interpolate discretely,
9+
* which can cause visible snapping at scroll boundaries.
10+
*/
11+
const ScrollAreaGlobalStyle = createGlobalStyle(
12+
() => css`
13+
@property --lobe-scroll-area-fade-top {
14+
inherits: true;
15+
initial-value: 0;
16+
syntax: '<length>';
17+
}
18+
19+
@property --lobe-scroll-area-fade-bottom {
20+
inherits: true;
21+
initial-value: 0;
22+
syntax: '<length>';
23+
}
24+
`,
25+
);
26+
27+
export default ScrollAreaGlobalStyle;

src/ScrollArea/index.md

Lines changed: 52 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ description: A native scroll container with custom scrollbars and gradient scrol
99

1010
<code src="./demos/index.tsx" nopadding></code>
1111

12+
## Mask on gradient background
13+
14+
<code src="./demos/background.tsx" nopadding></code>
15+
1216
## Both scrollbars
1317

1418
<code src="./demos/both.tsx" nopadding></code>
@@ -46,33 +50,62 @@ import {
4650
</ScrollAreaRoot>;
4751
```
4852

49-
## Gradient scroll fade
53+
## Scroll fade (mask, scroll-driven)
5054

5155
Enable the fade by setting `scrollFade` on `ScrollArea` or `ScrollAreaViewport`.
5256

53-
The fade effect is driven by the viewport overflow CSS variables. These variables do not inherit by default for performance reasons, so the styles on `ScrollArea.Viewport` opt in by setting them to `inherit` on the pseudo-elements.
57+
The fade effect is implemented as a `mask-image` (so it works on background images too).
58+
59+
When supported, it uses **scroll-driven animations** via `animation-timeline: scroll()` to drive the mask based on scroll position (see MDN: `https://developer.mozilla.org/ja/docs/Web/CSS/Reference/Properties/animation-timeline`). When not supported, it falls back to Base UI overflow CSS variables.
5460

5561
```css
56-
.Viewport::before {
62+
.Viewport {
5763
--scroll-area-overflow-y-start: inherit;
58-
top: 0;
59-
height: min(40px, var(--scroll-area-overflow-y-start));
60-
background: linear-gradient(to bottom, var(--lobe-color-bg-layout), transparent);
61-
}
62-
63-
.Viewport::after {
6464
--scroll-area-overflow-y-end: inherit;
65-
bottom: 0;
66-
height: min(40px, var(--scroll-area-overflow-y-end, 40px));
67-
background: linear-gradient(to top, var(--lobe-color-bg-layout), transparent);
68-
}
69-
```
7065

71-
For SSR, use the fallback value in `var()` when reading `--scroll-area-overflow-y-end`.
72-
73-
```css
74-
.Viewport::after {
75-
height: min(40px, var(--scroll-area-overflow-y-end, 40px));
66+
--fade-size: 40px;
67+
--fade-top: min(var(--fade-size), var(--scroll-area-overflow-y-start, 0px));
68+
--fade-bottom: min(var(--fade-size), var(--scroll-area-overflow-y-end, 0px));
69+
70+
@supports (animation-timeline: scroll()) {
71+
@keyframes fadeY {
72+
0% {
73+
--fade-top: 0px;
74+
--fade-bottom: var(--fade-size);
75+
}
76+
10%,
77+
90% {
78+
--fade-top: var(--fade-size);
79+
--fade-bottom: var(--fade-size);
80+
}
81+
100% {
82+
--fade-top: var(--fade-size);
83+
--fade-bottom: 0px;
84+
}
85+
}
86+
87+
animation: fadeY 1ms linear both;
88+
animation-timeline: scroll(self y);
89+
}
90+
91+
-webkit-mask-image: linear-gradient(
92+
to bottom,
93+
transparent 0,
94+
#000 var(--fade-top),
95+
#000 calc(100% - var(--fade-bottom)),
96+
transparent 100%
97+
);
98+
mask-image: linear-gradient(
99+
to bottom,
100+
transparent 0,
101+
#000 var(--fade-top),
102+
#000 calc(100% - var(--fade-bottom)),
103+
transparent 100%
104+
);
105+
-webkit-mask-repeat: no-repeat;
106+
mask-repeat: no-repeat;
107+
-webkit-mask-size: 100% 100%;
108+
mask-size: 100% 100%;
76109
}
77110
```
78111

src/ScrollArea/style.ts

Lines changed: 63 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -101,37 +101,73 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({
101101
`,
102102

103103
viewportFade: css`
104-
&::before,
105-
&::after {
106-
pointer-events: none;
107-
content: '';
108-
109-
position: sticky;
110-
z-index: 1;
111-
inset-inline-start: 0;
112-
113-
display: block;
114-
115-
width: 100%;
116-
border-radius: ${cssVar.borderRadius};
117-
118-
transition: height 0.1s ease-out;
119-
}
104+
--scroll-area-overflow-y-start: inherit;
105+
--scroll-area-overflow-y-end: inherit;
106+
--lobe-scroll-area-fade-size: 40px;
107+
--lobe-scroll-area-fade-top: min(
108+
var(--lobe-scroll-area-fade-size),
109+
var(--scroll-area-overflow-y-start, 0px)
110+
);
111+
--lobe-scroll-area-fade-bottom: min(
112+
var(--lobe-scroll-area-fade-size),
113+
var(--scroll-area-overflow-y-end, 0px)
114+
);
115+
116+
/* Fade the CONTENT via mask, so it works on background images too. */
117+
mask-image: linear-gradient(
118+
to bottom,
119+
transparent 0,
120+
#000 var(--lobe-scroll-area-fade-top),
121+
#000 calc(100% - var(--lobe-scroll-area-fade-bottom)),
122+
transparent 100%
123+
);
124+
mask-image: linear-gradient(
125+
to bottom,
126+
transparent 0,
127+
#000 var(--lobe-scroll-area-fade-top),
128+
#000 calc(100% - var(--lobe-scroll-area-fade-bottom)),
129+
transparent 100%
130+
);
131+
mask-repeat: no-repeat;
132+
mask-repeat: no-repeat;
133+
mask-size: 100% 100%;
134+
mask-size: 100% 100%;
135+
136+
/* Scroll-driven animation: use scroll position to drive the mask. */
137+
@supports (animation-timeline: scroll()) {
138+
/*
139+
* Important: drive fade by *distance to edges* (first/last 40px),
140+
* so reaching top/bottom doesn't cause a sudden snap.
141+
*/
142+
@keyframes lobe-scroll-area-fade-top-in {
143+
from {
144+
--lobe-scroll-area-fade-top: 0;
145+
}
146+
147+
to {
148+
--lobe-scroll-area-fade-top: var(--lobe-scroll-area-fade-size);
149+
}
150+
}
120151
121-
&::before {
122-
--scroll-area-overflow-y-start: inherit;
152+
@keyframes lobe-scroll-area-fade-bottom-out {
153+
from {
154+
--lobe-scroll-area-fade-bottom: var(--lobe-scroll-area-fade-size);
155+
}
123156
124-
inset-block-start: 0;
125-
height: min(40px, var(--scroll-area-overflow-y-start));
126-
background: linear-gradient(to bottom, ${cssVar.colorBgLayout}, transparent);
127-
}
157+
to {
158+
--lobe-scroll-area-fade-bottom: 0;
159+
}
160+
}
128161
129-
&::after {
130-
--scroll-area-overflow-y-end: inherit;
162+
animation-name: lobe-scroll-area-fade-top-in, lobe-scroll-area-fade-bottom-out;
163+
animation-duration: 1ms, 1ms;
164+
animation-timing-function: linear, linear;
165+
animation-fill-mode: both, both;
166+
animation-timeline: scroll(self y), scroll(self y);
131167
132-
inset-block-end: 0;
133-
height: min(40px, var(--scroll-area-overflow-y-end, 40px));
134-
background: linear-gradient(to top, ${cssVar.colorBgLayout}, transparent);
168+
animation-range:
169+
0 var(--lobe-scroll-area-fade-size),
170+
calc(100% - var(--lobe-scroll-area-fade-size)) 100%;
135171
}
136172
`,
137173
}));

0 commit comments

Comments
 (0)