Reactive Grids in Nuxt

Build a reactive & responsive grid of SVG elements that respond to mouse movement

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!

vue
<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!

scss
.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.

javascript
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.