Setting up your template
We'll be creating a reactive grid of SVG elements that respond to mouse movement and create a wave-like effect.
To start, we'll need to set up a grid of SVG elements that will be responsive to mouse movement. This will render an array that is calculated based on the number of columns and rows that fit in this screen size. This makes this automatically responsive!
<section id="example" class="w-consistent">
<section
class="grid-background"
:style="{
transform: `perspective(1000px) rotateX(${tiltX}deg) rotateY(${tiltY}deg)`
}"
>
<div
v-for="(item, index) in gridItems"
:key="index"
class="grid-item"
@mouseenter="!isMobile && updateScales(item)"
@mouseleave="!isMobile && updateScales(null)"
:style="{
left: `${item.left}px`,
top: `${item.top}px`,
width: `${item.width}px`,
height: `${item.height}px`,
transform: `scale(${item.scale})`,
opacity: item.opacity
}"
>
<svg :style="{ width: '50%', height: '50%' }">
<defs>
<mask :id="`mask-${index}`">
<image :href="item.svg" width="100%" height="100%" />
</mask>
</defs>
<rect width="100%" height="100%" :fill="`#${item.color}`" :mask="`url(#mask-${index})`" />
</svg>
</div>
</section>
</section>There's surprisingly little styling that is required for this!
.grid-background {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
pointer-events: all;
z-index: 0;
transform-style: preserve-3d;
transition: transform 0.3s ease-out;
.grid-item {
position: absolute;
background: transparent;
border-radius: $br-sm;
transition: transform 0.2s ease-out, opacity 0.2s ease-out;
display: flex;
align-items: center;
justify-content: center;
transform-origin: center center;
svg {
display: block;
}
}
}Calculating the grid items
Next, we'll need to calculate the grid items based on the number of columns and rows that fit in the screen size. We'll also randomly assign an SVG and color to each grid item.
import { ref, onMounted, onUnmounted, reactive, nextTick } from 'vue'
const COLUMNS = ref(32)
const GAP = 0
const gridItems = ref([])
const colors = ['cccccc']
let waveAnimationFrame = null
let waveTime = 0
let hoveredItem = null
const tiltX = ref(0)
const tiltY = ref(0)
const isMobile = ref(false)
function updateColumns() {
const width = window.innerWidth
if (width < 768) {
COLUMNS.value = 6
isMobile.value = true
} else if (width < 1024) {
COLUMNS.value = 8
isMobile.value = true
} else if (width < 1440) {
COLUMNS.value = 12
isMobile.value = false
} else {
COLUMNS.value = 22
isMobile.value = false
}
}
function calculateGridItems() {
const heroEl = document.querySelector('#example')
if (!heroEl) return
updateColumns()
const heroHeight = heroEl.offsetHeight
const heroWidth = heroEl.offsetWidth
const totalGapWidth = GAP * (COLUMNS.value - 1)
const itemWidth = (heroWidth - totalGapWidth) / COLUMNS.value
const itemHeight = itemWidth
const rows = Math.ceil(heroHeight / (itemHeight + GAP)) + 1
const items = []
for (let row = 0; row < rows; row++) {
for (let col = 0; col < COLUMNS.value; col++) {
const svgNumber = Math.floor(Math.random() * 20) + 1
const color = colors[Math.floor(Math.random() * colors.length)]
items.push(reactive({
row, col, scale: 1, opacity: 0.1,
baseScale: 1, baseOpacity: 0.1,
left: col * (itemWidth + GAP),
top: row * (itemHeight + GAP),
width: itemWidth, height: itemHeight,
svg: `/elements/${svgNumber}.svg`,
color
}))
}
}
gridItems.value = items
}
function updateScales(item) {
hoveredItem = item
if (!hoveredItem) {
gridItems.value.forEach(item => {
item.baseScale = 1
item.baseOpacity = 0.1
})
return
}
gridItems.value.forEach(item => {
const rowDist = Math.abs(item.row - hoveredItem.row)
const colDist = Math.abs(item.col - hoveredItem.col)
const distance = Math.max(rowDist, colDist)
if (distance === 0) {
item.baseScale = 2.4
item.baseOpacity = 1
} else if (distance === 1) {
item.baseScale = 1.8
item.baseOpacity = 0.8
} else if (distance === 2) {
item.baseScale = 1.15
item.baseOpacity = 0.5
} else if (distance === 3) {
item.baseScale = 1
item.baseOpacity = 0.3
} else {
item.baseScale = 1
item.baseOpacity = 0.1
}
})
}
function animateWaves() {
waveTime += 0.025
gridItems.value.forEach(item => {
const wave1 = Math.sin(item.col * 0.2 + item.row * 0.15 + waveTime) * 1 + 0.5
const wave2 = Math.sin(item.col * 0.06 + item.row * 0.55 + waveTime) * .2 + 0.05
const combinedWave = wave1 + wave2
const waveScaleInfluence = combinedWave * 0
const waveOpacityInfluence = combinedWave * 0.2
item.scale = item.baseScale + waveScaleInfluence
item.opacity = Math.min(1, item.baseOpacity + waveOpacityInfluence)
})
waveAnimationFrame = requestAnimationFrame(animateWaves)
}
onMounted(() => {
nextTick(() => {
updateColumns()
calculateGridItems()
animateWaves()
})
window.addEventListener('resize', calculateGridItems)
})
onUnmounted(() => {
window.removeEventListener('resize', calculateGridItems)
if (waveAnimationFrame) {
cancelAnimationFrame(waveAnimationFrame)
}
})Conclusion
So, this is a fun little experiment that I think could be used in a lot of different ways. I think it would be cool to see this used in a more subtle way as a background for a site. I think it could also be used as a loading animation or a background for a hero section. I think the possibilities are endless.