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 a107 scoped SFC Vue component. You will have108 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>