I'm building a slot game where symbols (divs) are laid out in columns inside a flex container. When a match is detected, I remove the matching elements from the DOM and then new ones fall in from the top.
After the removal, I want the remaining symbols above them to fall smoothly into the empty space (Not the newly generated ones, since they fall in fine). However, since removing elements causes reflow, a transition doesn't apply and the elements just shift downwards to fill the empty spaces that were left by removed elements. And then after that newly generated ones fill whatever is left with a nice transition.
How can I make the symbols fall down with a smooth animation when I remove symbols below it?
This is the logic for the slot, without setting the height to 0, since it looks way worse that way.
const symbols = ["🍒", "🍋", "🍇", "🔔"];
const columns = document.querySelectorAll(".col");
const spinButton = document.querySelector("#spin-button");
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const getRandomSymbol = () => {
const randomIndex = Math.floor(Math.random() * symbols.length);
return symbols[randomIndex];
};
const generateColumns = () => {
[...columns].forEach((col) => {
while (col.children.length < 4) {
const symbol = getRandomSymbol();
const symbolElement = document.createElement("div");
symbolElement.textContent = symbol;
symbolElement.dataset.symbolType = symbol;
symbolElement.classList.add("symbol", "above-reel", "falling");
setTimeout(() => {
symbolElement.style.transform = "translateY(0%)";
}, 300);
col.appendChild(symbolElement);
}
});
};
const startSpin = async () => {
let currentSymbols = document.querySelectorAll(".symbol");
currentSymbols.forEach((symbol) => {
symbol.remove();
});
generateColumns();
await wait(400);
checkWins();
};
const checkWins = async () => {
let winningSymbols = [];
symbols.forEach((symbol) => {
// Check for each symbol
let consecutiveCount = 0;
for (let col = 0; col < columns.length; col++) {
// Check if each column has the symbol
const columnSymbols = [...columns[col].children].map(
(s) => s.dataset.symbolType
);
if (columnSymbols.includes(symbol)) {
consecutiveCount++;
} else {
break; // Stop checking if a column does not have the symbol
}
}
if (consecutiveCount >= 3) {
let lastColIndex = consecutiveCount - 1; // Get the last column index where the symbol was found
winningSymbols.push([symbol, lastColIndex]); // if there are 3 or more consecutive columns with the same symbol store the winning symbol
}
});
if (winningSymbols.length !== 0) {
await wait(1000);
tumble(winningSymbols);
} else {
console.log("gg");
}
};
const tumble = async (winningSymbols) => {
const allMatches = [];
for (let i = 0; i < winningSymbols.length; i++) {
for (let j = 0; j < winningSymbols[i][1] + 1; j++) {
const matches = columns[j].querySelectorAll(
`div[data-symbol-type="${winningSymbols[i][0]}"]`
);
allMatches.push(...matches);
}
}
allMatches.map(async (match) => {
match.classList.add("removing");
await wait(850);
match.remove();
});
await wait(200);
generateColumns();
await wait(350); // wait for symbols to drop down before checking
checkWins();
};
spinButton.addEventListener("click", () => {
startSpin();
});
body {
font-family: Arial, sans-serif;
background: radial-gradient(circle, #000000, #250136);
color: white;
text-align: center;
padding: 20px;
}
.slots-container {
display: flex;
justify-content: center;
gap: 10px;
padding: 20px;
background: #222;
border-radius: 10px;
}
.col {
display: flex;
flex-direction: column-reverse;
overflow-y: hidden;
gap: 5px;
min-height: 215px;
width: 60px;
background: rgba(255, 255, 255, 0.1);
border-radius: 5px;
padding: 10px;
}
.symbol {
font-size: 30px;
text-align: center;
background: white;
border-radius: 5px;
padding: 5px 0;
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
}
.symbol.above-reel {
transform: translateY(-500%);
}
.symbol.removing {
animation: fadeOut 0.5s forwards;
}
@keyframes fadeOut {
to {
opacity: 0;
transform: scale(0);
}
}
/* Controls */
.controls {
display: flex;
justify-content: center;
gap: 20px;
margin-top: 20px;
}
#spin-button {
padding: 10px 25px;
font-weight: bold;
background: #f44336;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
#spin-button.spinning {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.box {
background: #333;
color: white;
padding: 10px;
border-radius: 5px;
min-width: 100px;
text-align: center;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="style.css" />
<title>Document</title>
<script type="module" src="./js/main.js"></script>
</head>
<body>
<main>
<div class="slots-container">
<div id="col1" class="col"></div>
<div id="col2" class="col"></div>
<div id="col3" class="col"></div>
<div id="col4" class="col"></div>
<div id="col5" class="col"></div>
<div id="col6" class="col"></div>
</div>
<div class="controls">
<div id="last-win" class="box">LAST WIN:</div>
<button id="spin-button">SPIN</button>
<div id="balance" class="box"></div>
</div>
</main>
<div id="explosions"></div>
</body>
</html>
I've been wanting to do similar stuff for a dynamic multi-table component, displaying 1 to 4 tables with custom animations for transitions between states. Unfortunately, animations using either display: grid
or display: flex
were not handling all cases.
I ended up setting all elements to position: absolute
and play with elements top
right
bottom
left
properties.
This might not be ideal but it gives you full control over the animation part.
// Example implementation
updateSlotPosition(someId, position) {
const element = document.getElementById(someId);
const totalPadding = getPaddingForPosition(position);
element.style.top = `${position * slotHeightPx + padding}px`;
}
At least with this method you are guaranteed to have smooth transitions for all states.