Control Center Panel — Free HTML CSS JS Snippet

Control Center Panel · Mobile · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Toggle tiles driven entirely by a data-on attribute, styled purely with CSS attribute selectors — no class-juggling logic
Per-tile bounce animation retriggered on every click via a forced offsetWidth reflow, even on rapid repeated clicks
Distinct active colors per toggle category (indigo default, orange for Airplane Mode, deep indigo for Focus)
Custom vertical slider built on raw Pointer Events — impossible to achieve with a rotated input type=range
valueFromClientY() inverts the Y-axis math so up means more and down means less, matching real-world slider expectations
setPointerCapture keeps drags tracking smoothly even when the pointer leaves the narrow 46px track
Double-rAF entrance trick guarantees the spring scale+fade animation reliably plays from its starting state on mount
Frosted-glass backdrop-filter blur card styling matching the real iOS Control Center aesthetic

About this UI Snippet

Control Center Panel — iOS-Style Toggle Tiles & Custom Vertical Sliders in Vanilla JS

Screenshot of the Control Center Panel snippet rendered live

iOS Control Center is one of the most recognizable pieces of mobile UI in existence: a frosted-glass card of square toggle tiles for Wi-Fi, Bluetooth, Airplane Mode and Focus, sitting alongside vertical sliders for brightness and volume that fill from the bottom as you drag. None of that is achievable with native HTML form controls — a checkbox does not bounce or recolor on toggle, and <input type="range"> is permanently horizontal with no supported way to rotate it into a bottom-up vertical fill bar without breaking its hit-testing and accessibility semantics. This snippet rebuilds the whole panel from primitive <button> elements and raw Pointer Events, giving full control over the visual state machine and letting the entrance, toggle, and drag interactions all be tuned independently.

Toggle tiles: a data attribute as the single source of truth

Each tile is a plain <button> with a data-on="true" or data-on="false" attribute — that attribute is the entire state model, and CSS attribute selectors (.tile[data-on="true"]) handle all the visual differences: background color, icon color, and icon-well opacity. Clicking a tile simply flips the attribute string and lets CSS transitions animate the background-color and color changes over 220ms. Airplane Mode and Focus get their own override colors (orange and indigo) when active, matching how iOS visually distinguishes different toggle categories rather than using one blanket "on" color for everything.

The bounce: retriggering a CSS animation from JavaScript

A naive classList.add('bounce') on a class that is already present does nothing, because the browser has no new animation to start. The fix used here is the classic reflow trick: classList.remove('bounce'), then read tile.offsetWidth (a layout property, forcing the browser to flush pending style changes synchronously), then classList.add('bounce') again. That forced read is what makes the browser treat the second add as a genuinely new animation start rather than a no-op, so every single click gets its own fresh scale-down-then-spring-back bounce, even multiple rapid clicks in a row.

The vertical slider: translating pointer Y into a 0-100% fill

This is the part a native <input type="range"> fundamentally cannot do cleanly. setupVerticalSlider() attaches pointerdown/pointermove/pointerup listeners to a track div, and on every relevant pointer event calls valueFromClientY(clientY), which does three things: reads the track's getBoundingClientRect() to get its position and height, subtracts the rect's top from the pointer's clientY to get a relative offset inside the track, and then computes 1 - (relY / rect.height) — the *inversion* is the key detail, since a lower relative Y (near the top of the track) should mean a higher value, and a relative Y near the track's bottom should mean a value near zero, the opposite of how Y coordinates normally increase downward. The result is multiplied by 100 and rounded to a clean percentage, then clamped between 0 and 100 in applyValue() before being written directly to the fill element's height style, so the colored bar visually grows from the bottom exactly where the pointer is.

Why Pointer Events and setPointerCapture, not mousedown/touchstart

Using the unified Pointer Events API means one set of listeners handles mouse, touch, and stylus input identically — no separate touch handler branch is needed. track.setPointerCapture(e.pointerId) on pointerdown is what keeps pointermove firing on the track element even if the user's finger or cursor drifts outside the narrow 46px-wide track mid-drag; without it, a fast or imprecise drag would stop updating the slider the instant the pointer left the element's bounding box, which feels broken on a control this narrow.

Entrance animation: double rAF to guarantee the transition fires

The panel starts at opacity: 0; transform: scale(0.82) in its base CSS, and the .mounted class (which triggers the spring keyframe animation) is added inside a nested pair of requestAnimationFrame calls rather than immediately on script load. A single rAF is sometimes not enough — the browser can still batch the class addition into the same style-calculation pass as the initial render, skipping the "from" state entirely and causing the animation to appear to snap instead of transition. Nesting two rAF calls guarantees the initial styles have been painted at least once before the animation-triggering class is applied, which is a reliable, dependency-free way to force an entrance animation to actually run from its starting state.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Give this snippet's code to an AI assistant like Claude and ask it to walk through why valueFromClientY() inverts the percentage calculation (1 minus the ratio, not just the ratio) — it's a small line that is easy to get backwards and worth understanding fully. Then try asking for a horizontal-lock so slider drags ignore horizontal pointer movement, a double-tap-to-mute gesture on the volume tile, or a version where toggling Airplane Mode also visually disables the Wi-Fi and Bluetooth tiles, the way real iOS behaves.

Prompt to recreate it

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

text
Build an iOS-style Control Center panel in plain HTML, CSS, and JavaScript — no frameworks, no native input type="range".

