DEV Community

Rails Designer
Rails Designer

Posted on • Originally published at railsdesigner.com

Add snow to your app with Stimulus

This article was originally published on Rails Designer blog; see the original article for the snow effect β˜ƒοΈ


With the end of 2025 near, let's build something fun: a snow effect for your (Rails) app or site (built with Perron?) using one Stimulus controller. Snow will fall from the top of the viewport, pile up at the bottom and you can sweep it away by dragging your mouse. Give it a try on this page! πŸ˜Šβ˜ƒοΈ

Creating the basic controller

Start with the controller structure and lifecycle methods. Create a new Stimulus controller:

// app/javascript/controllers/let_it_snow_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
    this.#startSnow()
  }

  disconnect() {
    this.#stopSnow()
    this.#meltSnow()
  }

  // private

  #startSnow() {
    this.#animationFrame = requestAnimationFrame(() => this.#animate())
  }

  #stopSnow() {
    if (this.#animationFrame) {
      cancelAnimationFrame(this.#animationFrame)

      this.#animationFrame = null
    }
  }

  #animate() {
    // animation loop goes here
    this.#animationFrame = requestAnimationFrame(() => this.#animate())
  }

  #meltSnow() {
    // cleanup goes here
  }

  #animationFrame = null
}
Enter fullscreen mode Exit fullscreen mode

The connect method starts the snow animation when the controller initializes. The disconnect method cleans everything up when the controller is removed. The animation loop uses requestAnimationFrame for smooth 60fps animation. This browser API synchronizes animations with the screen refresh rate, so animationns are smooth (all while automatically pausing when the tab is not visible to save your CPU from running hot and melting all snow on its own).

Do not forget to add the controller to any element:

<div data-controller="let-it-snow"></div>
Enter fullscreen mode Exit fullscreen mode

Let it snow! β˜ƒοΈ

Now create snowflakes and make them fall. Add the snowflake creation logic:

#animate() {
  this.#createSnowBasedOnIntensity()

  this.#animationFrame = requestAnimationFrame(() => this.#animate())
}

#createSnowBasedOnIntensity() {
  if (Math.random() < 0.02) {
    this.#createSnowflake()
  }
}

#createSnowflake() {
  const snowflake = document.createElement("div")

  snowflake.textContent = "❄️"
  this.#applySnowflakeStyles(snowflake)
  this.#applySnowflakePhysics(snowflake)

  document.body.appendChild(snowflake)
  this.#fallingSnow.push(snowflake)
}

#applySnowflakeStyles(snowflake) {
  Object.assign(snowflake.style, {
    position: "fixed",
    left: `${Math.random() * window.innerWidth}px`,
    top: "-50px",
    fontSize: `${Math.random() * 20 + 15}px`,
    pointerEvents: "none",
    zIndex: "9999",
    userSelect: "none",
    isolation: "isolate"
  })
}

#applySnowflakePhysics(snowflake) {
  snowflake.dataset.velocityY = (Math.random() * 1 + 0.5).toString()
  snowflake.dataset.velocityX = (Math.random() * 0.5 - 0.25).toString()
  snowflake.dataset.rotation = "0"
  snowflake.dataset.rotationSpeed = (Math.random() * 2 - 1).toString()
}

// Private
#fallingSnow = []
Enter fullscreen mode Exit fullscreen mode

Each snowflake is a div with the ❄️ emoji. The styles position it at a random horizontal location above the viewport. The physics data attributes control how fast it falls and drifts sideways. The isolation: isolate property ensures snowflakes don't interfere with text selection or clicking on your page content (try selectingβ€”with the broom some text on this page).

Animate all the snow! β˜ƒοΈ

Make the snowflakes move down the screen with some gentle horizontal drift:

#animate() {
  this.#createSnowBasedOnIntensity()
  this.#updateFallingSnow()

  this.#animationFrame = requestAnimationFrame(() => this.#animate())
}

#updateFallingSnow() {
  const bottomThreshold = window.innerHeight - this.#accumulatedHeight

  this.#fallingSnow.forEach(snowflake => {
    this.#moveSnowflake(snowflake)

    if (this.#getSnowflakeTop(snowflake) >= bottomThreshold) {
      snowflake.dataset.settled = "true"
    }
  })
}

