Air Quality
An air quality card: AQI on a banded gauge, the main pollutant, an hourly strip, and advice that changes with the level.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/air-quality.json'use client'
import { useState } from 'react'
import './air-quality.css'
export interface AirQualityProps {
hours: { t: string; aqi: number }[]
pollutants?: { name: string; value: number; unit: string }[]
place?: string
fill?: 'dark' | 'light'
className?: string
}
const BANDS: [number, string, string][] = [
[50, 'Good', 'Air is clean. A great time to be outside.'],
[100, 'Moderate', 'Fine for most people. Unusually sensitive people may want to ease off.'],
[150, 'Unhealthy for sensitive groups', 'Sensitive groups should keep long outdoor efforts short.'],
[200, 'Unhealthy', 'Everyone may feel effects. Move workouts indoors.'],
[300, 'Very unhealthy', 'Avoid outdoor exertion; keep windows closed.'],
[500, 'Hazardous', 'Stay indoors with filtered air if you can.'],
]
const band = (v: number) => BANDS.findIndex(([m]) => v <= m)
const r1 = (n: number) => Math.round(n * 10) / 10
/** AQI gauge with hourly forecast. */
export function AirQuality({ hours, pollutants = [], place = 'Mandeville', fill = 'dark', className }: AirQualityProps) {
const [i, setI] = useState(0)
const v = hours[i]?.aqi ?? 0, b = Math.max(0, band(v))
const frac = Math.min(1, v / 300)
const arc = (a0: number, a1: number) => { const p = (a: number) => [r1(110 + 90 * Math.cos(Math.PI * (1 - a))), r1(110 - 90 * Math.sin(Math.PI * (1 - a)))]; const [x0, y0] = p(a0), [x1, y1] = p(a1); return `M${x0} ${y0} A90 90 0 0 1 ${x1} ${y1}` }
const edges = [0, 50, 100, 150, 200, 300].map((x) => x / 300)
const max = Math.max(...hours.map((h) => h.aqi), 60)
const main = pollutants.reduce((a, p, k) => (p.value > (pollutants[a]?.value ?? -1) ? k : a), 0)
return (
<div className={['sd-aqi', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-aqi__h"><b>Air quality</b><span>{place} · {i ? `forecast ${hours[i].t}` : 'now'}</span></div>
<div className="sd-aqi__g" role="meter" aria-label="Air quality index" aria-valuemin={0} aria-valuemax={500} aria-valuenow={v} aria-valuetext={`${v}, ${BANDS[b][1]}`}>
<svg viewBox="0 0 220 125" aria-hidden>
{edges.slice(0, -1).map((e, k) => <path key={k} className="b" data-on={k === b || undefined} d={arc(e + 0.006, edges[k + 1] - 0.006)} style={{ stroke: `color-mix(in srgb, var(--fg) ${18 + k * 16}%, var(--bg))` }} />)}
<line className="sd-aqi__nd" x1="110" y1="110" x2="40" y2="110" style={{ transform: `rotate(${frac * 180}deg)` }} />
<circle className="sd-aqi__hub" cx="110" cy="110" r="5" />
</svg>
<div className="sd-aqi__v" aria-live="polite"><b>{v}</b><span>{BANDS[b][1]}</span></div>
</div>
<p className="sd-aqi__adv">{BANDS[b][2]}</p>
{pollutants.length > 0 && <div className="sd-aqi__pol">{pollutants.map((p, k) => <span key={p.name} data-main={k === main || undefined}>{p.name} {p.value} {p.unit}</span>)}</div>}
<div className="sd-aqi__hr" aria-label="Hourly">{hours.map((h, k) => <button key={h.t} type="button" aria-pressed={i === k} aria-label={`${h.t}, AQI ${h.aqi}`} onClick={() => setI(k)}><i style={{ height: `${Math.max(6, (h.aqi / max) * 46)}px` }} />{k === 0 ? 'Now' : h.t}</button>)}</div>
</div>
)
}.sd-aqi { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-aqi[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-aqi :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-aqi :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-aqi .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-aqi { width: min(100%, 330px); padding: 14px; grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; gap: 10px; }
.sd-aqi__h { display: flex; justify-content: space-between; font-size: 12px; color: var(--mute); }
.sd-aqi__h b { color: var(--fg); font-size: 13.5px; }
.sd-aqi__g { position: relative; width: 220px; margin: 0 auto; }
.sd-aqi__g svg { width: 100%; display: block; overflow: visible; }
.sd-aqi__g path.b { fill: none; stroke-width: 12; }
.sd-aqi__g path.b[data-on] { stroke-width: 16; }
.sd-aqi__nd { stroke: var(--fg); stroke-width: 3; stroke-linecap: round; transition: transform 500ms cubic-bezier(0.3, 1.3, 0.5, 1); transform-origin: 110px 110px; }
.sd-aqi__hub { fill: var(--fg); }
.sd-aqi__v { text-align: center; margin-top: 2px; }
.sd-aqi__v b { display: block; font-size: 34px; font-weight: 700; letter-spacing: -0.02em; line-height: 1; font-variant-numeric: tabular-nums; }
.sd-aqi__v span { font-size: 12.5px; font-weight: 600; }
.sd-aqi__adv { margin: 0; text-align: center; font-size: 12.5px; color: var(--mute); min-height: 34px; }
.sd-aqi__pol { display: flex; gap: 5px; justify-content: center; flex-wrap: wrap; }
.sd-aqi__pol span { padding: 4px 9px; border-radius: 99px; background: var(--soft); font-size: 11.5px; font-variant-numeric: tabular-nums; }
.sd-aqi__pol span[data-main] { background: var(--strong) !important; color: var(--on) !important; }
.sd-aqi__hr { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 3px; align-items: end; height: 70px; }
.sd-aqi__hr button { display: grid; gap: 3px; justify-items: center; align-content: end; height: 100%; padding: 0; font-size: 10.5px; color: var(--mute) !important; }
.sd-aqi__hr button i { width: 100%; border-radius: 3px 3px 1px 1px; background: color-mix(in srgb, var(--fg) 30%, var(--bg)); transition: background-color 120ms; }
.sd-aqi__hr button[aria-pressed='true'] { color: var(--fg) !important; font-weight: 700; }
.sd-aqi__hr button[aria-pressed='true'] i { background: var(--fg); }
@media (prefers-reduced-motion: reduce) { .sd-aqi *, .sd-aqi *::before, .sd-aqi *::after { animation: none !important; transition: none !important; } }Overview
The gauge is a segmented arc with the six US AQI bands; the needle points at the current value and the band name is spelled out. Pollutant chips show PM2.5, ozone and others.
The hourly strip is a row of bars; tapping an hour moves the gauge to that forecast and updates the advice line.
When to use
- Weather apps, outdoor activity planners, health dashboards.
When not to use
- Regulatory reporting: show raw concentrations.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/air-quality.json$
npx shadcn@latest add https://sundaeui.dev/r/air-quality.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/air-quality.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/air-quality.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy air-quality.tsx and air-quality.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { AirQuality } from '@/components/sundae/air-quality'
<AirQuality />Props
| Prop | Type | Default |
|---|---|---|
hours*Hourly values, first is now. | { t: string; aqi: number }[] | — |
pollutantsReadings. | { name: string; value: number; unit: string }[] | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The gauge is a meter with the band name in its value text.
- Hours are buttons; the selected one is pressed.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-aqi; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Air Quality free?
- Yes. Air Quality is free for personal and commercial projects, with full source on this page.
- How do I install Air Quality?
- Run npx shadcn@latest add https://sundaeui.dev/r/air-quality.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Air Quality support keyboard and screen readers?
- The gauge is a meter with the band name in its value text. Hours are buttons; the selected one is pressed.
- Does Air Quality respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All data →- Pro
Tick Gauge
Data · Refined Weather Forecast
Data · RefinedStacked Bars
Data · Refined- Pro
Scrub Sparkline
Data · Refined Countdown Timer
Data · Refined- Pro
Donut Breakdown
Data · Refined