Loot Box Reveal Animation — Free HTML CSS JS Snippet

Loot Box Reveal Animation · Animations · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Genuine weighted-random selection via cumulative weight subtraction in weightedPick(), not Math.random() * items.length
Four-tier rarity system (Common/Rare/Epic/Legendary) with per-tier border colour, box-shadow glow and particle count
Three-phase reveal sequence: CSS shake + pulsing glow-ring, burst-pop scale-out, then card fade/scale-in
Particle burst generated with trigonometric angle distribution and CSS custom properties (--dx, --dy) for outward motion
Bounded opening history: unshift + slice(0, 5) keeps exactly the last five results with rarity tags
Rarity-scaled particle count (8 to 36) and glow intensity so higher tiers feel visibly more dramatic
Button state management: Open Box disables mid-animation and swaps to Open Again after reveal completes
Fully data-driven prize table — add prizes or rarity tiers by editing one array and a few CSS rules

About this UI Snippet

Loot Box Reveal Animation — Weighted Rarity Randomness, Burst Reveal & Opening History

Screenshot of the Loot Box Reveal Animation snippet rendered live

Reward-reveal animations are one of the most requested gamification patterns in mobile games, live-service titles, and loyalty apps: the player taps a box, the box builds suspense with a shake and glow, then bursts open to reveal a prize whose visual drama scales with how rare it is. This snippet builds the full interaction with real weighted randomness behind it rather than a fake animation over a hard-coded prize.

Weighted rarity, not uniform randomness

The naive way to "randomise" a reward is items[Math.floor(Math.random() * items.length)], which gives every entry an equal 1-in-N chance regardless of how rare it is meant to feel. That is wrong for a rarity system where Legendary should be dramatically less likely than Common. The weightedPick() function instead assigns each prize a numeric weight (Common items weigh 60, Rare 25, Epic 12, Legendary 3), sums the total weight across all prizes, and draws one uniform random number in the range [0, totalWeight). It then walks the prize list subtracting each item's weight from that roll until the running total drops to zero or below — the item where that happens is the winner. This is the standard cumulative-distribution technique used by real gacha and loot systems: the probability of landing on any given prize is exactly weight / totalWeight, which for the two Legendary entries at weight 3 each out of a total of 197 works out to roughly 3% combined, matching the brief's target odds precisely.

Suspense sequencing with setTimeout, not a single CSS animation

The reveal is staged in three phases driven by chained setTimeout calls rather than one long animation, because each phase needs to react to information (the chosen rarity) that is not known until the roll happens. Phase one adds a .shaking class to the crate icon, triggering a CSS shake keyframe animation (alternating translateX/rotate) alongside a pulsing radial-gradient .glow-ring, both looping for about 1.4 seconds to build tension. Phase two, once the weighted prize has already been rolled internally, swaps .shaking for .burst, a cubic-bezier keyframe that scales the crate up and then down to zero — a satisfying "pop" exit. Phase three, timed to land as the burst finishes, reveals the .prize-card with an opacity and translateY/scale transition, and its border colour, box-shadow glow intensity, and heading text all switch based on the rolled rarity string via a rarity-{tier} CSS class.

Rarity-scaled particle burst

At the moment of burst, spawnParticles() generates a small set of absolutely positioned .particle divs distributed evenly around a circle using trigonometry (Math.cos(angle) * distance, Math.sin(angle) * distance) and animates each one outward and fading via a CSS custom property pair (--dx, --dy) consumed by the particle-fly keyframe. The particle count scales directly with rarity — 8 for Common up to 36 for Legendary — and the particle colour matches the rarity's accent colour, so a Legendary reveal visibly explodes with far more motion than a Common one. This scaling reinforces the rarity hierarchy without needing separate animation code per tier.

Opening history as a bounded array

Every completed reveal is unshifted onto a history array and the array is immediately sliced to its first five entries, giving an always-current "last five openings" feed rendered as a list with each prize's name and a coloured rarity tag. This is a simple but common state-management pattern worth internalising: keep the freshest N results by combining unshift with slice, rather than manually tracking indices or splicing from the end.

