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
}
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>
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 = []
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
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 = []
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)
}
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>
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 = []
}
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 = []
}
}
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)
βοΈ Is it snowing where you are? π Does it ever snow where you are? π