Adding Scroll Shadows to Overflow Containers

Implementing shadows to indicate hidden content using HTML, CSS, and JavaScript.

Scroll shadows are a great way to indicate to users that there is more to see in a particular direction in an overflow scroll container. Here is the effect we're going to produce:

When first investigating how to implement this, I stumbled upon solutions that were not compatible with content in the container having background colours, or npm packages that were overly complicated to setup.

I'll show you both an HTML + JavaScript version, as well as a Vue version that should get you started. We'll also pull in Bootstrap just to give us some striped table styling, as well as some utility classes.

HTML + JavaScript Version

1<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha2/dist/css/bootstrap.min.css" rel="stylesheet"/>
2 
3<div class="position-relative border" style="inset:0;">
4 <div id="scroll-container" class="overflow-auto" style="max-height:20rem;">
5 <div class="vertical-scroll-shadow"></div>
6 <div class="horizontal-scroll-shadow"></div>
7 
8 <table class="table table-striped mb-0 text-nowrap">
9 <thead>
10 <tr>
11 <th>Column 1</th>
12 <th>Column 2</th>
13 <th>Column 3</th>
14 <th>Column 4</th>
15 <th>Column 5</th>
16 <th>Column 6</th>
17 </tr>
18 </thead>
19 <tbody>
20 <tr>
21 <td>Lorem ipsum dolor sit amet</td>
22 <td>Consectetur adipiscing elit</td>
23 <td>Sed do eiusmod tempor incididunt</td>
24 <td>Ut labore et dolore magna aliqua</td>
25 <td>Ut enim ad minim veniam</td>
26 <td>Quis nostrud exercitation ullamco</td>
27 </tr>
28 <!-- ... -->
29 </tbody>
30 </table>
31 </div>
32</div>
33 
34<script>
35document.addEventListener('DOMContentLoaded', function() {
36 const scrollContainer = document.getElementById('scroll-container');
37 
38 const verticalScrollShadow = scrollContainer.querySelector('.vertical-scroll-shadow');
39 const horizontalScrollShadow = scrollContainer.querySelector('.horizontal-scroll-shadow');
40 
41 function updateShadows() {
42 const scrollLeft = scrollContainer.scrollLeft;
43 const maxScrollLeft = scrollContainer.scrollWidth - scrollContainer.clientWidth;
44 
45 const scrollTop = scrollContainer.scrollTop;
46 const maxScrollTop = scrollContainer.scrollHeight - scrollContainer.clientHeight;
47 
48 // Disable both horizontal shadows if there's no horizontal scrollable area.
49 if (scrollContainer.scrollWidth <= scrollContainer.clientWidth) {
50 horizontalScrollShadow.classList.remove(['shadow-left', 'shadow-right']);
51 } else {
52 horizontalScrollShadow.classList.toggle('shadow-left', scrollLeft > 0);
53 horizontalScrollShadow.classList.toggle('shadow-right', scrollLeft < maxScrollLeft)
54 }
55 
56 // Disable both vertical shadows if there's no vertical scrollable area.
57 if (scrollContainer.scrollHeight <= scrollContainer.clientHeight) {
58 verticalScrollShadow.classList.remove(['shadow-top', 'shadow-bottom']);
59 } else {
60 verticalScrollShadow.classList.toggle('shadow-top', scrollTop > 0);
61 verticalScrollShadow.classList.toggle('shadow-bottom', scrollTop < maxScrollTop);
62 }
63 }
64 
65 // Initial update of shadows.
66 updateShadows();
67 
68 // Update shadows on scroll.
69 scrollContainer.addEventListener('scroll', updateShadows);
70 
71 // Create a ResizeObserver to detect changes in the
72 // parent element's width to be able to disable
73 // shadows if there's no scrollable area.
74 const resizeObserver = new ResizeObserver(updateShadows);
75 
76 resizeObserver.observe(scrollContainer.parentElement);
77});
78</script>
79 
80<style>
81:root {
82 --shadow-transparency: 0.2;
83}
84 
85.vertical-scroll-shadow.shadow-top::before {
86 content: '';
87 position: absolute;
88 top: 0;
89 height: 20px;
90 width: 100%;
91 pointer-events: none;
92 background-image: linear-gradient(to bottom, rgba(0, 0, 0, var(--shadow-transparency)), rgba(0, 0, 0, 0));
93}
94 
95.vertical-scroll-shadow.shadow-bottom::after {
96 content: '';
97 position: absolute;
98 bottom: 0;
99 height: 20px;
100 width: 100%;
101 pointer-events: none;
102 background-image: linear-gradient(to top, rgba(0, 0, 0, var(--shadow-transparency)), rgba(0, 0, 0, 0));
103}
104 
105.horizontal-scroll-shadow.shadow-left::before {
106 content: '';
107 position: absolute;
108 top: 0;
109 left: 0;
110 bottom: 0;
111 width: 20px;
112 pointer-events: none;
113 background-image: linear-gradient(to right, rgba(0, 0, 0, var(--shadow-transparency)), rgba(0, 0, 0, 0));
114}
115 
116.horizontal-scroll-shadow.shadow-right::after {
117 content: '';
118 position: absolute;
119 top: 0;
120 right: 0;
121 bottom: 0;
122 width: 20px;
123 pointer-events: none;
124 background-image: linear-gradient(to left, rgba(0, 0, 0, var(--shadow-transparency)), rgba(0, 0, 0, 0));
125}
126</style>