A note on responsible use

This pattern is genuinely fun to build and to use, but reward-reveal mechanics with randomised rarity sit adjacent to gambling psychology, and several jurisdictions now regulate loot boxes in games marketed to minors (disclosure requirements in Belgium, the Netherlands, and proposed UK and US legislation). Use this component for free, cosmetic-only rewards — daily login bonuses, achievement unlocks, non-purchasable skins — never as a mechanic players pay real money to open, and always disclose the actual odds if you ship anything resembling it in a live product.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Paste this snippet's HTML, CSS and JS into an AI coding assistant like Claude and ask it to trace exactly how weightedPick() turns the PRIZES array's weight values into real probabilities — it's a good way to build intuition for cumulative-distribution sampling beyond just this one game mechanic. You can also ask it to walk through the three-phase setTimeout sequence (shake, burst, reveal) and explain why the timings are chained the way they are rather than driven by animationend listeners. Worth asking it to extend the component too: request a sound-effect hook keyed to rarity tier, a localStorage-backed history so openings survive a page reload, or a "pity timer" that guarantees an Epic-or-better prize after a configurable number of Common results in a row, which is a common real-world refinement to loot mechanics that keeps players from feeling unlucky for too long.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a loot box reward-reveal animation in plain HTML, CSS, and JavaScript with genuine weighted-rarity randomness — no frameworks, no libraries.

Requirements:
- A prize table of at least 6 items across 4 rarity tiers (Common, Rare, Epic, Legendary) where each item has an explicit numeric weight, and a selection function that uses cumulative weight subtraction against a single random draw (not Math.random() * array.length) so the actual odds match weight / totalWeight.
- A three-phase reveal sequence: a shake-and-glow suspense phase on a box icon lasting roughly 1-1.5 seconds, a burst/pop exit animation once the prize has been internally rolled, then a prize card fade/scale-in styled according to the rolled rarity.
- Each rarity tier must have visually distinct treatment — different border colour, glow/box-shadow intensity, and particle burst count/colour — so Legendary reveals are obviously more dramatic than Common ones.
- A particle burst effect at the moment of reveal, with particles distributed radially outward from the box and fading out, scaled in count by rarity tier.
- A bounded "recent openings" history list showing the last 5 results with their rarity, implemented by trimming an array rather than an unbounded log.
- An "Open Again" action that resets all animation state cleanly and can be triggered repeatedly without visual glitches or stacked timers.
- Disable the open button during the animation sequence so users cannot trigger overlapping reveals.
- In a comment, note that this pattern should only be used for free/cosmetic rewards, not real-money loot mechanics, given regulatory scrutiny in several jurisdictions.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Step by step

How to Use

  1. 1
    Open a boxClick "Open Box" to start the sequence. The crate shakes and glows for about 1.4 seconds while the weighted roll runs internally, then it bursts and the prize card fades in with rarity-specific colour and glow intensity.
  2. 2
    Read the rarity tierThe prize card header ("Common", "Rare", "Epic", "Legendary") and border colour tell you the rolled tier. Legendary prizes get a gold border, the strongest box-shadow glow, and the largest particle burst; Common prizes get a muted grey border and a small burst.
  3. 3
    Check the opening historyThe right-hand panel keeps a running list of your last five openings with each prize's name and a coloured rarity tag, generated by unshifting onto the history array and slicing it to length 5 in addToHistory().
  4. 4
    Open againAfter a reveal, the button switches to "Open Again", which calls the same openBox() function and resets the crate and glow classes via resetStage() before starting a fresh shake-burst-reveal cycle.
  5. 5
    Tune the oddsIn the JS panel, edit the weight field on any entry in the PRIZES array. Weights do not need to sum to 100 — weightedPick() normalises against the actual total, so you can add or remove prizes freely without recalculating percentages by hand.
  6. 6
    Add new prizes or rarity tiersPush a new object with name, rarity, weight, and icon into PRIZES, then add matching CSS rules for .rarity-{tier}, .tag-{tier}, and entries in RARITY_LABEL, RARITY_PARTICLES, and RARITY_COLORS so the new tier gets its own visual treatment.