#moveSnowflake(snowflake) {
  const top = parseFloat(snowflake.style.top)
  const left = parseFloat(snowflake.style.left)
  const velocityY = parseFloat(snowflake.dataset.velocityY)
  const velocityX = parseFloat(snowflake.dataset.velocityX)
  const rotation = parseFloat(snowflake.dataset.rotation)
  const rotationSpeed = parseFloat(snowflake.dataset.rotationSpeed)

  let newLeft = left + velocityX
  newLeft = this.#constrainHorizontally(snowflake, newLeft, velocityX)

  snowflake.style.top = `${top + velocityY}px`
  snowflake.style.left = `${newLeft}px`
  snowflake.style.transform = `rotate(${rotation + rotationSpeed}deg)`
  snowflake.dataset.rotation = (rotation + rotationSpeed).toString()
}

#constrainHorizontally(snowflake, left, velocityX) {
  if (left < 0) {
    snowflake.dataset.velocityX = Math.abs(velocityX).toString()

    return 0
  }

  if (left > window.innerWidth) {
    snowflake.dataset.velocityX = (-Math.abs(velocityX)).toString()

    return window.innerWidth
  }

  return left
}

#getSnowflakeTop(snowflake) {
  return parseFloat(snowflake.style.top)
}

// Private
#accumulatedHeight = 0
Enter fullscreen mode Exit fullscreen mode

The snowflakes fall at their velocity and rotate as they go. When they hit the edges of the viewport they bounce back. When they reach the bottom they're marked as settled. Cool, right? πŸ˜…

Pile up all the snow! β˜ƒοΈ

β€œSettled” snowflakes need to stop falling and β€œpile” at the bottom (laws of nature demands that from us):

#animate() {
  this.#createSnowBasedOnIntensity()
  this.#updateFallingSnow()
  this.#settleSnowAtBottom()

  this.#animationFrame = requestAnimationFrame(() => this.#animate())
}

#settleSnowAtBottom() {
  const settledSnow = this.#fallingSnow.filter(s => s.dataset.settled === "true")

  if (settledSnow.length === 0) return

  settledSnow.forEach(snowflake => {
    const finalTop = window.innerHeight - this.#accumulatedHeight - 30

    snowflake.style.top = `${finalTop}px`
    snowflake.dataset.bottomOffset = this.#accumulatedHeight.toString()

    this.#piledSnow.push(snowflake)
  })

  this.#fallingSnow = this.#fallingSnow.filter(s => s.dataset.settled !== "true")
  this.#increaseAccumulatedSnow(settledSnow.length)
}

#increaseAccumulatedSnow(count) {
  this.#accumulatedHeight += (count * this.pixelsPerMinuteValue) / 3600
}

// Private
#piledSnow = []
Enter fullscreen mode Exit fullscreen mode

Settled snowflakes get positioned at the top of the existing pile. The accumulated height grows with each snowflake that settles. This creates a visible pile of snow at the bottom of the viewport.

Sweep away all the snow! β˜ƒοΈ

Add the broom cursor and sweeping functionality:

static values = {
  intensity: { type: String, default: "light" },
  pixelsPerMinute: { type: Number, default: 100 },
  broomActive: { type: Boolean, default: false }
}

trackMouse(event) {
  const { clientX, clientY, buttons } = event

  if (buttons === 1) {
    this.broomActiveValue = true
    this.#sweepAtPosition(clientX, clientY)
  } else {
    this.broomActiveValue = false
  }
}

sweepAwaySnow({ clientX, clientY }) {
  if (!this.broomActiveValue) return

  this.#sweepAtPosition(clientX, clientY)
}

broomActiveValueChanged(isBrooming) {
  document.body.style.cursor = isBrooming
    ? "url('data:image/svg+xml;utf8,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"32\" height=\"32\" viewBox=\"0 0 32 32\"><text y=\"28\" font-size=\"28\">🧹</text></svg>') 0 32, auto"
    : "default"
}

#sweepAtPosition(mouseX, mouseY) {
  const sweepRadius = 80
  const sweptSnow = this.#findSnowInRadius(mouseX, mouseY, sweepRadius)

  if (sweptSnow.length === 0) return

  sweptSnow.forEach(snowflake => snowflake.remove())
  this.#piledSnow = this.#piledSnow.filter(s => !sweptSnow.includes(s))
  this.#decreaseAccumulatedSnow(sweptSnow.length)
}

#findSnowInRadius(mouseX, mouseY, radius) {
  return this.#piledSnow.filter(snowflake => {
    const left = parseFloat(snowflake.style.left)
    const top = parseFloat(snowflake.style.top)
    const distance = Math.sqrt(Math.pow(mouseX - left, 2) + Math.pow(mouseY - top, 2))

    return distance <= radius
  })
}

