Close Menu

    Subscribe to Updates

    Get the latest creative news from FooBar about art, design and business.

    What's Hot

    How to create Animated Social Media Card Hover using HTML CSS and JS

    20 October 2025

    How to create Animated LogOut Button using HTML CSS and JS

    18 October 2025

    How to create Animated Firework Diwali using HTML CSS and JS

    16 October 2025
    Facebook X (Twitter) Instagram YouTube Telegram Threads
    Coding StellaCoding Stella
    • Home
    • Blog
    • HTML & CSS
      • Login Form
    • JavaScript
    • Hire us!
    Coding StellaCoding Stella
    Home - JavaScript - How to create Animated Firework Diwali using HTML CSS and JS
    JavaScript

    How to create Animated Firework Diwali using HTML CSS and JS

    Coding StellaBy Coding Stella16 October 2025No Comments50 Mins Read
    Share Facebook Twitter Pinterest LinkedIn Tumblr Reddit Email WhatsApp Copy Link

    Let’s create an Animated Firework Diwali project using HTML, CSS, and JavaScript. This festive project will display colorful fireworks lighting up the screen, perfect for celebrating the joy of Diwali with creativity and code! 🪔✨

    We’ll use:

    • HTML to set up the structure of the fireworks display.
    • CSS to style the night sky and create glowing firework effects.
    • JavaScript to trigger animations, random explosions, and spark movements for a lively celebration.

    Let’s get started on building this Animated Firework Diwali project. Whether you’re a beginner or an experienced developer, this is a fun and visual way to practice coding while spreading festive cheer through animation and interactivity. Let’s light up the web with fireworks! 🎆

    HTML :

    This HTML file creates an animated Diwali fireworks show. It sets up the webpage structure, loads icons, fonts, and styles, and connects to external scripts for animation logic. The <canvas> elements (trails-canvas and main-canvas) are where the fireworks are drawn. There are buttons to pause, play, control sound, and open settings, where users can customize shell type, size, quality, lighting, and more. The SVG section defines reusable icons, and external JS files (Stage.js, MyMath.js, and script.js) handle the fireworks animation and interactivity. Overall, it’s a complete web-based fireworks simulation with user controls and responsive design.

    <!DOCTYPE html>
    <html lang="en">
    <head>
    
        <meta charset="UTF-8">
        <title>Animated Firework Diwali | @coding.stella</title>
        <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
    	<meta name="mobile-web-app-capable" content="yes">
    	<meta name="apple-mobile-web-app-capable" content="yes">
    	<meta name="theme-color" content="#000000">
    	<link rel="shortcut icon" type="image/png" href="https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/firework-burst-icon-v2.png">
    	<link rel="icon" type="image/png" href="https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/firework-burst-icon-v2.png">
    	<link rel="apple-touch-icon-precomposed" href="https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/firework-burst-icon-v2.png">
    	<meta name="msapplication-TileColor" content="#000000">
    	<meta name="msapplication-TileImage" content="https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/firework-burst-icon-v2.png">
    	<link href="https://fonts.googleapis.com/css?family=Russo+One" rel="stylesheet"><link rel="stylesheet" href="https://public.codepenassets.com/css/reset-2.0.min.css">
    	<link rel="stylesheet" href="./style.css">
    
    </head>
    <body>
      <!-- SVG Spritesheet -->
    <div style="height: 0; width: 0; position: absolute; visibility: hidden;">
    	<svg xmlns="http://www.w3.org/2000/svg">
    		<symbol id="icon-play" viewBox="0 0 24 24">
    			<path d="M8 5v14l11-7z"/>
    		</symbol>
    		<symbol id="icon-pause" viewBox="0 0 24 24">
    			<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
    		</symbol>
    		<symbol id="icon-close" viewBox="0 0 24 24">
    			<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
    		</symbol>
    		<symbol id="icon-settings" viewBox="0 0 24 24">
    			<path d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"/>
    		</symbol>
    		<symbol id="icon-sound-on" viewBox="0 0 24 24">
    			<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
    		</symbol>
    		<symbol id="icon-sound-off" viewBox="0 0 24 24">
    			<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
    		</symbol>
    	</svg>
    </div>
    
    <!-- App -->
    <div class="container">
    	<div class="loading-init">
    		<div class="loading-init__header">Loading</div>
    		<div class="loading-init__status">Assembling Shells</div>
    	</div>
    	<div class="stage-container remove">
    		<div class="canvas-container">
    			<canvas id="trails-canvas"></canvas>
    			<canvas id="main-canvas"></canvas>
    		</div>
    		<div class="controls">
    			<div class="btn pause-btn">
    				<svg fill="white" width="24" height="24"><use href="#icon-pause" xlink:href="#icon-pause"></use></svg>
    			</div>
    			<div class="btn sound-btn">
    				<svg fill="white" width="24" height="24"><use href="#icon-sound-off" xlink:href="#icon-sound-off"></use></svg>
    			</div>
    			<div class="btn settings-btn">
    				<svg fill="white" width="24" height="24"><use href="#icon-settings" xlink:href="#icon-settings"></use></svg>
    			</div>
    		</div>
    		<div class="menu hide">
    			<div class="menu__inner-wrap">
    				<div class="btn btn--bright close-menu-btn">
    					<svg fill="white" width="24" height="24"><use href="#icon-close" xlink:href="#icon-close"></use></svg>
    				</div>
    				<div class="menu__header">Settings</div>
    				<div class="menu__subheader">For more info, click any label.</div>
    				<form>
    					<div class="form-option form-option--select">
    						<label class="shell-type-label">Shell Type</label>
    						<select class="shell-type"></select>
    					</div>
    					<div class="form-option form-option--select">
    						<label class="shell-size-label">Shell Size</label>
    						<select class="shell-size"></select>
    					</div>
    					<div class="form-option form-option--select">
    						<label class="quality-ui-label">Quality</label>
    						<select class="quality-ui"></select>
    					</div>
    					<div class="form-option form-option--select">
    						<label class="sky-lighting-label">Sky Lighting</label>
    						<select class="sky-lighting"></select>
    					</div>
    					<div class="form-option form-option--select">
    						<label class="scaleFactor-label">Scale</label>
    						<select class="scaleFactor"></select>
    					</div>
    					<div class="form-option form-option--checkbox">
    						<label class="auto-launch-label">Auto Fire</label>
    						<input class="auto-launch" type="checkbox" />
    					</div>
    					<div class="form-option form-option--checkbox form-option--finale-mode">
    						<label class="finale-mode-label">Finale Mode</label>
    						<input class="finale-mode" type="checkbox" />
    					</div>
    					<div class="form-option form-option--checkbox">
    						<label class="hide-controls-label">Hide Controls</label>
    						<input class="hide-controls" type="checkbox" />
    					</div>
    					<div class="form-option form-option--checkbox form-option--fullscreen">
    						<label class="fullscreen-label">Fullscreen</label>
    						<input class="fullscreen" type="checkbox" />
    					</div>
    					<div class="form-option form-option--checkbox">
    						<label class="long-exposure-label">Open Shutter</label>
    						<input class="long-exposure" type="checkbox" />
    					</div>
    				</form>
    			</div>
    		</div>
    	</div>
    	<div class="help-modal">
    		<div class="help-modal__overlay"></div>
    		<div class="help-modal__dialog">
    			<div class="help-modal__header"></div>
    			<div class="help-modal__body"></div>
    			<button type="button" class="help-modal__close-btn">Close</button>
    		</div>
    	</div>
    </div>
        <script src='https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/fscreen%401.0.1.js'></script>
    <script src='https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/Stage%400.1.4.js'></script>
    <script src='https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/MyMath.js'></script><script  src="./script.js"></script>
    
    </body>
    </html>

    CSS :

    This CSS styles the Diwali fireworks animation webpage by giving it a dark theme and smooth layout. It centers everything using flexbox, hides scrollbars, and ensures elements fit well on all screen sizes. The .container holds the main content, while .loading-init shows a loading screen. The .stage-container and .canvas-container handle the fireworks display using two overlapping canvases. Controls like play, pause, and settings buttons are positioned at the top and fade on hover. The .menu and .help-modal use transitions for smooth opening/closing effects, while buttons and forms are styled with white, semi-transparent text for a glowing night look. Overall, it gives a clean, responsive, and cinematic feel to the fireworks animation.

    * {
      position: relative;
      box-sizing: border-box;
    }
    
    html,
    body {
      height: 100%;
    }
    
    html {
      background-color: #000;
    }
    
    body {
      overflow: hidden;
      color: rgba(255, 255, 255, 0.5);
      font-family: "Russo One", arial, sans-serif;
      line-height: 1.25;
      letter-spacing: 0.06em;
    }
    
    .hide {
      opacity: 0;
      visibility: hidden;
    }
    
    .remove {
      display: none !important;
    }
    
    .blur {
      filter: blur(12px);
    }
    
    .container {
      height: 100%;
      display: flex;
      justify-content: center;
      align-items: center;
    }
    
    .loading-init {
      width: 100%;
      align-self: center;
      text-align: center;
      text-transform: uppercase;
    }
    .loading-init__header {
      font-size: 2.2em;
    }
    .loading-init__status {
      margin-top: 1em;
      font-size: 0.8em;
      opacity: 0.75;
    }
    
    .stage-container {
      overflow: hidden;
      box-sizing: initial;
      border: 1px solid #222;
      margin: -1px;
    }
    @media (max-width: 840px) {
      .stage-container {
        border: none;
        margin: 0;
      }
    }
    
    .canvas-container {
      width: 100%;
      height: 100%;
      transition: filter 0.3s;
    }
    .canvas-container canvas {
      position: absolute;
      mix-blend-mode: lighten;
      transform: translateZ(0);
    }
    
    .controls {
      position: absolute;
      top: 0;
      width: 100%;
      padding-bottom: 50px;
      display: flex;
      justify-content: space-between;
      transition: opacity 0.3s, visibility 0.3s;
    }
    @media (min-width: 840px) {
      .controls {
        visibility: visible;
      }
      .controls.hide:hover {
        opacity: 1;
      }
    }
    
    .menu {
      position: absolute;
      top: 0;
      bottom: 0;
      left: 0;
      right: 0;
      background-color: rgba(0, 0, 0, 0.42);
      transition: opacity 0.3s, visibility 0.3s;
    }
    .menu__inner-wrap {
      display: flex;
      flex-direction: column;
      justify-content: center;
      align-items: center;
      position: absolute;
      top: 0;
      bottom: 0;
      left: 0;
      right: 0;
      transition: opacity 0.3s;
    }
    .menu__header {
      margin-top: auto;
      margin-bottom: 8px;
      padding-top: 16px;
      font-size: 2em;
      text-transform: uppercase;
    }
    .menu__subheader {
      margin-bottom: auto;
      padding-bottom: 12px;
      font-size: 0.86em;
      opacity: 0.8;
    }
    .menu form {
      width: 100%;
      max-width: 400px;
      padding: 0 10px;
      overflow: auto;
      -webkit-overflow-scrolling: touch;
    }
    .menu .form-option {
      display: flex;
      align-items: center;
      margin: 16px 0;
      transition: opacity 0.3s;
    }
    .menu .form-option label {
      display: block;
      width: 50%;
      padding-right: 12px;
      text-align: right;
      text-transform: uppercase;
      -webkit-user-select: none;
         -moz-user-select: none;
          -ms-user-select: none;
              user-select: none;
    }
    .menu .form-option--select select {
      display: block;
      width: 50%;
      height: 30px;
      font-size: 1rem;
      font-family: "Russo One", arial, sans-serif;
      color: rgba(255, 255, 255, 0.5);
      letter-spacing: 0.06em;
      background-color: transparent;
      border: 1px solid rgba(255, 255, 255, 0.5);
    }
    .menu .form-option--select select option {
      background-color: black;
    }
    .menu .form-option--checkbox input {
      display: block;
      width: 26px;
      height: 26px;
      margin: 0;
      opacity: 0.5;
    }
    @media (max-width: 840px) {
      .menu .form-option select, .menu .form-option input {
        outline: none;
      }
    }
    
    .close-menu-btn {
      position: absolute;
      top: 0;
      right: 0;
    }
    
    .btn {
      opacity: 0.16;
      width: 50px;
      height: 50px;
      display: flex;
      -webkit-user-select: none;
         -moz-user-select: none;
          -ms-user-select: none;
              user-select: none;
      cursor: default;
      transition: opacity 0.3s;
    }
    .btn--bright {
      opacity: 0.5;
    }
    @media (min-width: 840px) {
      .btn:hover {
        opacity: 0.32;
      }
      .btn--bright:hover {
        opacity: 0.75;
      }
    }
    .btn svg {
      display: block;
      margin: auto;
    }
    
    .help-modal {
      display: flex;
      justify-content: center;
      align-items: center;
      position: fixed;
      top: 0;
      bottom: 0;
      left: 0;
      right: 0;
      visibility: hidden;
      transition-property: visibility;
      transition-duration: 0.25s;
    }
    .help-modal__overlay {
      position: absolute;
      top: 0;
      bottom: 0;
      left: 0;
      right: 0;
      opacity: 0;
      transition-property: opacity;
      transition-timing-function: ease-in;
      transition-duration: 0.25s;
    }
    .help-modal__dialog {
      display: flex;
      flex-direction: column;
      align-items: center;
      max-width: 400px;
      max-height: calc(100vh - 100px);
      margin: 10px;
      padding: 20px;
      border-radius: 0.3em;
      background-color: rgba(0, 0, 0, 0.4);
      opacity: 0;
      transform: scale(0.9, 0.9);
      transition-property: opacity, transform;
      transition-timing-function: ease-in;
      transition-duration: 0.25s;
    }
    @media (min-width: 840px) {
      .help-modal__dialog {
        font-size: 1.25rem;
        max-width: 500px;
      }
    }
    .help-modal__header {
      font-size: 1.75em;
      text-transform: uppercase;
      text-align: center;
    }
    .help-modal__body {
      overflow-y: auto;
      -webkit-overflow-scrolling: touch;
      margin: 1em 0;
      padding: 1em 0;
      border-top: 1px solid rgba(255, 255, 255, 0.25);
      border-bottom: 1px solid rgba(255, 255, 255, 0.25);
      line-height: 1.5;
      color: rgba(255, 255, 255, 0.75);
    }
    .help-modal__close-btn {
      flex-shrink: 0;
      outline: none;
      border: none;
      border-radius: 2px;
      padding: 0.25em 0.75em;
      margin-top: 0.36em;
      font-family: "Russo One", arial, sans-serif;
      font-size: 1em;
      color: rgba(255, 255, 255, 0.5);
      text-transform: uppercase;
      letter-spacing: 0.06em;
      background-color: rgba(255, 255, 255, 0.25);
      transition: color 0.3s, background-color 0.3s;
    }
    .help-modal__close-btn:hover, .help-modal__close-btn:active, .help-modal__close-btn:focus {
      color: #FFF;
      background-color: #09F;
    }
    .help-modal.active {
      visibility: visible;
      transition-duration: 0.4s;
    }
    .help-modal.active .help-modal__overlay {
      opacity: 1;
      transition-timing-function: ease-out;
      transition-duration: 0.4s;
    }
    .help-modal.active .help-modal__dialog {
      opacity: 1;
      transform: scale(1, 1);
      transition-timing-function: ease-out;
      transition-duration: 0.4s;
    }

    JavaScript:

    This JavaScript code powers the interactive Diwali fireworks animation by controlling visuals, settings, and user interactions. It detects device type (mobile/desktop), sets quality and performance levels, and manages fullscreen, sound, and animation speed. The store object acts as the app’s brain – saving and loading user settings like shell size, quality, or lighting from localStorage. It also updates the canvas visuals and UI when settings change. Helper functions handle pausing, sound toggling, fullscreen mode, and launching fireworks automatically. In short, it manages all logic behind the fireworks’ appearance, behavior, and customization in real time.

    'use strict';
    console.clear();
    
    const IS_MOBILE = window.innerWidth <= 640;
    const IS_DESKTOP = window.innerWidth > 800;
    const IS_HEADER = IS_DESKTOP && window.innerHeight < 300;
    // Detect high end devices. This will be a moving target.
    const IS_HIGH_END_DEVICE = (() => {
    	const hwConcurrency = navigator.hardwareConcurrency;
    	if (!hwConcurrency) {
    		return false;
    	}
    	const minCount = window.innerWidth <= 1024 ? 4 : 8;
    	return hwConcurrency >= minCount;
    })();
    
    const MAX_WIDTH = 7680;
    const MAX_HEIGHT = 4320;
    const GRAVITY = 0.9; // Acceleration in px/s
    let simSpeed = 1;
    
    function getDefaultScaleFactor() {
    	if (IS_MOBILE) return 0.9;
    	if (IS_HEADER) return 0.75;
    	return 1;
    }
    
    let stageW, stageH;
    
    // All quality globals will be overwritten and updated via `configDidUpdate`.
    let quality = 1;
    let isLowQuality = false;
    let isNormalQuality = true;
    let isHighQuality = false;
    
    const QUALITY_LOW = 1;
    const QUALITY_NORMAL = 2;
    const QUALITY_HIGH = 3;
    
    const SKY_LIGHT_NONE = 0;
    const SKY_LIGHT_DIM = 1;
    const SKY_LIGHT_NORMAL = 2;
    
    const COLOR = {
    	Red: '#ff0043',
    	Green: '#14fc56',
    	Blue: '#1e7fff',
    	Purple: '#e60aff',
    	Gold: '#ffbf36',
    	White: '#ffffff'
    };
    
    // Special invisible color (not rendered, and therefore not in COLOR map)
    const INVISIBLE = '_INVISIBLE_';
    
    const PI_2 = Math.PI * 2;
    const PI_HALF = Math.PI * 0.5;
    
    // Stage.disableHighDPI = true;
    const trailsStage = new Stage('trails-canvas');
    const mainStage = new Stage('main-canvas');
    const stages = [
    	trailsStage,
    	mainStage
    ];
    
    
    
    // Fullscreen helpers, using Fscreen for prefixes.
    function fullscreenEnabled() {
    	return fscreen.fullscreenEnabled;
    }
    
    // Note that fullscreen state is synced to store, and the store should be the source
    // of truth for whether the app is in fullscreen mode or not.
    function isFullscreen() {
    	return !!fscreen.fullscreenElement;
    }
    
    // Attempt to toggle fullscreen mode.
    function toggleFullscreen() {
    	if (fullscreenEnabled()) {
    		if (isFullscreen()) {
    			fscreen.exitFullscreen();
    		} else {
    			fscreen.requestFullscreen(document.documentElement);
    		}
    	}
    }
    
    // Sync fullscreen changes with store. An event listener is necessary because the user can
    // toggle fullscreen mode directly through the browser, and we want to react to that.
    fscreen.addEventListener('fullscreenchange', () => {
    	store.setState({ fullscreen: isFullscreen() });
    });
    
    
    
    
    // Simple state container; the source of truth.
    const store = {
    	_listeners: new Set(),
    	_dispatch(prevState) {
    		this._listeners.forEach(listener => listener(this.state, prevState))
    	},
    	
    	state: {
    		// will be unpaused in init()
    		paused: true,
    		soundEnabled: false,
    		menuOpen: false,
    		openHelpTopic: null,
    		fullscreen: isFullscreen(),
    		// Note that config values used for <select>s must be strings, unless manually converting values to strings
    		// at render time, and parsing on change.
    		config: {
    			quality: String(IS_HIGH_END_DEVICE ? QUALITY_HIGH : QUALITY_NORMAL), // will be mirrored to a global variable named `quality` in `configDidUpdate`, for perf.
    			shell: 'Random',
    			size: IS_DESKTOP
    				? '3' // Desktop default
    				: IS_HEADER 
    					? '1.2' // Profile header default (doesn't need to be an int)
    					: '2', // Mobile default
    			autoLaunch: true,
    			finale: false,
    			skyLighting: SKY_LIGHT_NORMAL + '',
    			hideControls: IS_HEADER,
    			longExposure: false,
    			scaleFactor: getDefaultScaleFactor()
    		}
    	},
    	
    	setState(nextState) {
    		const prevState = this.state;
    		this.state = Object.assign({}, this.state, nextState);
    		this._dispatch(prevState);
    		this.persist();
    	},
    	
    	subscribe(listener) {
    		this._listeners.add(listener);
    		return () => this._listeners.remove(listener);
    	},
    	
    	// Load / persist select state to localStorage
    	// Mutates state because `store.load()` should only be called once immediately after store is created, before any subscriptions.
    	load() {
    		const serializedData = localStorage.getItem('cm_fireworks_data');
    		if (serializedData) {
    			const {
    				schemaVersion,
    				data
    			} = JSON.parse(serializedData);
    			
    			const config = this.state.config;
    			switch(schemaVersion) {
    				case '1.1':
    					config.quality = data.quality;
    					config.size = data.size;
    					config.skyLighting = data.skyLighting;
    					break;
    				case '1.2':
    					config.quality = data.quality;
    					config.size = data.size;
    					config.skyLighting = data.skyLighting;
    					config.scaleFactor = data.scaleFactor;
    					break;
    				default:
    					throw new Error('version switch should be exhaustive');
    			}
    			console.log(`Loaded config (schema version ${schemaVersion})`);
    		}
    		// Deprecated data format. Checked with care (it's not namespaced).
    		else if (localStorage.getItem('schemaVersion') === '1') {
    			let size;
    			// Attempt to parse data, ignoring if there is an error.
    			try {
    				const sizeRaw = localStorage.getItem('configSize');
    				size = typeof sizeRaw === 'string' && JSON.parse(sizeRaw);
    			}
    			catch(e) {
    				console.log('Recovered from error parsing saved config:');
    				console.error(e);
    				return;
    			}
    			// Only restore validated values
    			const sizeInt = parseInt(size, 10);
    			if (sizeInt >= 0 && sizeInt <= 4) {
    				this.state.config.size = String(sizeInt);
    			}
    		}
    	},
    	
    	persist() {
    		const config = this.state.config;
    		localStorage.setItem('cm_fireworks_data', JSON.stringify({
    			schemaVersion: '1.2',
    			data: {
    				quality: config.quality,
    				size: config.size,
    				skyLighting: config.skyLighting,
    				scaleFactor: config.scaleFactor
    			}
    		}));
    	}
    };
    
    
    if (!IS_HEADER) {
    	store.load();
    }
    
    // Actions
    // ---------
    
    function togglePause(toggle) {
    	const paused = store.state.paused;
    	let newValue;
    	if (typeof toggle === 'boolean') {
    		newValue = toggle;
    	} else {
    		newValue = !paused;
    	}
    
    	if (paused !== newValue) {
    		store.setState({ paused: newValue });
    	}
    }
    
    function toggleSound(toggle) {
    	if (typeof toggle === 'boolean') {
    		store.setState({ soundEnabled: toggle });
    	} else {
    		store.setState({ soundEnabled: !store.state.soundEnabled });
    	}
    }
    
    function toggleMenu(toggle) {
    	if (typeof toggle === 'boolean') {
    		store.setState({ menuOpen: toggle });
    	} else {
    		store.setState({ menuOpen: !store.state.menuOpen });
    	}
    }
    
    function updateConfig(nextConfig) {
    	nextConfig = nextConfig || getConfigFromDOM();
    	store.setState({
    		config: Object.assign({}, store.state.config, nextConfig)
    	});
    	
    	configDidUpdate();
    }
    
    // Map config to various properties & apply side effects
    function configDidUpdate() {
    	const config = store.state.config;
    	
    	quality = qualitySelector();
    	isLowQuality = quality === QUALITY_LOW;
    	isNormalQuality = quality === QUALITY_NORMAL;
    	isHighQuality = quality === QUALITY_HIGH;
    	
    	if (skyLightingSelector() === SKY_LIGHT_NONE) {
    		appNodes.canvasContainer.style.backgroundColor = '#000';
    	}
    	
    	Spark.drawWidth = quality === QUALITY_HIGH ? 0.75 : 1;
    }
    
    // Selectors
    // -----------
    
    const isRunning = (state=store.state) => !state.paused && !state.menuOpen;
    // Whether user has enabled sound.
    const soundEnabledSelector = (state=store.state) => state.soundEnabled;
    // Whether any sounds are allowed, taking into account multiple factors.
    const canPlaySoundSelector = (state=store.state) => isRunning(state) && soundEnabledSelector(state);
    // Convert quality to number.
    const qualitySelector = () => +store.state.config.quality;
    const shellNameSelector = () => store.state.config.shell;
    // Convert shell size to number.
    const shellSizeSelector = () => +store.state.config.size;
    const finaleSelector = () => store.state.config.finale;
    const skyLightingSelector = () => +store.state.config.skyLighting;
    const scaleFactorSelector = () => store.state.config.scaleFactor;
    
    
    
    // Help Content
    const helpContent = {
    	shellType: {
    		header: 'Shell Type',
    		body: 'The type of firework that will be launched. Select "Random" for a nice assortment!'
    	},
    	shellSize: {
    		header: 'Shell Size',
    		body: 'The size of the fireworks. Modeled after real firework shell sizes, larger shells have bigger bursts with more stars, and sometimes more complex effects. However, larger shells also require more processing power and may cause lag.'
    	},
    	quality: {
    		header: 'Quality',
    		body: 'Overall graphics quality. If the animation is not running smoothly, try lowering the quality. High quality greatly increases the amount of sparks rendered and may cause lag.'
    	},
    	skyLighting: {
    		header: 'Sky Lighting',
    		body: 'Illuminates the background as fireworks explode. If the background looks too bright on your screen, try setting it to "Dim" or "None".'
    	},
    	scaleFactor: {
    		header: 'Scale',
    		body: 'Allows scaling the size of all fireworks, essentially moving you closer or farther away. For larger shell sizes, it can be convenient to decrease the scale a bit, especially on phones or tablets.'
    	},
    	autoLaunch: {
    		header: 'Auto Fire',
    		body: 'Launches sequences of fireworks automatically. Sit back and enjoy the show, or disable to have full control.'
    	},
    	finaleMode: {
    		header: 'Finale Mode',
    		body: 'Launches intense bursts of fireworks. May cause lag. Requires "Auto Fire" to be enabled.'
    	},
    	hideControls: {
    		header: 'Hide Controls',
    		body: 'Hides the translucent controls along the top of the screen. Useful for screenshots, or just a more seamless experience. While hidden, you can still tap the top-right corner to re-open this menu.'
    	},
    	fullscreen: {
    		header: 'Fullscreen',
    		body: 'Toggles fullscreen mode.'
    	},
    	longExposure: {
    		header: 'Open Shutter',
    		body: 'Experimental effect that preserves long streaks of light, similar to leaving a camera shutter open.'
    	}
    };
    
    const nodeKeyToHelpKey = {
    	shellTypeLabel: 'shellType',
    	shellSizeLabel: 'shellSize',
    	qualityLabel: 'quality',
    	skyLightingLabel: 'skyLighting',
    	scaleFactorLabel: 'scaleFactor',
    	autoLaunchLabel: 'autoLaunch',
    	finaleModeLabel: 'finaleMode',
    	hideControlsLabel: 'hideControls',
    	fullscreenLabel: 'fullscreen',
    	longExposureLabel: 'longExposure'
    };
    
    
    // Render app UI / keep in sync with state
    const appNodes = {
    	stageContainer: '.stage-container',
    	canvasContainer: '.canvas-container',
    	controls: '.controls',
    	menu: '.menu',
    	menuInnerWrap: '.menu__inner-wrap',
    	pauseBtn: '.pause-btn',
    	pauseBtnSVG: '.pause-btn use',
    	soundBtn: '.sound-btn',
    	soundBtnSVG: '.sound-btn use',
    	shellType: '.shell-type',
    	shellTypeLabel: '.shell-type-label',
    	shellSize: '.shell-size',
    	shellSizeLabel: '.shell-size-label',
    	quality: '.quality-ui',
    	qualityLabel: '.quality-ui-label',
    	skyLighting: '.sky-lighting',
    	skyLightingLabel: '.sky-lighting-label',
    	scaleFactor: '.scaleFactor',
    	scaleFactorLabel: '.scaleFactor-label',
    	autoLaunch: '.auto-launch',
    	autoLaunchLabel: '.auto-launch-label',
    	finaleModeFormOption: '.form-option--finale-mode',
    	finaleMode: '.finale-mode',
    	finaleModeLabel: '.finale-mode-label',
    	hideControls: '.hide-controls',
    	hideControlsLabel: '.hide-controls-label',
    	fullscreenFormOption: '.form-option--fullscreen',
    	fullscreen: '.fullscreen',
    	fullscreenLabel: '.fullscreen-label',
    	longExposure: '.long-exposure',
    	longExposureLabel: '.long-exposure-label',
    	
    	// Help UI
    	helpModal: '.help-modal',
    	helpModalOverlay: '.help-modal__overlay',
    	helpModalHeader: '.help-modal__header',
    	helpModalBody: '.help-modal__body',
    	helpModalCloseBtn: '.help-modal__close-btn'
    };
    
    // Convert appNodes selectors to dom nodes
    Object.keys(appNodes).forEach(key => {
    	appNodes[key] = document.querySelector(appNodes[key]);
    });
    
    // Remove fullscreen control if not supported.
    if (!fullscreenEnabled()) {
    	appNodes.fullscreenFormOption.classList.add('remove');
    }
    
    // First render is called in init()
    function renderApp(state) {
    	const pauseBtnIcon = `#icon-${state.paused ? 'play' : 'pause'}`;
    	const soundBtnIcon = `#icon-sound-${soundEnabledSelector() ? 'on' : 'off'}`;
    	appNodes.pauseBtnSVG.setAttribute('href', pauseBtnIcon);
    	appNodes.pauseBtnSVG.setAttribute('xlink:href', pauseBtnIcon);
    	appNodes.soundBtnSVG.setAttribute('href', soundBtnIcon);
    	appNodes.soundBtnSVG.setAttribute('xlink:href', soundBtnIcon);
    	appNodes.controls.classList.toggle('hide', state.menuOpen || state.config.hideControls);
    	appNodes.canvasContainer.classList.toggle('blur', state.menuOpen);
    	appNodes.menu.classList.toggle('hide', !state.menuOpen);
    	appNodes.finaleModeFormOption.style.opacity = state.config.autoLaunch ? 1 : 0.32;
    	
    	appNodes.quality.value = state.config.quality;
    	appNodes.shellType.value = state.config.shell;
    	appNodes.shellSize.value = state.config.size;
    	appNodes.autoLaunch.checked = state.config.autoLaunch;
    	appNodes.finaleMode.checked = state.config.finale;
    	appNodes.skyLighting.value = state.config.skyLighting;
    	appNodes.hideControls.checked = state.config.hideControls;
    	appNodes.fullscreen.checked = state.fullscreen;
    	appNodes.longExposure.checked = state.config.longExposure;
    	appNodes.scaleFactor.value = state.config.scaleFactor.toFixed(2);
    	
    	appNodes.menuInnerWrap.style.opacity = state.openHelpTopic ? 0.12 : 1;
    	appNodes.helpModal.classList.toggle('active', !!state.openHelpTopic);
    	if (state.openHelpTopic) {
    		const { header, body } = helpContent[state.openHelpTopic];
    		appNodes.helpModalHeader.textContent = header;
    		appNodes.helpModalBody.textContent = body;
    	}
    }
    
    store.subscribe(renderApp);
    
    // Perform side effects on state changes
    function handleStateChange(state, prevState) {
    	const canPlaySound = canPlaySoundSelector(state);
    	const canPlaySoundPrev = canPlaySoundSelector(prevState);
    	
    	if (canPlaySound !== canPlaySoundPrev) {
    		if (canPlaySound) {
    			soundManager.resumeAll();
    		} else {
    			soundManager.pauseAll();
    		}
    	}
    }
    
    store.subscribe(handleStateChange);
    
    
    function getConfigFromDOM() {
    	return {
    		quality: appNodes.quality.value,
    		shell: appNodes.shellType.value,
    		size: appNodes.shellSize.value,
    		autoLaunch: appNodes.autoLaunch.checked,
    		finale: appNodes.finaleMode.checked,
    		skyLighting: appNodes.skyLighting.value,
    		longExposure: appNodes.longExposure.checked,
    		hideControls: appNodes.hideControls.checked,
    		// Store value as number.
    		scaleFactor: parseFloat(appNodes.scaleFactor.value)
    	};
    };
    
    const updateConfigNoEvent = () => updateConfig();
    appNodes.quality.addEventListener('input', updateConfigNoEvent);
    appNodes.shellType.addEventListener('input', updateConfigNoEvent);
    appNodes.shellSize.addEventListener('input', updateConfigNoEvent);
    appNodes.autoLaunch.addEventListener('click', () => setTimeout(updateConfig, 0));
    appNodes.finaleMode.addEventListener('click', () => setTimeout(updateConfig, 0));
    appNodes.skyLighting.addEventListener('input', updateConfigNoEvent);
    appNodes.longExposure.addEventListener('click', () => setTimeout(updateConfig, 0));
    appNodes.hideControls.addEventListener('click', () => setTimeout(updateConfig, 0));
    appNodes.fullscreen.addEventListener('click', () => setTimeout(toggleFullscreen, 0));
    // Changing scaleFactor requires triggering resize handling code as well.
    appNodes.scaleFactor.addEventListener('input', () => {
    	updateConfig();
    	handleResize();
    });
    
    Object.keys(nodeKeyToHelpKey).forEach(nodeKey => {
    	const helpKey = nodeKeyToHelpKey[nodeKey];
    	appNodes[nodeKey].addEventListener('click', () => {
    		store.setState({ openHelpTopic: helpKey });
    	});
    });
    
    appNodes.helpModalCloseBtn.addEventListener('click', () => {
    	store.setState({ openHelpTopic: null });
    });
    
    appNodes.helpModalOverlay.addEventListener('click', () => {
    	store.setState({ openHelpTopic: null });
    });
    
    
    
    // Constant derivations
    const COLOR_NAMES = Object.keys(COLOR);
    const COLOR_CODES = COLOR_NAMES.map(colorName => COLOR[colorName]);
    // Invisible stars need an indentifier, even through they won't be rendered - physics still apply.
    const COLOR_CODES_W_INVIS = [...COLOR_CODES, INVISIBLE];
    // Map of color codes to their index in the array. Useful for quickly determining if a color has already been updated in a loop.
    const COLOR_CODE_INDEXES = COLOR_CODES_W_INVIS.reduce((obj, code, i) => {
    	obj[code] = i;
    	return obj;
    }, {});
    // Tuples is a map keys by color codes (hex) with values of { r, g, b } tuples (still just objects).
    const COLOR_TUPLES = {};
    COLOR_CODES.forEach(hex => {
    	COLOR_TUPLES[hex] = {
    		r: parseInt(hex.substr(1, 2), 16),
    		g: parseInt(hex.substr(3, 2), 16),
    		b: parseInt(hex.substr(5, 2), 16),
    	};
    });
    
    // Get a random color.
    function randomColorSimple() {
    	return COLOR_CODES[Math.random() * COLOR_CODES.length | 0];
    }
    
    // Get a random color, with some customization options available.
    let lastColor;
    function randomColor(options) {
    	const notSame = options && options.notSame;
    	const notColor = options && options.notColor;
    	const limitWhite = options && options.limitWhite;
    	let color = randomColorSimple();
    	
    	// limit the amount of white chosen randomly
    	if (limitWhite && color === COLOR.White && Math.random() < 0.6) {
    		color = randomColorSimple();
    	}
    	
    	if (notSame) {
    		while (color === lastColor) {
    			color = randomColorSimple();
    		}
    	}
    	else if (notColor) {
    		while (color === notColor) {
    			color = randomColorSimple();
    		}
    	}
    	
    	lastColor = color;
    	return color;
    }
    
    function whiteOrGold() {
    	return Math.random() < 0.5 ? COLOR.Gold : COLOR.White;
    }
    
    
    // Shell helpers
    function makePistilColor(shellColor) {
    	return (shellColor === COLOR.White || shellColor === COLOR.Gold) ? randomColor({ notColor: shellColor }) : whiteOrGold();
    }
    
    // Unique shell types
    const crysanthemumShell = (size=1) => {
    	const glitter = Math.random() < 0.25;
    	const singleColor = Math.random() < 0.72;
    	const color = singleColor ? randomColor({ limitWhite: true }) : [randomColor(), randomColor({ notSame: true })];
    	const pistil = singleColor && Math.random() < 0.42;
    	const pistilColor = pistil && makePistilColor(color);
    	const secondColor = singleColor && (Math.random() < 0.2 || color === COLOR.White) ? pistilColor || randomColor({ notColor: color, limitWhite: true }) : null;
    	const streamers = !pistil && color !== COLOR.White && Math.random() < 0.42;
    	let starDensity = glitter ? 1.1 : 1.25;
    	if (isLowQuality) starDensity *= 0.8;
    	if (isHighQuality) starDensity = 1.2;
    	return {
    		shellSize: size,
    		spreadSize: 300 + size * 100,
    		starLife: 900 + size * 200,
    		starDensity,
    		color,
    		secondColor,
    		glitter: glitter ? 'light' : '',
    		glitterColor: whiteOrGold(),
    		pistil,
    		pistilColor,
    		streamers
    	};
    };
    
    
    const ghostShell = (size=1) => {
    	// Extend crysanthemum shell
    	const shell = crysanthemumShell(size);
    	// Ghost effect can be fast, so extend star life
    	shell.starLife *= 1.5;
    	// Ensure we always have a single color other than white
    	let ghostColor = randomColor({ notColor: COLOR.White });
    	// Always use streamers, and sometimes a pistil
    	shell.streamers = true;
    	const pistil = Math.random() < 0.42;
    	const pistilColor = pistil && makePistilColor(ghostColor);
    	// Ghost effect - transition from invisible to chosen color
    	shell.color = INVISIBLE;
    	shell.secondColor = ghostColor;
    	// We don't want glitter to be spewed by invisible stars, and we don't currently
    	// have a way to transition glitter state. So we'll disable it.
    	shell.glitter = '';
    	
    	return shell;
    };
    
    
    const strobeShell = (size=1) => {
    	const color = randomColor({ limitWhite: true });
    	return {
    		shellSize: size,
    		spreadSize: 280 + size * 92,
    		starLife: 1100 + size * 200,
    		starLifeVariation: 0.40,
    		starDensity: 1.1,
    		color,
    		glitter: 'light',
    		glitterColor: COLOR.White,
    		strobe: true,
    		strobeColor: Math.random() < 0.5 ? COLOR.White : null,
    		pistil: Math.random() < 0.5,
    		pistilColor: makePistilColor(color)
    	};
    };
    
    
    const palmShell = (size=1) => {
    	const color = randomColor();
    	const thick = Math.random() < 0.5;
    	return {
    		shellSize: size,
    		color,
    		spreadSize: 250 + size * 75,
    		starDensity: thick ? 0.15 : 0.4,
    		starLife: 1800 + size * 200,
    		glitter: thick ? 'thick' : 'heavy'
    	};
    };
    
    const ringShell = (size=1) => {
    	const color = randomColor();
    	const pistil = Math.random() < 0.75;
    	return {
    		shellSize: size,
    		ring: true,
    		color,
    		spreadSize: 300 + size * 100,
    		starLife: 900 + size * 200,
    		starCount: 2.2 * PI_2 * (size+1),
    		pistil,
    		pistilColor: makePistilColor(color),
    		glitter: !pistil ? 'light' : '',
    		glitterColor: color === COLOR.Gold ? COLOR.Gold : COLOR.White,
    		streamers: Math.random() < 0.3
    	};
    	// return Object.assign({}, defaultShell, config);
    };
    
    const crossetteShell = (size=1) => {
    	const color = randomColor({ limitWhite: true });
    	return {
    		shellSize: size,
    		spreadSize: 300 + size * 100,
    		starLife: 750 + size * 160,
    		starLifeVariation: 0.4,
    		starDensity: 0.85,
    		color,
    		crossette: true,
    		pistil: Math.random() < 0.5,
    		pistilColor: makePistilColor(color)
    	};
    };
    
    const floralShell = (size=1) => ({
    	shellSize: size,
    	spreadSize: 300 + size * 120,
    	starDensity: 0.12,
    	starLife: 500 + size * 50,
    	starLifeVariation: 0.5,
    	color: Math.random() < 0.65 ? 'random' : (Math.random() < 0.15 ? randomColor() : [randomColor(), randomColor({ notSame: true })]),
    	floral: true
    });
    
    const fallingLeavesShell = (size=1) => ({
    	shellSize: size,
    	color: INVISIBLE,
    	spreadSize: 300 + size * 120,
    	starDensity: 0.12,
    	starLife: 500 + size * 50,
    	starLifeVariation: 0.5,
    	glitter: 'medium',
    	glitterColor: COLOR.Gold,
    	fallingLeaves: true
    });
    
    const willowShell = (size=1) => ({
    	shellSize: size,
    	spreadSize: 300 + size * 100,
    	starDensity: 0.6,
    	starLife: 3000 + size * 300,
    	glitter: 'willow',
    	glitterColor: COLOR.Gold,
    	color: INVISIBLE
    });
    
    const crackleShell = (size=1) => {
    	// favor gold
    	const color = Math.random() < 0.75 ? COLOR.Gold : randomColor();
    	return {
    		shellSize: size,
    		spreadSize: 380 + size * 75,
    		starDensity: isLowQuality ? 0.65 : 1,
    		starLife: 600 + size * 100,
    		starLifeVariation: 0.32,
    		glitter: 'light',
    		glitterColor: COLOR.Gold,
    		color,
    		crackle: true,
    		pistil: Math.random() < 0.65,
    		pistilColor: makePistilColor(color)
    	};
    };
    
    const horsetailShell = (size=1) => {
    	const color = randomColor();
    	return {
    		shellSize: size,
    		horsetail: true,
    		color,
    		spreadSize: 250 + size * 38,
    		starDensity: 0.9,
    		starLife: 2500 + size * 300,
    		glitter: 'medium',
    		glitterColor: Math.random() < 0.5 ? whiteOrGold() : color,
    		// Add strobe effect to white horsetails, to make them more interesting
    		strobe: color === COLOR.White
    	};
    };
    
    function randomShellName() {
    	return Math.random() < 0.5 ? 'Crysanthemum' : shellNames[(Math.random() * (shellNames.length - 1) + 1) | 0 ];
    }
    
    function randomShell(size) {
    	if (IS_HEADER) return randomFastShell()(size);
    	// Normal operation
    	return shellTypes[randomShellName()](size);
    }
    
    function shellFromConfig(size) {
    	return shellTypes[shellNameSelector()](size);
    }
    
    // Get a random shell, not including processing intensive varients
    // Note this is only random when "Random" shell is selected in config.
    // Also, this does not create the shell, only returns the factory function.
    const fastShellBlacklist = ['Falling Leaves', 'Floral', 'Willow'];
    function randomFastShell() {
    	const isRandom = shellNameSelector() === 'Random';
    	let shellName = isRandom ? randomShellName() : shellNameSelector();
    	if (isRandom) {
    		while (fastShellBlacklist.includes(shellName)) {
    			shellName = randomShellName();
    		}
    	}
    	return shellTypes[shellName];
    }
    
    
    const shellTypes = {
    	'Random': randomShell,
    	'Crackle': crackleShell,
    	'Crossette': crossetteShell,
    	'Crysanthemum': crysanthemumShell,
    	'Falling Leaves': fallingLeavesShell,
    	'Floral': floralShell,
    	'Ghost': ghostShell,
    	'Horse Tail': horsetailShell,
    	'Palm': palmShell,
    	'Ring': ringShell,
    	'Strobe': strobeShell,
    	'Willow': willowShell
    };
    
    const shellNames = Object.keys(shellTypes);
    
    function init() {
    	// Remove loading state
    	document.querySelector('.loading-init').remove();
    	appNodes.stageContainer.classList.remove('remove');
    	
    	// Populate dropdowns
    	function setOptionsForSelect(node, options) {
    		node.innerHTML = options.reduce((acc, opt) => acc += `<option value="${opt.value}">${opt.label}</option>`, '');
    	}
    
    	// shell type
    	let options = '';
    	shellNames.forEach(opt => options += `<option value="${opt}">${opt}</option>`);
    	appNodes.shellType.innerHTML = options;
    	// shell size
    	options = '';
    	['3"', '4"', '6"', '8"', '12"', '16"'].forEach((opt, i) => options += `<option value="${i}">${opt}</option>`);
    	appNodes.shellSize.innerHTML = options;
    	
    	setOptionsForSelect(appNodes.quality, [
    		{ label: 'Low', value: QUALITY_LOW },
    		{ label: 'Normal', value: QUALITY_NORMAL },
    		{ label: 'High', value: QUALITY_HIGH }
    	]);
    	
    	setOptionsForSelect(appNodes.skyLighting, [
    		{ label: 'None', value: SKY_LIGHT_NONE },
    		{ label: 'Dim', value: SKY_LIGHT_DIM },
    		{ label: 'Normal', value: SKY_LIGHT_NORMAL }
    	]);
    	
    	// 0.9 is mobile default
    	setOptionsForSelect(
    		appNodes.scaleFactor,
    		[0.5, 0.62, 0.75, 0.9, 1.0, 1.5, 2.0]
    		.map(value => ({ value: value.toFixed(2), label: `${value*100}%` }))
    	);
    	
    	// Begin simulation
    	togglePause(false);
    	
    	// initial render
    	renderApp(store.state);
    	
    	// Apply initial config
    	configDidUpdate();
    }
    
    
    function fitShellPositionInBoundsH(position) {
    	const edge = 0.18;
    	return (1 - edge*2) * position + edge;
    }
    
    function fitShellPositionInBoundsV(position) {
    	return position * 0.75;
    }
    
    function getRandomShellPositionH() {
    	return fitShellPositionInBoundsH(Math.random());
    }
    
    function getRandomShellPositionV() {
    	return fitShellPositionInBoundsV(Math.random());
    }
    
    function getRandomShellSize() {
    	const baseSize = shellSizeSelector();
    	const maxVariance = Math.min(2.5, baseSize);
    	const variance = Math.random() * maxVariance;
    	const size = baseSize - variance;
    	const height = maxVariance === 0 ? Math.random() : 1 - (variance / maxVariance);
    	const centerOffset = Math.random() * (1 - height * 0.65) * 0.5;
    	const x = Math.random() < 0.5 ? 0.5 - centerOffset : 0.5 + centerOffset;
    	return {
    		size,
    		x: fitShellPositionInBoundsH(x),
    		height: fitShellPositionInBoundsV(height)
    	};
    }
    
    
    // Launches a shell from a user pointer event, based on state.config
    function launchShellFromConfig(event) {
    	const shell = new Shell(shellFromConfig(shellSizeSelector()));
    	const w = mainStage.width;
    	const h = mainStage.height;
    	
    	shell.launch(
    		event ? event.x / w : getRandomShellPositionH(),
    		event ? 1 - event.y / h : getRandomShellPositionV()
    	);
    }
    
    
    // Sequences
    // -----------
    
    function seqRandomShell() {
    	const size = getRandomShellSize();
    	const shell = new Shell(shellFromConfig(size.size));
    	shell.launch(size.x, size.height);
    	
    	let extraDelay = shell.starLife;
    	if (shell.fallingLeaves) {
    		extraDelay = 4600;
    	}
    	
    	return 900 + Math.random() * 600 + extraDelay;
    }
    
    function seqRandomFastShell() {
    	const shellType = randomFastShell();
    	const size = getRandomShellSize();
    	const shell = new Shell(shellType(size.size));
    	shell.launch(size.x, size.height);
    	
    	let extraDelay = shell.starLife;
    	
    	return 900 + Math.random() * 600 + extraDelay;
    }
    
    function seqTwoRandom() {
    	const size1 = getRandomShellSize();
    	const size2 = getRandomShellSize();
    	const shell1 = new Shell(shellFromConfig(size1.size));
    	const shell2 = new Shell(shellFromConfig(size2.size));
    	const leftOffset = Math.random() * 0.2 - 0.1;
    	const rightOffset = Math.random() * 0.2 - 0.1;
    	shell1.launch(0.3 + leftOffset, size1.height);
    	setTimeout(() => {
    		shell2.launch(0.7 + rightOffset, size2.height);
    	}, 100);
    	
    	let extraDelay = Math.max(shell1.starLife, shell2.starLife);
    	if (shell1.fallingLeaves || shell2.fallingLeaves) {
    		extraDelay = 4600;
    	}
    	
    	return 900 + Math.random() * 600 + extraDelay;
    }
    
    function seqTriple() {
    	const shellType = randomFastShell();
    	const baseSize = shellSizeSelector();
    	const smallSize = Math.max(0, baseSize - 1.25);
    	
    	const offset = Math.random() * 0.08 - 0.04;
    	const shell1 = new Shell(shellType(baseSize));
    	shell1.launch(0.5 + offset, 0.7);
    	
    	const leftDelay = 1000 + Math.random() * 400;
    	const rightDelay = 1000 + Math.random() * 400;
    	
    	setTimeout(() => {
    		const offset = Math.random() * 0.08 - 0.04;
    		const shell2 = new Shell(shellType(smallSize));
    		shell2.launch(0.2 + offset, 0.1);
    	}, leftDelay);
    	
    	setTimeout(() => {
    		const offset = Math.random() * 0.08 - 0.04;
    		const shell3 = new Shell(shellType(smallSize));
    		shell3.launch(0.8 + offset, 0.1);
    	}, rightDelay);
    	
    	return 4000;
    }
    
    function seqPyramid() {
    	const barrageCountHalf = IS_DESKTOP ? 7 : 4;
    	const largeSize = shellSizeSelector();
    	const smallSize = Math.max(0, largeSize - 3);
    	const randomMainShell = Math.random() < 0.78 ? crysanthemumShell : ringShell;
    	const randomSpecialShell = randomShell;
    
    	function launchShell(x, useSpecial) {
    		const isRandom = shellNameSelector() === 'Random';
    		let shellType = isRandom
    			? useSpecial ? randomSpecialShell : randomMainShell
    			: shellTypes[shellNameSelector()];
    		const shell = new Shell(shellType(useSpecial ? largeSize : smallSize));
    		const height = x <= 0.5 ? x / 0.5 : (1 - x) / 0.5;
    		shell.launch(x, useSpecial ? 0.75 : height * 0.42);
    	}
    	
    	let count = 0;
    	let delay = 0;
    	while(count <= barrageCountHalf) {
    		if (count === barrageCountHalf) {
    			setTimeout(() => {
    				launchShell(0.5, true);
    			}, delay);
    		} else {
    			const offset = count / barrageCountHalf * 0.5;
    			const delayOffset = Math.random() * 30 + 30;
    			setTimeout(() => {
    				launchShell(offset, false);
    			}, delay);
    			setTimeout(() => {
    				launchShell(1 - offset, false);
    			}, delay + delayOffset);
    		}
    		
    		count++;
    		delay += 200;
    	}
    	
    	return 3400 + barrageCountHalf * 250;
    }
    
    function seqSmallBarrage() {
    	seqSmallBarrage.lastCalled = Date.now();
    	const barrageCount = IS_DESKTOP ? 11 : 5;
    	const specialIndex = IS_DESKTOP ? 3 : 1;
    	const shellSize = Math.max(0, shellSizeSelector() - 2);
    	const randomMainShell = Math.random() < 0.78 ? crysanthemumShell : ringShell;
    	const randomSpecialShell = randomFastShell();
    	
    	// (cos(x*5π+0.5π)+1)/2 is a custom wave bounded by 0 and 1 used to set varying launch heights
    	function launchShell(x, useSpecial) {
    		const isRandom = shellNameSelector() === 'Random';
    		let shellType = isRandom
    			? useSpecial ? randomSpecialShell : randomMainShell
    			: shellTypes[shellNameSelector()];
    		const shell = new Shell(shellType(shellSize));
    		const height = (Math.cos(x*5*Math.PI + PI_HALF) + 1) / 2;
    		shell.launch(x, height * 0.75);
    	}
    	
    	let count = 0;
    	let delay = 0;
    	while(count < barrageCount) {
    		if (count === 0) {
    			launchShell(0.5, false)
    			count += 1;
    		}
    		else {
    			const offset = (count + 1) / barrageCount / 2;
    			const delayOffset = Math.random() * 30 + 30;
    			const useSpecial = count === specialIndex;
    			setTimeout(() => {
    				launchShell(0.5 + offset, useSpecial);
    			}, delay);
    			setTimeout(() => {
    				launchShell(0.5 - offset, useSpecial);
    			}, delay + delayOffset);
    			count += 2;
    		}
    		delay += 200;
    	}
    	
    	return 3400 + barrageCount * 120;
    }
    seqSmallBarrage.cooldown = 15000;
    seqSmallBarrage.lastCalled = Date.now();
    
    
    const sequences = [
    	seqRandomShell,
    	seqTwoRandom,
    	seqTriple,
    	seqPyramid,
    	seqSmallBarrage
    ];
    
    
    let isFirstSeq = true;
    const finaleCount = 32;
    let currentFinaleCount = 0;
    function startSequence() {
    	if (isFirstSeq) {
    		isFirstSeq = false;
    		if (IS_HEADER) {
    			return seqTwoRandom();
    		}
    		else {
    			const shell = new Shell(crysanthemumShell(shellSizeSelector()));
    			shell.launch(0.5, 0.5);
    			return 2400;
    		}
    	}
    	
    	if (finaleSelector()) {
    		seqRandomFastShell();
    		if (currentFinaleCount < finaleCount) {
    			currentFinaleCount++;
    			return 170;
    		}
    		else {
    			currentFinaleCount = 0;
    			return 6000;
    		}
    	}
    	
    	const rand = Math.random();
    	
    	if (rand < 0.08 && Date.now() - seqSmallBarrage.lastCalled > seqSmallBarrage.cooldown) {
    		return seqSmallBarrage();
    	}
    	
    	if (rand < 0.1) {
    		return seqPyramid();
    	}
    	
    	if (rand < 0.6 && !IS_HEADER) {
    		return seqRandomShell();
    	}
    	else if (rand < 0.8) {
    		return seqTwoRandom();
    	}
    	else if (rand < 1) {
    		return seqTriple();
    	}
    }
    
    
    let activePointerCount = 0;
    let isUpdatingSpeed = false;
    
    function handlePointerStart(event) {
    	activePointerCount++;
    	const btnSize = 50;
    	
    	if (event.y < btnSize) {
    		if (event.x < btnSize) {
    			togglePause();
    			return;
    		}
    		if (event.x > mainStage.width/2 - btnSize/2 && event.x < mainStage.width/2 + btnSize/2) {
    			toggleSound();
    			return;
    		}
    		if (event.x > mainStage.width - btnSize) {
    			toggleMenu();
    			return;
    		}
    	}
    	
    	if (!isRunning()) return;
    	
    	if (updateSpeedFromEvent(event)) {
    		isUpdatingSpeed = true;
    	}
    	else if (event.onCanvas) {
    		launchShellFromConfig(event);
    	}
    }
    
    function handlePointerEnd(event) {
    	activePointerCount--;
    	isUpdatingSpeed = false;
    }
    
    function handlePointerMove(event) {
    	if (!isRunning()) return;
    	
    	if (isUpdatingSpeed) {
    		updateSpeedFromEvent(event);
    	}
    }
    
    function handleKeydown(event) {
    	// P
    	if (event.keyCode === 80) {
    		togglePause();
    	}
    	// O
    	else if (event.keyCode === 79) {
    		toggleMenu();
    	}
    	// Esc
    	else if (event.keyCode === 27) {
    		toggleMenu(false);
    	}
    }
    
    mainStage.addEventListener('pointerstart', handlePointerStart);
    mainStage.addEventListener('pointerend', handlePointerEnd);
    mainStage.addEventListener('pointermove', handlePointerMove);
    window.addEventListener('keydown', handleKeydown);
    
    
    // Account for window resize and custom scale changes.
    function handleResize() {
    	const w = window.innerWidth;
    	const h = window.innerHeight;
    	// Try to adopt screen size, heeding maximum sizes specified
    	const containerW = Math.min(w, MAX_WIDTH);
    	// On small screens, use full device height
    	const containerH = w <= 420 ? h : Math.min(h, MAX_HEIGHT);
    	appNodes.stageContainer.style.width = containerW + 'px';
    	appNodes.stageContainer.style.height = containerH + 'px';
    	stages.forEach(stage => stage.resize(containerW, containerH));
    	// Account for scale
    	const scaleFactor = scaleFactorSelector();
    	stageW = containerW / scaleFactor;
    	stageH = containerH / scaleFactor;
    }
    
    // Compute initial dimensions
    handleResize();
    
    window.addEventListener('resize', handleResize);
    
    
    // Dynamic globals
    let currentFrame = 0;
    let speedBarOpacity = 0;
    let autoLaunchTime = 0;
    
    function updateSpeedFromEvent(event) {
    	if (isUpdatingSpeed || event.y >= mainStage.height - 44) {
    		// On phones it's hard to hit the edge pixels in order to set speed at 0 or 1, so some padding is provided to make that easier.
    		const edge = 16;
    		const newSpeed = (event.x - edge) / (mainStage.width - edge * 2);
    		simSpeed = Math.min(Math.max(newSpeed, 0), 1);
    		// show speed bar after an update
    		speedBarOpacity = 1;
    		// If we updated the speed, return true
    		return true;
    	}
    	// Return false if the speed wasn't updated
    	return false;
    }
    
    
    // Extracted function to keep `update()` optimized
    function updateGlobals(timeStep, lag) {
    	currentFrame++;
    	
    	// Always try to fade out speed bar
    	if (!isUpdatingSpeed) {
    	speedBarOpacity -= lag / 30; // half a second
    		if (speedBarOpacity < 0) {
    			speedBarOpacity = 0;
    		}
    	}
    	
    	// auto launch shells
    	if (store.state.config.autoLaunch) {
    		autoLaunchTime -= timeStep;
    		if (autoLaunchTime <= 0) {
    			autoLaunchTime = startSequence() * 1.25;
    		}
    	}
    }
    
    
    function update(frameTime, lag) {
    	if (!isRunning()) return;
    	
    	const width = stageW;
    	const height = stageH;
    	const timeStep = frameTime * simSpeed;
    	const speed = simSpeed * lag;
    	
    	updateGlobals(timeStep, lag);
    	
    	const starDrag = 1 - (1 - Star.airDrag) * speed;
    	const starDragHeavy = 1 - (1 - Star.airDragHeavy) * speed;
    	const sparkDrag = 1 - (1 - Spark.airDrag) * speed;
    	const gAcc = timeStep / 1000 * GRAVITY;
    	COLOR_CODES_W_INVIS.forEach(color => {
    		// Stars
    		const stars = Star.active[color];
    		for (let i=stars.length-1; i>=0; i=i-1) {
    			const star = stars[i];
    			// Only update each star once per frame. Since color can change, it's possible a star could update twice without this, leading to a "jump".
    			if (star.updateFrame === currentFrame) {
    				continue;
    			}
    			star.updateFrame = currentFrame;
    			
    			star.life -= timeStep;
    			if (star.life <= 0) {
    				stars.splice(i, 1);
    				Star.returnInstance(star);
    			} else {
    				const burnRate = Math.pow(star.life / star.fullLife, 0.5);
    				const burnRateInverse = 1 - burnRate;
    
    				star.prevX = star.x;
    				star.prevY = star.y;
    				star.x += star.speedX * speed;
    				star.y += star.speedY * speed;
    				// Apply air drag if star isn't "heavy". The heavy property is used for the shell comets.
    				if (!star.heavy) {
    					star.speedX *= starDrag;
    					star.speedY *= starDrag;
    				}
    				else {
    					star.speedX *= starDragHeavy;
    					star.speedY *= starDragHeavy;
    				}
    				star.speedY += gAcc;
    				
    				if (star.spinRadius) {
    					star.spinAngle += star.spinSpeed * speed;
    					star.x += Math.sin(star.spinAngle) * star.spinRadius * speed;
    					star.y += Math.cos(star.spinAngle) * star.spinRadius * speed;
    				}
    				
    				if (star.sparkFreq) {
    					star.sparkTimer -= timeStep;
    					while (star.sparkTimer < 0) {
    						star.sparkTimer += star.sparkFreq * 0.75 + star.sparkFreq * burnRateInverse * 4;
    						Spark.add(
    							star.x,
    							star.y,
    							star.sparkColor,
    							Math.random() * PI_2,
    							Math.random() * star.sparkSpeed * burnRate,
    							star.sparkLife * 0.8 + Math.random() * star.sparkLifeVariation * star.sparkLife
    						);
    					}
    				}
    				
    				// Handle star transitions
    				if (star.life < star.transitionTime) {
    					if (star.secondColor && !star.colorChanged) {
    						star.colorChanged = true;
    						star.color = star.secondColor;
    						stars.splice(i, 1);
    						Star.active[star.secondColor].push(star);
    						if (star.secondColor === INVISIBLE) {
    							star.sparkFreq = 0;
    						}
    					}
    					
    					if (star.strobe) {
    						// Strobes in the following pattern: on:off:off:on:off:off in increments of `strobeFreq` ms.
    						star.visible = Math.floor(star.life / star.strobeFreq) % 3 === 0;
    					}
    				}
    			}
    		}
    											
    		// Sparks
    		const sparks = Spark.active[color];
    		for (let i=sparks.length-1; i>=0; i=i-1) {
    			const spark = sparks[i];
    			spark.life -= timeStep;
    			if (spark.life <= 0) {
    				sparks.splice(i, 1);
    				Spark.returnInstance(spark);
    			} else {
    				spark.prevX = spark.x;
    				spark.prevY = spark.y;
    				spark.x += spark.speedX * speed;
    				spark.y += spark.speedY * speed;
    				spark.speedX *= sparkDrag;
    				spark.speedY *= sparkDrag;
    				spark.speedY += gAcc;
    			}
    		}
    	});
    	
    	render(speed);
    }
    
    function render(speed) {
    	const { dpr } = mainStage;
    	const width = stageW;
    	const height = stageH;
    	const trailsCtx = trailsStage.ctx;
    	const mainCtx = mainStage.ctx;
    	
    	if (skyLightingSelector() !== SKY_LIGHT_NONE) {
    		colorSky(speed);
    	}
    	
    	// Account for high DPI screens, and custom scale factor.
    	const scaleFactor = scaleFactorSelector();
    	trailsCtx.scale(dpr * scaleFactor, dpr * scaleFactor);
    	mainCtx.scale(dpr * scaleFactor, dpr * scaleFactor);
    	
    	trailsCtx.globalCompositeOperation = 'source-over';
    	trailsCtx.fillStyle = `rgba(0, 0, 0, ${store.state.config.longExposure ? 0.0025 : 0.175 * speed})`;
    	trailsCtx.fillRect(0, 0, width, height);
    	
    	mainCtx.clearRect(0, 0, width, height);
    	
    	// Draw queued burst flashes
    	// These must also be drawn using source-over due to Safari. Seems rendering the gradients using lighten draws large black boxes instead.
    	// Thankfully, these burst flashes look pretty much the same either way.
    	while (BurstFlash.active.length) {
    		const bf = BurstFlash.active.pop();
    		
    		const burstGradient = trailsCtx.createRadialGradient(bf.x, bf.y, 0, bf.x, bf.y, bf.radius);
    		burstGradient.addColorStop(0.024, 'rgba(255, 255, 255, 1)');
    		burstGradient.addColorStop(0.125, 'rgba(255, 160, 20, 0.2)');
    		burstGradient.addColorStop(0.32, 'rgba(255, 140, 20, 0.11)');
    		burstGradient.addColorStop(1, 'rgba(255, 120, 20, 0)');
    		trailsCtx.fillStyle = burstGradient;
    		trailsCtx.fillRect(bf.x - bf.radius, bf.y - bf.radius, bf.radius * 2, bf.radius * 2);
    		
    		BurstFlash.returnInstance(bf);
    	}
    	
    	// Remaining drawing on trails canvas will use 'lighten' blend mode
    	trailsCtx.globalCompositeOperation = 'lighten';
    	
    	// Draw stars
    	trailsCtx.lineWidth = Star.drawWidth;
    	trailsCtx.lineCap = isLowQuality ? 'square' : 'round';
    	mainCtx.strokeStyle = '#fff';
      mainCtx.lineWidth = 1;
    	mainCtx.beginPath();
    	COLOR_CODES.forEach(color => {
    		const stars = Star.active[color];
    		trailsCtx.strokeStyle = color;
    		trailsCtx.beginPath();
    		stars.forEach(star => {
    			if (star.visible) {
    				trailsCtx.moveTo(star.x, star.y);
    				trailsCtx.lineTo(star.prevX, star.prevY);
    				mainCtx.moveTo(star.x, star.y);
    				mainCtx.lineTo(star.x - star.speedX * 1.6, star.y - star.speedY * 1.6);
    			}
    		});
    		trailsCtx.stroke();
    	});
    	mainCtx.stroke();
    
    	// Draw sparks
    	trailsCtx.lineWidth = Spark.drawWidth;
    	trailsCtx.lineCap = 'butt';
    	COLOR_CODES.forEach(color => {
    		const sparks = Spark.active[color];
    		trailsCtx.strokeStyle = color;
    		trailsCtx.beginPath();
    		sparks.forEach(spark => {
    			trailsCtx.moveTo(spark.x, spark.y);
    			trailsCtx.lineTo(spark.prevX, spark.prevY);
    		});
    		trailsCtx.stroke();
    	});
    	
    	
    	// Render speed bar if visible
    	if (speedBarOpacity) {
    		const speedBarHeight = 6;
    		mainCtx.globalAlpha = speedBarOpacity;
    		mainCtx.fillStyle = COLOR.Blue;
    		mainCtx.fillRect(0, height - speedBarHeight, width * simSpeed, speedBarHeight);
    		mainCtx.globalAlpha = 1;
    	}
    	
    	
    	trailsCtx.setTransform(1, 0, 0, 1, 0, 0);
    	mainCtx.setTransform(1, 0, 0, 1, 0, 0);
    }
    
    
    // Draw colored overlay based on combined brightness of stars (light up the sky!)
    // Note: this is applied to the canvas container's background-color, so it's behind the particles
    const currentSkyColor = { r: 0, g: 0, b: 0 };
    const targetSkyColor = { r: 0, g: 0, b: 0 };
    function colorSky(speed) {
    	// The maximum r, g, or b value that will be used (255 would represent no maximum)
    	const maxSkySaturation = skyLightingSelector() * 15;
    	// How many stars are required in total to reach maximum sky brightness
    	const maxStarCount = 500;
    	let totalStarCount = 0;
    	// Initialize sky as black
    	targetSkyColor.r = 0;
    	targetSkyColor.g = 0;
    	targetSkyColor.b = 0;
    	// Add each known color to sky, multiplied by particle count of that color. This will put RGB values wildly out of bounds, but we'll scale them back later.
    	// Also add up total star count.
    	COLOR_CODES.forEach(color => {
    		const tuple = COLOR_TUPLES[color];
    		const count =  Star.active[color].length;
    		totalStarCount += count;
    		targetSkyColor.r += tuple.r * count;
    		targetSkyColor.g += tuple.g * count;
    		targetSkyColor.b += tuple.b * count;
    	});
    	
    	// Clamp intensity at 1.0, and map to a custom non-linear curve. This allows few stars to perceivably light up the sky, while more stars continue to increase the brightness but at a lesser rate. This is more inline with humans' non-linear brightness perception.
    	const intensity = Math.pow(Math.min(1, totalStarCount / maxStarCount), 0.3);
    	// Figure out which color component has the highest value, so we can scale them without affecting the ratios.
    	// Prevent 0 from being used, so we don't divide by zero in the next step.
    	const maxColorComponent = Math.max(1, targetSkyColor.r, targetSkyColor.g, targetSkyColor.b);
    	// Scale all color components to a max of `maxSkySaturation`, and apply intensity.
    	targetSkyColor.r = targetSkyColor.r / maxColorComponent * maxSkySaturation * intensity;
    	targetSkyColor.g = targetSkyColor.g / maxColorComponent * maxSkySaturation * intensity;
    	targetSkyColor.b = targetSkyColor.b / maxColorComponent * maxSkySaturation * intensity;
    	
    	// Animate changes to color to smooth out transitions.
    	const colorChange = 10;
    	currentSkyColor.r += (targetSkyColor.r - currentSkyColor.r) / colorChange * speed;
    	currentSkyColor.g += (targetSkyColor.g - currentSkyColor.g) / colorChange * speed;
    	currentSkyColor.b += (targetSkyColor.b - currentSkyColor.b) / colorChange * speed;
    	
    	appNodes.canvasContainer.style.backgroundColor = `rgb(${currentSkyColor.r | 0}, ${currentSkyColor.g | 0}, ${currentSkyColor.b | 0})`;
    }
    
    mainStage.addEventListener('ticker', update);
    
    
    // Helper used to semi-randomly spread particles over an arc
    // Values are flexible - `start` and `arcLength` can be negative, and `randomness` is simply a multiplier for random addition.
    function createParticleArc(start, arcLength, count, randomness, particleFactory) {
    	const angleDelta = arcLength / count;
    	// Sometimes there is an extra particle at the end, too close to the start. Subtracting half the angleDelta ensures that is skipped.
    	// Would be nice to fix this a better way.
    	const end = start + arcLength - (angleDelta * 0.5);
    	
    	if (end > start) {
    		// Optimization: `angle=angle+angleDelta` vs. angle+=angleDelta
    		// V8 deoptimises with let compound assignment
    		for (let angle=start; angle<end; angle=angle+angleDelta) {
    			particleFactory(angle + Math.random() * angleDelta * randomness);
    		}
    	} else {
    		for (let angle=start; angle>end; angle=angle+angleDelta) {
    			particleFactory(angle + Math.random() * angleDelta * randomness);
    		}
    	}
    }
    
    
    /**
     * Helper used to create a spherical burst of particles.
     *
     * @param  {Number} count               The desired number of stars/particles. This value is a suggestion, and the
     *                                      created burst may have more particles. The current algorithm can't perfectly
     *                                      distribute a specific number of points evenly on a sphere's surface.
     * @param  {Function} particleFactory   Called once per star/particle generated. Passed two arguments:
     *                                        `angle`: The direction of the star/particle.
     *                                        `speed`: A multipler for the particle speed, from 0.0 to 1.0.
     * @param  {Number} startAngle=0        For segmented bursts, you can generate only a partial arc of particles. This
     *                                      allows setting the starting arc angle (radians).
     * @param  {Number} arcLength=TAU       The length of the arc (radians). Defaults to a full circle.
     *
     * @return {void}              Returns nothing; it's up to `particleFactory` to use the given data.
     */
    function createBurst(count, particleFactory, startAngle=0, arcLength=PI_2) {
    	// Assuming sphere with surface area of `count`, calculate various
    	// properties of said sphere (unit is stars).
    	// Radius
    	const R = 0.5 * Math.sqrt(count/Math.PI);
    	// Circumference
    	const C = 2 * R * Math.PI;
    	// Half Circumference
    	const C_HALF = C / 2;
    	
    	// Make a series of rings, sizing them as if they were spaced evenly
    	// along the curved surface of a sphere.
    	for (let i=0; i<=C_HALF; i++) {
    		const ringAngle = i / C_HALF * PI_HALF;
    		const ringSize = Math.cos(ringAngle);
    		const partsPerFullRing = C * ringSize;
    		const partsPerArc = partsPerFullRing * (arcLength / PI_2);
    		
    		const angleInc = PI_2 / partsPerFullRing;
    		const angleOffset = Math.random() * angleInc + startAngle;
    		// Each particle needs a bit of randomness to improve appearance.
    		const maxRandomAngleOffset = angleInc * 0.33;
    		
    		for (let i=0; i<partsPerArc; i++) {
    			const randomAngleOffset = Math.random() * maxRandomAngleOffset;
    			let angle = angleInc * i + angleOffset + randomAngleOffset;
    			particleFactory(angle, ringSize);
    		}
    	}
    }
    
    
    
    
    // Various star effects.
    // These are designed to be attached to a star's `onDeath` event.
    
    // Crossette breaks star into four same-color pieces which branch in a cross-like shape.
    function crossetteEffect(star) {
    	const startAngle = Math.random() * PI_HALF;
    	createParticleArc(startAngle, PI_2, 4, 0.5, (angle) => {
    		Star.add(
    			star.x,
    			star.y,
    			star.color,
    			angle,
    			Math.random() * 0.6 + 0.75,
    			600
    		);
    	});
    }
    
    // Flower is like a mini shell
    function floralEffect(star) {
    	const count = 12 + 6 * quality;
    	createBurst(count, (angle, speedMult) => {
    		Star.add(
    			star.x,
    			star.y,
    			star.color,
    			angle,
    			speedMult * 2.4,
    			1000 + Math.random() * 300,
    			star.speedX,
    			star.speedY
    		);
    	});
    	// Queue burst flash render
    	BurstFlash.add(star.x, star.y, 46);
    	soundManager.playSound('burstSmall');
    }
    
    // Floral burst with willow stars
    function fallingLeavesEffect(star) {
    	createBurst(7, (angle, speedMult) => {
    		const newStar = Star.add(
    			star.x,
    			star.y,
    			INVISIBLE,
    			angle,
    			speedMult * 2.4,
    			2400 + Math.random() * 600,
    			star.speedX,
    			star.speedY
    		);
    		
    		newStar.sparkColor = COLOR.Gold;
    		newStar.sparkFreq = 144 / quality;
    		newStar.sparkSpeed = 0.28;
    		newStar.sparkLife = 750;
    		newStar.sparkLifeVariation = 3.2;
    	});
    	// Queue burst flash render
    	BurstFlash.add(star.x, star.y, 46);
    	soundManager.playSound('burstSmall');
    }
    
    // Crackle pops into a small cloud of golden sparks.
    function crackleEffect(star) {
    	const count = isHighQuality ? 32 : 16;
    	createParticleArc(0, PI_2, count, 1.8, (angle) => {
    		Spark.add(
    			star.x,
    			star.y,
    			COLOR.Gold,
    			angle,
    			// apply near cubic falloff to speed (places more particles towards outside)
    			Math.pow(Math.random(), 0.45) * 2.4,
    			300 + Math.random() * 200
    		);
    	});
    }
    
    
    
    /**
     * Shell can be constructed with options:
     *
     * spreadSize:      Size of the burst.
     * starCount: Number of stars to create. This is optional, and will be set to a reasonable quantity for size if omitted.
     * starLife:
     * starLifeVariation:
     * color:
     * glitterColor:
     * glitter: One of: 'light', 'medium', 'heavy', 'streamer', 'willow'
     * pistil:
     * pistilColor:
     * streamers:
     * crossette:
     * floral:
     * crackle:
     */
    class Shell {
    	constructor(options) {
    		Object.assign(this, options);
    		this.starLifeVariation = options.starLifeVariation || 0.125;
    		this.color = options.color || randomColor();
    		this.glitterColor = options.glitterColor || this.color;
    				
    		// Set default starCount if needed, will be based on shell size and scale exponentially, like a sphere's surface area.
    		if (!this.starCount) {
    			const density = options.starDensity || 1;
    			const scaledSize = this.spreadSize / 54;
    			this.starCount = Math.max(6, scaledSize * scaledSize * density);
    		}
    	}
    	
    	launch(position, launchHeight) {
    		const width = stageW;
    		const height = stageH;
    		// Distance from sides of screen to keep shells.
    		const hpad = 60;
    		// Distance from top of screen to keep shell bursts.
    		const vpad = 50;
    		// Minimum burst height, as a percentage of stage height
    		const minHeightPercent = 0.45;
    		// Minimum burst height in px
    		const minHeight = height - height * minHeightPercent;
    		
    		const launchX = position * (width - hpad * 2) + hpad;
    		const launchY = height;
    		const burstY = minHeight - (launchHeight * (minHeight - vpad));
    		
    		const launchDistance = launchY - burstY;
    		// Using a custom power curve to approximate Vi needed to reach launchDistance under gravity and air drag.
    		// Magic numbers came from testing.
    		const launchVelocity = Math.pow(launchDistance * 0.04, 0.64);
    		
    		const comet = this.comet = Star.add(
    			launchX,
    			launchY,
    			typeof this.color === 'string' && this.color !== 'random' ? this.color : COLOR.White,
    			Math.PI,
    			launchVelocity * (this.horsetail ? 1.2 : 1),
    			// Hang time is derived linearly from Vi; exact number came from testing
    			launchVelocity * (this.horsetail ? 100 : 400)
    		);
    		
    		// making comet "heavy" limits air drag
    		comet.heavy = true;
    		// comet spark trail
    		comet.spinRadius = MyMath.random(0.32, 0.85);
    		comet.sparkFreq = 32 / quality;
    		if (isHighQuality) comet.sparkFreq = 8;
    		comet.sparkLife = 320;
    		comet.sparkLifeVariation = 3;
    		if (this.glitter === 'willow' || this.fallingLeaves) {
    			comet.sparkFreq = 20 / quality;
    			comet.sparkSpeed = 0.5;
    			comet.sparkLife = 500;
    		}
    		if (this.color === INVISIBLE) {
    			comet.sparkColor = COLOR.Gold;
    		}
    		
    		// Randomly make comet "burn out" a bit early.
    		// This is disabled for horsetail shells, due to their very short airtime.
    		if (Math.random() > 0.4 && !this.horsetail) {
    			comet.secondColor = INVISIBLE;
    			comet.transitionTime = Math.pow(Math.random(), 1.5) * 700 + 500;
    		}
    		
    		comet.onDeath = comet => this.burst(comet.x, comet.y);
    		
    		soundManager.playSound('lift');
    	}
    	
    	burst(x, y) {
    		// Set burst speed so overall burst grows to set size. This specific formula was derived from testing, and is affected by simulated air drag.
    		const speed = this.spreadSize / 96;
    
    		let color, onDeath, sparkFreq, sparkSpeed, sparkLife;
    		let sparkLifeVariation = 0.25;
    		// Some death effects, like crackle, play a sound, but should only be played once.
    		let playedDeathSound = false;
    		
    		if (this.crossette) onDeath = (star) => {
    			if (!playedDeathSound) {
    				soundManager.playSound('crackleSmall');
    				playedDeathSound = true;
    			}
    			crossetteEffect(star);
    		}
    		if (this.crackle) onDeath = (star) => {
    			if (!playedDeathSound) {
    				soundManager.playSound('crackle');
    				playedDeathSound = true;
    			}
    			crackleEffect(star);
    		}
    		if (this.floral) onDeath = floralEffect;
    		if (this.fallingLeaves) onDeath = fallingLeavesEffect;
    		
    		if (this.glitter === 'light') {
    			sparkFreq = 400;
    			sparkSpeed = 0.3;
    			sparkLife = 300;
    			sparkLifeVariation = 2;
    		}
    		else if (this.glitter === 'medium') {
    			sparkFreq = 200;
    			sparkSpeed = 0.44;
    			sparkLife = 700;
    			sparkLifeVariation = 2;
    		}
    		else if (this.glitter === 'heavy') {
    			sparkFreq = 80;
    			sparkSpeed = 0.8;
    			sparkLife = 1400;
    			sparkLifeVariation = 2;
    		}
    		else if (this.glitter === 'thick') {
    			sparkFreq = 16;
    			sparkSpeed = isHighQuality ? 1.65 : 1.5;
    			sparkLife = 1400;
    			sparkLifeVariation = 3;
    		}
    		else if (this.glitter === 'streamer') {
    			sparkFreq = 32;
    			sparkSpeed = 1.05;
    			sparkLife = 620;
    			sparkLifeVariation = 2;
    		}
    		else if (this.glitter === 'willow') {
    			sparkFreq = 120;
    			sparkSpeed = 0.34;
    			sparkLife = 1400;
    			sparkLifeVariation = 3.8;
    		}
    		
    		// Apply quality to spark count
    		sparkFreq = sparkFreq / quality;
    		
    		// Star factory for primary burst, pistils, and streamers.
    		let firstStar = true;
    		const starFactory = (angle, speedMult) => {
    			// For non-horsetail shells, compute an initial vertical speed to add to star burst.
    			// The magic number comes from testing what looks best. The ideal is that all shell
    			// bursts appear visually centered for the majority of the star life (excl. willows etc.)
    			const standardInitialSpeed = this.spreadSize / 1800;
    			
    			const star = Star.add(
    				x,
    				y,
    				color || randomColor(),
    				angle,
    				speedMult * speed,
    				// add minor variation to star life
    				this.starLife + Math.random() * this.starLife * this.starLifeVariation,
    				this.horsetail ? this.comet && this.comet.speedX : 0,
    				this.horsetail ? this.comet && this.comet.speedY : -standardInitialSpeed
    			);
    	
    			if (this.secondColor) {
    				star.transitionTime = this.starLife * (Math.random() * 0.05 + 0.32);
    				star.secondColor = this.secondColor;
    			}
    
    			if (this.strobe) {
    				star.transitionTime = this.starLife * (Math.random() * 0.08 + 0.46);
    				star.strobe = true;
    				// How many milliseconds between switch of strobe state "tick". Note that the strobe pattern
    				// is on:off:off, so this is the "on" duration, while the "off" duration is twice as long.
    				star.strobeFreq = Math.random() * 20 + 40;
    				if (this.strobeColor) {
    					star.secondColor = this.strobeColor;
    				}
    			}
    			
    			star.onDeath = onDeath;
    
    			if (this.glitter) {
    				star.sparkFreq = sparkFreq;
    				star.sparkSpeed = sparkSpeed;
    				star.sparkLife = sparkLife;
    				star.sparkLifeVariation = sparkLifeVariation;
    				star.sparkColor = this.glitterColor;
    				star.sparkTimer = Math.random() * star.sparkFreq;
    			}
    		};
    		
    		
    		if (typeof this.color === 'string') {
    			if (this.color === 'random') {
    				color = null; // falsey value creates random color in starFactory
    			} else {
    				color = this.color;
    			}
    			
    			// Rings have positional randomness, but are rotated randomly
    			if (this.ring) {
    				const ringStartAngle = Math.random() * Math.PI;
    				const ringSquash = Math.pow(Math.random(), 2) * 0.85 + 0.15;;
    				
    				createParticleArc(0, PI_2, this.starCount, 0, angle => {
    					// Create a ring, squashed horizontally
    					const initSpeedX = Math.sin(angle) * speed * ringSquash;
    					const initSpeedY = Math.cos(angle) * speed;
    					// Rotate ring
    					const newSpeed = MyMath.pointDist(0, 0, initSpeedX, initSpeedY);
    					const newAngle = MyMath.pointAngle(0, 0, initSpeedX, initSpeedY) + ringStartAngle;
    					const star = Star.add(
    						x,
    						y,
    						color,
    						newAngle,
    						// apply near cubic falloff to speed (places more particles towards outside)
    						newSpeed,//speed,
    						// add minor variation to star life
    						this.starLife + Math.random() * this.starLife * this.starLifeVariation
    					);
    					
    					if (this.glitter) {
    						star.sparkFreq = sparkFreq;
    						star.sparkSpeed = sparkSpeed;
    						star.sparkLife = sparkLife;
    						star.sparkLifeVariation = sparkLifeVariation;
    						star.sparkColor = this.glitterColor;
    						star.sparkTimer = Math.random() * star.sparkFreq;
    					}
    				});
    			}
    			// Normal burst
    			else {
    				createBurst(this.starCount, starFactory);
    			}
    		}
    		else if (Array.isArray(this.color)) {
    			if (Math.random() < 0.5) {
    				const start = Math.random() * Math.PI;
    				const start2 = start + Math.PI;
    				const arc = Math.PI;
    				color = this.color[0];
    				// Not creating a full arc automatically reduces star count.
    				createBurst(this.starCount, starFactory, start, arc);
    				color = this.color[1];
    				createBurst(this.starCount, starFactory, start2, arc);
    			} else {
    				color = this.color[0];
    				createBurst(this.starCount / 2, starFactory);
    				color = this.color[1];
    				createBurst(this.starCount / 2, starFactory);
    			}
    		}
    		else {
    			throw new Error('Invalid shell color. Expected string or array of strings, but got: ' + this.color);
    		}
    		
    		if (this.pistil) {
    			const innerShell = new Shell({
    				spreadSize: this.spreadSize * 0.5,
    				starLife: this.starLife * 0.6,
    				starLifeVariation: this.starLifeVariation,
    				starDensity: 1.4,
    				color: this.pistilColor,
    				glitter: 'light',
    				glitterColor: this.pistilColor === COLOR.Gold ? COLOR.Gold : COLOR.White
    			});
    			innerShell.burst(x, y);
    		}
    		
    		if (this.streamers) {
    			const innerShell = new Shell({
    				spreadSize: this.spreadSize * 0.9,
    				starLife: this.starLife * 0.8,
    				starLifeVariation: this.starLifeVariation,
    				starCount: Math.floor(Math.max(6, this.spreadSize / 45)),
    				color: COLOR.White,
    				glitter: 'streamer'
    			});
    			innerShell.burst(x, y);
    		}
    		
    		// Queue burst flash render
    		BurstFlash.add(x, y, this.spreadSize / 4);
    
    		// Play sound, but only for "original" shell, the one that was launched.
    		// We don't want multiple sounds from pistil or streamer "sub-shells".
    		// This can be detected by the presence of a comet.
    		if (this.comet) {
    			// Scale explosion sound based on current shell size and selected (max) shell size.
    			// Shooting selected shell size will always sound the same no matter the selected size,
    			// but when smaller shells are auto-fired, they will sound smaller. It doesn't sound great
    			// when a value too small is given though, so instead of basing it on proportions, we just
    			// look at the difference in size and map it to a range known to sound good.
    			const maxDiff = 2;
    			const sizeDifferenceFromMaxSize = Math.min(maxDiff, shellSizeSelector() - this.shellSize);
    			const soundScale = (1 - sizeDifferenceFromMaxSize / maxDiff) * 0.3 + 0.7;
    			soundManager.playSound('burst', soundScale);
    		}
    	}
    }
    
    
    
    const BurstFlash = {
    	active: [],
    	_pool: [],
    	
    	_new() {
    		return {}
    	},
    	
    	add(x, y, radius) {
    		const instance = this._pool.pop() || this._new();
    		
    		instance.x = x;
    		instance.y = y;
    		instance.radius = radius;
    		
    		this.active.push(instance);
    		return instance;
    	},
    	
    	returnInstance(instance) {
    		this._pool.push(instance);
    	}
    };
    
    
    
    // Helper to generate objects for storing active particles.
    // Particles are stored in arrays keyed by color (code, not name) for improved rendering performance.
    function createParticleCollection() {
    	const collection = {};
    	COLOR_CODES_W_INVIS.forEach(color => {
    		collection[color] = [];
    	});
    	return collection;
    }
    
    
    // Star properties (WIP)
    // -----------------------
    // transitionTime - how close to end of life that star transition happens
    
    const Star = {
    	// Visual properties
    	drawWidth: 3,
    	airDrag: 0.98,
    	airDragHeavy: 0.992,
    	
    	// Star particles will be keyed by color
    	active: createParticleCollection(),
    	_pool: [],
    	
    	_new() {
    		return {};
    	},
    
    	add(x, y, color, angle, speed, life, speedOffX, speedOffY) {
    		const instance = this._pool.pop() || this._new();
    		
    		instance.visible = true;
    		instance.heavy = false;
    		instance.x = x;
    		instance.y = y;
    		instance.prevX = x;
    		instance.prevY = y;
    		instance.color = color;
    		instance.speedX = Math.sin(angle) * speed + (speedOffX || 0);
    		instance.speedY = Math.cos(angle) * speed + (speedOffY || 0);
    		instance.life = life;
    		instance.fullLife = life;
    		instance.spinAngle = Math.random() * PI_2;
    		instance.spinSpeed = 0.8;
    		instance.spinRadius = 0;
    		instance.sparkFreq = 0; // ms between spark emissions
    		instance.sparkSpeed = 1;
    		instance.sparkTimer = 0;
    		instance.sparkColor = color;
    		instance.sparkLife = 750;
    		instance.sparkLifeVariation = 0.25;
    		instance.strobe = false;
    		
    		this.active[color].push(instance);
    		return instance;
    	},
    
    	// Public method for cleaning up and returning an instance back to the pool.
    	returnInstance(instance) {
    		// Call onDeath handler if available (and pass it current star instance)
    		instance.onDeath && instance.onDeath(instance);
    		// Clean up
    		instance.onDeath = null;
    		instance.secondColor = null;
    		instance.transitionTime = 0;
    		instance.colorChanged = false;
    		// Add back to the pool.
    		this._pool.push(instance);
    	}
    };
    
    
    const Spark = {
    	// Visual properties
    	drawWidth: 0, // set in `configDidUpdate()`
    	airDrag: 0.9,
    	
    	// Star particles will be keyed by color
    	active: createParticleCollection(),
    	_pool: [],
    	
    	_new() {
    		return {};
    	},
    
    	add(x, y, color, angle, speed, life) {
    		const instance = this._pool.pop() || this._new();
    		
    		instance.x = x;
    		instance.y = y;
    		instance.prevX = x;
    		instance.prevY = y;
    		instance.color = color;
    		instance.speedX = Math.sin(angle) * speed;
    		instance.speedY = Math.cos(angle) * speed;
    		instance.life = life;
    		
    		this.active[color].push(instance);
    		return instance;
    	},
    
    	// Public method for cleaning up and returning an instance back to the pool.
    	returnInstance(instance) {
    		// Add back to the pool.
    		this._pool.push(instance);
    	}
    };
    
    
    
    const soundManager = {
    	baseURL: 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/',
    	ctx: new (window.AudioContext || window.webkitAudioContext),
    	sources: {
    		lift: {
    			volume: 1,
    			playbackRateMin: 0.85,
    			playbackRateMax: 0.95,
    			fileNames: [
    				'lift1.mp3',
    				'lift2.mp3',
    				'lift3.mp3'
    			]
    		},
    		burst: {
    			volume: 1,
    			playbackRateMin: 0.8,
    			playbackRateMax: 0.9,
    			fileNames: [
    				'burst1.mp3',
    				'burst2.mp3'
    			]
    		},
    		burstSmall: {
    			volume: 0.25,
    			playbackRateMin: 0.8,
    			playbackRateMax: 1,
    			fileNames: [
    				'burst-sm-1.mp3',
    				'burst-sm-2.mp3'
    			]
    		},
    		crackle: {
    			volume: 0.2,
    			playbackRateMin: 1,
    			playbackRateMax: 1,
    			fileNames: ['crackle1.mp3']
    		},
    		crackleSmall: {
    			volume: 0.3,
    			playbackRateMin: 1,
    			playbackRateMax: 1,
    			fileNames: ['crackle-sm-1.mp3']
    		}
    	},
    
    	preload() {
    		const allFilePromises = [];
    
    		function checkStatus(response) {
    			if (response.status >= 200 && response.status < 300) {
    				return response;
    			}
    			const customError = new Error(response.statusText);
    			customError.response = response;
    			throw customError;
    		}
    
    		const types = Object.keys(this.sources);
    		types.forEach(type => {
    			const source = this.sources[type];
    			const { fileNames } = source;
    			const filePromises = [];
    			fileNames.forEach(fileName => {
    				const fileURL = this.baseURL + fileName;
    				// Promise will resolve with decoded audio buffer.
    				const promise = fetch(fileURL)
    					.then(checkStatus)
    					.then(response => response.arrayBuffer())
    					.then(data => new Promise(resolve => {
    						this.ctx.decodeAudioData(data, resolve);
    					}));
    
    				filePromises.push(promise);
    				allFilePromises.push(promise);
    			});
    
    			Promise.all(filePromises)
    				.then(buffers => {
    					source.buffers = buffers;
    				});
    		});
    
    		return Promise.all(allFilePromises);
    	},
    	
    	pauseAll() {
    		this.ctx.suspend();
    	},
    
    	resumeAll() {
    		// Play a sound with no volume for iOS. This 'unlocks' the audio context when the user first enables sound.
    		this.playSound('lift', 0);
    		// Chrome mobile requires interaction before starting audio context.
    		// The sound toggle button is triggered on 'touchstart', which doesn't seem to count as a full
    		// interaction to Chrome. I guess it needs a click? At any rate if the first thing the user does
    		// is enable audio, it doesn't work. Using a setTimeout allows the first interaction to be registered.
    		// Perhaps a better solution is to track whether the user has interacted, and if not but they try enabling
    		// sound, show a tooltip that they should tap again to enable sound.
    		setTimeout(() => {
    			this.ctx.resume();
    		}, 250);
    	},
    	
    	// Private property used to throttle small burst sounds.
    	_lastSmallBurstTime: 0,
    
    	/**
    	 * Play a sound of `type`. Will randomly pick a file associated with type, and play it at the specified volume
    	 * and play speed, with a bit of random variance in play speed. This is all based on `sources` config.
    	 *
    	 * @param  {string} type - The type of sound to play.
    	 * @param  {?number} scale=1 - Value between 0 and 1 (values outside range will be clamped). Scales less than one
    	 *                             descrease volume and increase playback speed. This is because large explosions are
    	 *                             louder, deeper, and reverberate longer than small explosions.
    	 *                             Note that a scale of 0 will mute the sound.
    	 */
    	playSound(type, scale=1) {
    		// Ensure `scale` is within valid range.
    		scale = MyMath.clamp(scale, 0, 1);
    
    		// Disallow starting new sounds if sound is disabled, app is running in slow motion, or paused.
    		// Slow motion check has some wiggle room in case user doesn't finish dragging the speed bar
    		// *all* the way back.
    		if (!canPlaySoundSelector() || simSpeed < 0.95) {
    			return;
    		}
    		
    		// Throttle small bursts, since floral/falling leaves shells have a lot of them.
    		if (type === 'burstSmall') {
    			const now = Date.now();
    			if (now - this._lastSmallBurstTime < 20) {
    				return;
    			}
    			this._lastSmallBurstTime = now;
    		}
    		
    		const source = this.sources[type];
    
    		if (!source) {
    			throw new Error(`Sound of type "${type}" doesn't exist.`);
    		}
    		
    		const initialVolume = source.volume;
    		const initialPlaybackRate = MyMath.random(
    			source.playbackRateMin,
    			source.playbackRateMax
    		);
    		
    		// Volume descreases with scale.
    		const scaledVolume = initialVolume * scale;
    		// Playback rate increases with scale. For this, we map the scale of 0-1 to a scale of 2-1.
    		// So at a scale of 1, sound plays normally, but as scale approaches 0 speed approaches double.
    		const scaledPlaybackRate = initialPlaybackRate * (2 - scale);
    		
    		const gainNode = this.ctx.createGain();
    		gainNode.gain.value = scaledVolume;
    
    		const buffer = MyMath.randomChoice(source.buffers);
    		const bufferSource = this.ctx.createBufferSource();
    		bufferSource.playbackRate.value = scaledPlaybackRate;
    		bufferSource.buffer = buffer;
    		bufferSource.connect(gainNode);
    		gainNode.connect(this.ctx.destination);
    		bufferSource.start(0);
    	}
    };
    // Kick things off.
    
    function setLoadingStatus(status) {
    	document.querySelector('.loading-init__status').textContent = status;
    }
    
    if (IS_HEADER) {
    	init();
    } else {
    	// Allow status to render, then preload assets and start app.
    	setLoadingStatus('Lighting Fuses');
    	setTimeout(() => {
    		soundManager.preload()
    		.then(
    			init,
    			reason => {
    				init();
    				// setLoadingStatus('Error Loading Audio');
    				return Promise.reject(reason);
    			}
    		);
    	}, 0);
    }

    In conclusion, creating an Animated Firework Diwali using HTML, CSS, and JavaScript is a fun and creative way to celebrate the festival of lights through code. By combining structure, style, and interactivity, we’ve built a vibrant fireworks display that captures the spirit of Diwali. Let’s keep coding and celebrating with light, color, and joy! 🌟

    If your project has problems, don’t worry. Just click to download the source code and face your coding challenges with excitement. Have fun coding!

    Animation JavaScript Web Development
    Share. Copy Link Twitter Facebook LinkedIn Email WhatsApp
    Previous ArticleHow to create Escape Road Game using HTML CSS and JS
    Next Article How to create Animated LogOut Button using HTML CSS and JS
    Coding Stella
    • Website

    Related Posts

    JavaScript

    How to create Animated Social Media Card Hover using HTML CSS and JS

    20 October 2025
    JavaScript

    How to create Animated LogOut Button using HTML CSS and JS

    18 October 2025
    JavaScript

    How to create Escape Road Game using HTML CSS and JS

    14 October 2025
    Add A Comment
    Leave A Reply Cancel Reply

    Trending Post

    Master Frontend in 100 Days Ebook

    2 March 202429K Views

    How to make Modern Login Form using HTML & CSS | Glassmorphism

    11 January 202426K Views

    How to make I love you Animation in HTML CSS & JavaScript

    14 February 202421K Views

    How to make Valentine’s Day Card using HTML & CSS

    13 February 202414K Views
    Follow Us
    • Instagram
    • Facebook
    • YouTube
    • Twitter
    ads
    Featured Post

    How to make KFC Landing Page using HTML & CSS

    12 January 2024

    How to make Magic Mouse Effect using HTML CSS & JavaScript

    24 April 2024

    How to make Responsive Accordion Menu using HTML & CSS

    17 February 2024

    How to create New Year Animation using HTML CSS & JavaScript

    29 December 2024
    Latest Post

    How to create Animated Social Media Card Hover using HTML CSS and JS

    20 October 2025

    How to create Animated LogOut Button using HTML CSS and JS

    18 October 2025

    How to create Animated Firework Diwali using HTML CSS and JS

    16 October 2025

    How to create Escape Road Game using HTML CSS and JS

    14 October 2025
    Facebook X (Twitter) Instagram YouTube
    • About Us
    • Privacy Policy
    • Return and Refund Policy
    • Terms and Conditions
    • Contact Us
    • Buy me a coffee
    © 2025 Coding Stella. Made with 💙 by @coding.stella

    Type above and press Enter to search. Press Esc to cancel.

    Ad Blocker Enabled!
    Ad Blocker Enabled!
    Looks like you're using an ad blocker. We rely on advertising to help fund our site.
    Okay! I understood