Real-world uses

Common Use Cases

Daily login reward or achievement unlock screens
Mobile and web apps commonly reward returning users with a randomised cosmetic or currency drop. This component's weighted-rarity system and dramatic reveal animation give that moment real weight without requiring any payment — exactly the "genuinely free reward" use case this pattern is best suited to. Wire the win event to your backend to grant the actual item after the animation completes.
Teaching weighted random selection and cumulative distributions
The weightedPick() function is a compact, readable implementation of cumulative-distribution sampling, a technique used far beyond games — A/B test bucketing, randomised load balancing, and lottery-style draws all use the same subtract-until-zero pattern. Studying this snippet is a practical way to understand weighted randomness before reaching for a library.
Design system reference for rarity-tiered UI components
Trading card games, inventory systems, and collectible-drop apps all need a consistent visual language for rarity — colour, glow, and motion intensity that scales predictably across tiers. This snippet's RARITY_COLORS, RARITY_PARTICLES and CSS rarity classes form a ready-made reference for building that system consistently elsewhere in a product, similar in spirit to the tiered visual states in a Loading Progress Bar.
Onboarding or tutorial reward moments
New-user onboarding flows often end with a small celebratory reward to reinforce completion. Because the reveal sequence is fully self-contained and triggerable from a single openBox() call, it drops cleanly into the final step of an onboarding wizard or tutorial checklist to end the flow on a high note.
Prototyping drop-rate balance before backend integration
Game designers frequently need to playtest rarity odds before wiring up a real backend economy. Because the weight values are plain numbers in a single array, designers or developers can tweak them live, reopen the box repeatedly, and watch the opening history to sanity-check that Legendary really does feel rare at the intended percentage.
Free-tier engagement mechanic instead of a paid loot box
Products that want the psychological engagement of a reveal moment without any gambling-adjacent risk can restrict this component to purely cosmetic, non-purchasable, no-cost rewards — for example a weekly free spin on a wallpaper pack or emoji set — sidestepping the regulatory scrutiny that surrounds paid loot mechanics entirely.

Got questions?

Frequently Asked Questions

weightedPick() sums every prize's weight into a total, draws one uniform random number between 0 and that total, then walks the prize list subtracting each weight until the running value drops to zero or below. Because the draw is uniform across the full weight range, the probability of landing inside any given prize's slice is mathematically exactly weight / totalWeight — with the given weights that works out to roughly 60% Common, 25% Rare, 12% Epic and 3% Legendary combined across their entries, matching the brief's target tier percentages.

Not as-is, and we'd recommend against it generally. Randomised-rarity reward mechanics tied to real-money purchases are increasingly regulated as gambling-adjacent in games aimed at minors — Belgium and the Netherlands have restricted or banned them outright, and other jurisdictions require odds disclosure. This snippet is designed and intended for genuinely free, cosmetic-only rewards (login bonuses, achievement unlocks). If you do adapt it for a paid context, you must disclose exact odds and check your local regulations first.

Yes — edit the RARITY_PARTICLES object to change the particle count per tier, and adjust the box-shadow blur/spread values in the .rarity-{tier} CSS rules to change glow intensity. The particle distance is randomised per particle (60 to 120px) inside spawnParticles(), so you can also widen or narrow that range for a tighter or wider burst spread.

The reveal needs to react to the already-known prize rarity partway through the sequence — the burst timing and prize-card styling both depend on a value chosen before any animation starts. Chained setTimeout calls keep that logic simple and readable; animationend listeners would work too but add event-binding overhead for no real benefit at this scale, since the durations are fixed and known in advance.

The history array currently lives in memory only and resets on reload. To persist it, call localStorage.setItem('loot-history', JSON.stringify(history)) at the end of addToHistory(), and on load read it back with JSON.parse(localStorage.getItem('loot-history') || '[]') before the first renderHistory() call.