#decreaseAccumulatedSnow(count) {
  const pixelsToRemove = (count * this.pixelsPerMinuteValue) / 3600

  this.#accumulatedHeight = Math.max(0, this.#accumulatedHeight - pixelsToRemove)
}
Enter fullscreen mode Exit fullscreen mode

When you hold down the mouse button the cursor changes to a broom. Drag it around to sweep away snow. The accumulated height decreases as you remove snow. 🧹

Add the mouse tracking to your view:

<div
  data-controller="let-it-snow"
  data-action="mousemove@document->let-it-snow#trackMouse click@document->let-it-snow#sweepAwaySnow"
>
</div>
Enter fullscreen mode Exit fullscreen mode

Clean up all the snow! β˜ƒοΈ

Complete the cleanup method to remove all snowflakes:

#meltSnow() {
  this.#fallingSnow.forEach(s => s.remove())
  this.#piledSnow.forEach(s => s.remove())

  this.#fallingSnow = []
  this.#piledSnow = []
}
Enter fullscreen mode Exit fullscreen mode

This removes all snowflake elements from the DOM and clears the arrays when the controller disconnects.

Here is the full implementation:

// app/javascript/controllers/let_it_snow_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = {
    pixelsPerMinute: { type: Number, default: 100 },
    broomActive: { type: Boolean, default: false }
  }

  connect() {
    this.#startSnow()
  }

  disconnect() {
    this.#stopSnow()
    this.#meltSnow()
  }

  trackMouse(event) {
    const { clientX, clientY, buttons } = event

    if (buttons === 1) {
      this.broomActiveValue = true

      this.#sweepAtPosition(clientX, clientY)
    } else {
      this.broomActiveValue = false
    }
  }

  sweepAwaySnow({ clientX, clientY }) {
    if (!this.broomActiveValue) return

    this.#sweepAtPosition(clientX, clientY)
  }

  // Private

  #fallingSnow = []
  #piledSnow = []
  #animationFrame = null
  #accumulatedHeight = 0

  #intensityMap = {
    flurry: 60,
    light: 200,
    steady: 500,
    heavy: 1000,
    blizzard: 2000
  }

  intensityValueChanged(newIntensity) {
    if (this.#intensityMap[newIntensity]) {
      this.pixelsPerMinuteValue = this.#intensityMap[newIntensity]
    }
  }

  broomActiveValueChanged(isBrooming) {
    document.body.style.cursor = isBrooming
      ? "url('data:image/svg+xml;utf8,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"32\" height=\"32\" viewBox=\"0 0 32 32\"><text y=\"28\" font-size=\"28\">🧹</text></svg>') 0 32, auto"
      : "default"
  }

  #startSnow() {
    this.#animationFrame = requestAnimationFrame(() => this.#animate())
  }

  #stopSnow() {
    if (this.#animationFrame) {
      cancelAnimationFrame(this.#animationFrame)

      this.#animationFrame = null
    }
  }

  #animate() {
    this.#createSnowBasedOnIntensity()
    this.#updateFallingSnow()
    this.#settleSnowAtBottom()

    this.#animationFrame = requestAnimationFrame(() => this.#animate())
  }

  #createSnowBasedOnIntensity() {
    const probability = Math.min(0.5, (this.pixelsPerMinuteValue / 1000) * 0.3)

    if (Math.random() < probability) this.#createSnowflake()
  }

  #createSnowflake() {
    const snowflake = document.createElement("div")

    snowflake.textContent = "❄️"
    this.#applySnowflakeStyles(snowflake)
    this.#applySnowflakePhysics(snowflake)

    document.body.appendChild(snowflake)

    this.#fallingSnow.push(snowflake)
  }

  #applySnowflakeStyles(snowflake) {
    Object.assign(snowflake.style, {
      position: "fixed",
      left: `${Math.random() * window.innerWidth}px`,
      top: "-50px",
      fontSize: `${Math.random() * 20 + 15}px`,
      pointerEvents: "none",
      zIndex: "9999",
      userSelect: "none",
      isolation: "isolate"
    })
  }

  #applySnowflakePhysics(snowflake) {
    snowflake.dataset.velocityY = (Math.random() * 1 + 0.5).toString()
    snowflake.dataset.velocityX = (Math.random() * 0.5 - 0.25).toString()
    snowflake.dataset.rotation = "0"
    snowflake.dataset.rotationSpeed = (Math.random() * 2 - 1).toString()
  }

  #updateFallingSnow() {
    const bottomThreshold = window.innerHeight - this.#accumulatedHeight

    this.#fallingSnow.forEach(snowflake => {
      this.#moveSnowflake(snowflake)

      if (this.#getSnowflakeTop(snowflake) >= bottomThreshold) {
        snowflake.dataset.settled = "true"
      }
    })
  }

  #moveSnowflake(snowflake) {
    const top = parseFloat(snowflake.style.top)
    const left = parseFloat(snowflake.style.left)
    const velocityY = parseFloat(snowflake.dataset.velocityY)
    const velocityX = parseFloat(snowflake.dataset.velocityX)
    const rotation = parseFloat(snowflake.dataset.rotation)
    const rotationSpeed = parseFloat(snowflake.dataset.rotationSpeed)

    let newLeft = left + velocityX
    newLeft = this.#constrainHorizontally(snowflake, newLeft, velocityX)

    snowflake.style.top = `${top + velocityY}px`
    snowflake.style.left = `${newLeft}px`
    snowflake.style.transform = `rotate(${rotation + rotationSpeed}deg)`
    snowflake.dataset.rotation = (rotation + rotationSpeed).toString()
  }

  #constrainHorizontally(snowflake, left, velocityX) {
    if (left < 0) {
      snowflake.dataset.velocityX = Math.abs(velocityX).toString()

      return 0
    }

    if (left > window.innerWidth) {
      snowflake.dataset.velocityX = (-Math.abs(velocityX)).toString()

      return window.innerWidth
    }

    return left
  }

  #getSnowflakeTop(snowflake) {
    return parseFloat(snowflake.style.top)
  }

  #settleSnowAtBottom() {
    const settledSnow = this.#fallingSnow.filter(s => s.dataset.settled === "true")

    if (settledSnow.length === 0) return

    settledSnow.forEach(snowflake => {
      const finalTop = window.innerHeight - this.#accumulatedHeight - 30

      snowflake.style.top = `${finalTop}px`
      snowflake.dataset.bottomOffset = this.#accumulatedHeight.toString()

      this.#piledSnow.push(snowflake)
    })

    this.#fallingSnow = this.#fallingSnow.filter(s => s.dataset.settled !== "true")

    this.#increaseAccumulatedSnow(settledSnow.length)
  }

  #increaseAccumulatedSnow(count) {
    this.#accumulatedHeight += (count * this.pixelsPerMinuteValue) / 3600
  }

  #sweepAtPosition(mouseX, mouseY) {
    const sweepRadius = 80
    const sweptSnow = this.#findSnowInRadius(mouseX, mouseY, sweepRadius)

    if (sweptSnow.length === 0) return

    sweptSnow.forEach(snowflake => snowflake.remove())
    this.#piledSnow = this.#piledSnow.filter(s => !sweptSnow.includes(s))
    this.#decreaseAccumulatedSnow(sweptSnow.length)
  }

  #findSnowInRadius(mouseX, mouseY, radius) {
    return this.#piledSnow.filter(snowflake => {
      const left = parseFloat(snowflake.style.left)
      const top = parseFloat(snowflake.style.top)
      const distance = Math.sqrt(Math.pow(mouseX - left, 2) + Math.pow(mouseY - top, 2))

      return distance <= radius
    })
  }

  #decreaseAccumulatedSnow(count) {
    const pixelsToRemove = (count * this.pixelsPerMinuteValue) / 3600

    this.#accumulatedHeight = Math.max(0, this.#accumulatedHeight - pixelsToRemove)
  }

  #meltSnow() {
    this.#fallingSnow.forEach(s => s.remove())
    this.#piledSnow.forEach(s => s.remove())
    this.#fallingSnow = []
    this.#piledSnow = []
  }
}
Enter fullscreen mode Exit fullscreen mode

I hope this brought a little smile to your face all while learning a few new little JavaScript features. 😊 This will be the last article from Rails Designer for 2025, except for something little next week.

From me to you: thanks for your support in 2025 by either reading my articles, getting a copy of Rails Designer's UI Components Library or JavaScript for Rails Developers or using any of the Rails Designer-sponsored OSS projects. Would love to see you again here in 2026. ❀️

Top comments (1)

Collapse
 
railsdesigner profile image
Rails Designer

β˜ƒοΈ Is it snowing where you are? 😊 Does it ever snow where you are? πŸ˜