Requirements:
- A rounded, frosted-glass-styled card containing a 2x2 grid of toggle tiles for Wi-Fi, Bluetooth, Airplane Mode, and Focus/Do Not Disturb, each with a distinct SVG icon.
- Each tile's on/off state should be tracked with a single data attribute (not multiple classes), and clicking a tile should flip that attribute and transition its background color and icon color smoothly, with a couple of the tiles (like Airplane Mode) using a different accent color than the default when active.
- Every tile click must also play a quick squash-and-spring bounce animation, and it must replay correctly even on rapid repeated clicks on the same tile (hint: you cannot just re-add a class that's already present — you need to force a reflow between removing and re-adding it).
- Build at least one custom vertical slider (brightness or volume) as a div-based track with a fill element that grows from the bottom, NOT using input type="range", implemented with Pointer Events (pointerdown/pointermove/pointerup) and setPointerCapture so dragging keeps working even if the pointer strays outside the narrow track.
- The vertical slider's core math must convert a pointer's clientY position into a 0-100% value using the track's bounding rect, correctly inverting the axis so higher on the track means a higher percentage.
- Give the whole panel a spring-like scale-and-fade entrance animation when it first mounts, using a technique that reliably triggers the CSS transition from its starting state rather than snapping instantly (a double requestAnimationFrame is one valid approach).

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

Step by step

How to Use

  1. 1
    Watch the panel spring open on loadThe whole card scales up from slightly smaller than full size with a gentle overshoot and fades in, mimicking the swipe-down reveal of the real iOS Control Center.
  2. 2
    Click the Wi-Fi or Bluetooth tile to toggle it offThe tile background fades from solid indigo back to a dim neutral gray, the icon dims to match, and the tile plays a quick squash-and-spring bounce on every click.
  3. 3
    Click Airplane Mode or Focus to turn them onAirplane Mode turns orange and Focus turns deep indigo when active, each with the same bounce feedback, showing how different toggle categories get distinct active colors.
  4. 4
    Press and drag inside the Brightness sliderThe blue fill bar grows or shrinks from the bottom of the track, following your pointer position continuously as you drag up or down — not jumping in fixed steps.
  5. 5
    Click anywhere in the Volume track to jump directly to that levelA single click (without dragging) instantly sets the fill height to match the vertical position you clicked, exactly like adjusting brightness in Control Center with a tap.
  6. 6
    Drag past the top or bottom edge of a sliderThe fill value clamps cleanly at 0% or 100% instead of erroring or overshooting visually, even if your pointer moves outside the track while dragging thanks to pointer capture.

Real-world uses

Common Use Cases

Mobile web app quick-settings panels
Use as a real quick-settings drawer in a PWA or mobile-first web app, wiring each tile's data-on state to actual device or app settings (theme, notifications, connectivity mocks) instead of purely visual state.
iOS-style design system component reference
A faithful base for any product that wants an Apple-esque settings surface — pair with brightness-slider or multi-range-slider if you need a single reusable slider component extracted out.
Learn custom slider math without a UI library
The valueFromClientY() function is a self-contained, copy-paste-able lesson in translating pointer coordinates into a percentage value — the same core technique used in color pickers, custom scrubbers, and rating widgets.
Kiosk, dashboard, or smart-home control surfaces
Repurpose the tile grid and vertical sliders for a smart-home dashboard controlling real lights, thermostats, or media volume, since the interaction model already matches what users expect from a physical control panel.
Onboarding and demo screenshots for connectivity apps
Drop this into marketing pages or app screenshots for VPN, network utility, or system-monitor apps that want to visually reference the familiar Control Center metaphor without shipping real OS-level toggles.

Got questions?

Frequently Asked Questions

Rotating a native range input with CSS transform: rotate(-90deg) is a common workaround, but it breaks the input's hit-testing box (clicks land in the wrong place relative to what is visually shown), makes styling the fill-from-bottom track nearly impossible across browsers, and creates accessibility inconsistencies between screen readers reading the rotated versus visual orientation. Building the slider from a plain div and Pointer Events, as this snippet does, gives full control over the fill direction, hit area, and visual styling at the cost of manually implementing keyboard accessibility if you need it.

setupVerticalSlider() returns an object with get() and set(value) methods. The two slider instances are stored in the brightness and volume variables, so call brightness.get() to read the current 0-100 value at any time, or brightness.set(30) to programmatically move the slider (for example, syncing it to a real system brightness API).

Duplicate one .v-slider block in the HTML with new track/fill element IDs, add a matching CSS gradient rule if you want a distinct color, and call setupVerticalSlider("track-yourid", "fill-yourid", initialValue) in the script — the function is fully reusable and does not assume there are only two sliders on the page.

Yes. Model each tile's on/off state and each slider's value as component state (React useState, Vue ref, Angular component properties) rather than reading data-on attributes directly, and drive the pointer listeners from useEffect / onMounted / ngAfterViewInit, attaching them to a ref instead of getElementById. Remove the pointerdown/pointermove/pointerup listeners in the effect cleanup (React's returned cleanup function, onUnmounted, or ngOnDestroy) to avoid leaking listeners if the panel is conditionally unmounted while a drag is in progress.

A single rAF call can still land in the same style-calculation batch as the element's very first paint, so the browser never registers a distinct "before" state and the animation appears to snap instantly to its end state instead of transitioning. Nesting a second rAF inside the first guarantees at least one full frame has been painted with the initial (pre-animation) styles before the class that triggers the keyframe animation is applied, which reliably forces the transition to run from its starting values every time.