Vue Version

1<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-aFq/bzH65dt+w6FI2ooMVUpc+21e0SRygnTpmBvdBgSdnuTN7QbdgL+OapgHtvPp" crossorigin="anonymous">
2<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
3 
4<div id="app" class="position-relative border" style="inset:0;">
5 <div class="overflow-auto" style="max-height:20rem;" @scroll="updateShadows">
6 <div
7 class="vertical-scroll-shadow"
8 :class="{ 'shadow-top': showTopShadow, 'shadow-bottom': showBottomShadow }"
9 ></div>
10 
11 <div
12 class="horizontal-scroll-shadow"
13 :class="{ 'shadow-left': showLeftShadow, 'shadow-right': showRightShadow }"
14 ></div>
15 
16 <table class="table table-striped mb-0 text-nowrap">
17 <thead>
18 <tr>
19 <th>Column 1</th>
20 <th>Column 2</th>
21 <th>Column 3</th>
22 <th>Column 4</th>
23 <th>Column 5</th>
24 <th>Column 6</th>
25 </tr>
26 </thead>
27 <tbody>
28 <tr>
29 <td>Lorem ipsum dolor sit amet</td>
30 <td>Consectetur adipiscing elit</td>
31 <td>Sed do eiusmod tempor incididunt</td>
32 <td>Ut labore et dolore magna aliqua</td>
33 <td>Ut enim ad minim veniam</td>
34 <td>Quis nostrud exercitation ullamco</td>
35 </tr>
36 <!-- ... -->
37 </tbody>
38 </table>
39 </div>
40</div>
41 
42<script>
43const { createApp } = Vue;
44 
45createApp({
46 data: () => ({
47 resizeObserver: null,
48 showTopShadow: false,
49 showBottomShadow: false,
50 showLeftShadow: false,
51 showRightShadow: false,
52 }),
53 
54 mounted() {
55 // Initial update of shadows.
56 this.updateShadows();
57 
58 // Create a ResizeObserver to detect changes in the
59 // parent element's width to be able to disable
60 // shadows if there's no scrollable area.
61 if (this.$el.parentElement) {
62 this.resizeObserver = new ResizeObserver(this.updateShadows);
63 
64 this.resizeObserver.observe(this.$el.parentElement);
65 }
66 },
67 
68 beforeUnmount() {
69 if (this.resizeObserver) {
70 this.resizeObserver.disconnect();
71 }
72 },
73 
74 methods: {
75 updateShadows() {
76 const scrollLeft = this.$el.scrollLeft;
77 const maxScrollLeft = this.$el.scrollWidth - this.$el.clientWidth;
78 
79 const scrollTop = this.$el.scrollTop;
80 const maxScrollTop = this.$el.scrollHeight - this.$el.clientHeight;
81 
82 // Disable both horizontal shadows if there's no horizontal scrollable area.
83 if (this.$el.scrollWidth <= this.$el.clientWidth) {
84 this.showLeftShadow = false;
85 this.showRightShadow = false;
86 } else {
87 this.showLeftShadow = scrollLeft > 0;
88 this.showRightShadow = scrollLeft < maxScrollLeft;
89 }
90 
91 // Disable both vertical shadows if there's no vertical scrollable area.
92 if (this.$el.scrollHeight <= this.$el.clientHeight) {
93 this.showTopShadow = false;
94 this.showBottomShadow = false;
95 } else {
96 this.showTopShadow = scrollTop > 0;
97 this.showBottomShadow = scrollTop < maxScrollTop;
98 }
99 },
100 },
101}).mount('#app')
102</script>
103 
104<style>
105/*
106 Note: You won't be able to use this in a
107 scoped SFC Vue component. You will have
108 to extract this out if you're doing so.
109*/
110:root {
111 --shadow-transparency: 0.2;
112}
113 
114.vertical-scroll-shadow.shadow-top::before {
115 content: '';
116 position: absolute;
117 top: 0;
118 height: 20px;
119 width: 100%;
120 pointer-events: none;
121 background-image: linear-gradient(to bottom, rgba(0, 0, 0, var(--shadow-transparency)), rgba(0, 0, 0, 0));
122}
123 
124.vertical-scroll-shadow.shadow-bottom::after {
125 content: '';
126 position: absolute;
127 bottom: 0;
128 height: 20px;
129 width: 100%;
130 pointer-events: none;
131 background-image: linear-gradient(to top, rgba(0, 0, 0, var(--shadow-transparency)), rgba(0, 0, 0, 0));
132}
133 
134.horizontal-scroll-shadow.shadow-left::before {
135 content: '';
136 position: absolute;
137 top: 0;
138 left: 0;
139 bottom: 0;
140 width: 20px;
141 pointer-events: none;
142 background-image: linear-gradient(to right, rgba(0, 0, 0, var(--shadow-transparency)), rgba(0, 0, 0, 0));
143}
144 
145.horizontal-scroll-shadow.shadow-right::after {
146 content: '';
147 position: absolute;
148 top: 0;
149 right: 0;
150 bottom: 0;
151 width: 20px;
152 pointer-events: none;
153 background-image: linear-gradient(to left, rgba(0, 0, 0, var(--shadow-transparency)), rgba(0, 0, 0, 0));
154}
155</style>