Wi-Fi Networks
A Wi-Fi picker: signal bars, lock icons, a password prompt that expands in place, connecting state and forget network.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/wifi-networks.json'use client'
import { useState } from 'react'
import './wifi-networks.css'
export interface Network { ssid: string; signal: 0 | 1 | 2 | 3 | 4; secured: boolean }
export interface WifiNetworksProps {
networks: Network[]
onConnect: (ssid: string, password?: string) => boolean | Promise<boolean>
defaultConnected?: string
scanning?: boolean
defaultOn?: boolean
fill?: 'dark' | 'light'
className?: string
}
const Sig = ({ n }: { n: number }) => (
<svg className="sd-wif__sig" viewBox="0 0 20 16" aria-hidden>
{[0, 1, 2, 3].map((i) => <rect key={i} x={i * 5.2} y={12 - i * 3.6} width="3.6" height={4 + i * 3.6} rx="1" data-on={i < n || undefined} />)}
</svg>
)
const words = ['no', 'weak', 'fair', 'good', 'excellent']
/** Wi-Fi network list with inline password prompt. */
export function WifiNetworks({ networks, onConnect, defaultConnected, scanning = true, defaultOn = true, fill = 'dark', className }: WifiNetworksProps) {
const [on, setOn] = useState(defaultOn)
const [cur, setCur] = useState(defaultConnected)
const [open, setOpen] = useState<string | null>(null)
const [pw, setPw] = useState('')
const [show, setShow] = useState(false)
const [busy, setBusy] = useState<string | null>(null)
const [err, setErr] = useState(0)
const [menu, setMenu] = useState(false)
const connect = async (n: Network, p?: string) => {
setBusy(n.ssid); setErr(0)
const ok = await onConnect(n.ssid, p)
setBusy(null)
if (ok) { setCur(n.ssid); setOpen(null); setPw('') } else setErr((e) => e + 1)
}
const choose = (n: Network) => {
if (n.ssid === cur) { setMenu(!menu); return }
setMenu(false)
if (n.secured) { setOpen(open === n.ssid ? null : n.ssid); setPw(''); setErr(0) } else connect(n)
}
const sorted = [...networks].sort((a, b) => (a.ssid === cur ? -1 : b.ssid === cur ? 1 : b.signal - a.signal))
return (
<div className={['sd-wif', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-wif__top"><b>Wi-Fi</b><button type="button" role="switch" aria-checked={on} aria-label="Wi-Fi" className="sd-wif__sw" onClick={() => { setOn(!on); setOpen(null) }} /></div>
{!on ? <p className="sd-wif__off">Wi-Fi is off</p> : (
<>
<p className="sd-wif__h">Networks {scanning && <span className="sd-wif__spin" aria-label="Scanning" role="img" />}</p>
<div className="sd-wif__list" aria-live="polite">
{sorted.map((n) => {
const isOpen = open === n.ssid
return (
<div key={n.ssid} className="sd-wif__net" data-open={isOpen || (menu && n.ssid === cur) || undefined}>
<button type="button" className="sd-wif__row" aria-expanded={n.secured && n.ssid !== cur ? isOpen : undefined}
aria-label={`${n.ssid}, ${words[n.signal]} signal${n.secured ? ', secured' : ', open'}${n.ssid === cur ? ', connected' : ''}`} onClick={() => choose(n)}>
<Sig n={n.signal} />
<span className="sd-wif__name">{n.ssid}{n.ssid === cur && <small>Connected</small>}{busy === n.ssid && <small>Connecting…</small>}</span>
{busy === n.ssid ? <span className="sd-wif__busy" /> : n.ssid === cur ? <svg className="sd-wif__ok" viewBox="0 0 16 16" aria-hidden><path d="m3 8.5 3 3 7-7" /></svg>
: n.secured ? <svg className="sd-wif__ic" viewBox="0 0 14 14" aria-hidden><rect x="2.5" y="6" width="9" height="6.5" rx="1.5" /><path d="M4.5 6V4.5a2.5 2.5 0 0 1 5 0V6" /></svg> : null}
</button>
{menu && n.ssid === cur && <button type="button" className="sd-wif__fg" onClick={() => { setCur(undefined); setMenu(false) }}>Forget this network</button>}
{isOpen && (
<>
<form key={err} className="sd-wif__pw" data-shake={err ? '' : undefined} onSubmit={(e) => { e.preventDefault(); if (pw.length >= 8) connect(n, pw) }}>
<span className="sd-wif__field">
<input autoFocus type={show ? 'text' : 'password'} aria-label={`Password for ${n.ssid}`} placeholder="Password" value={pw} onChange={(e) => setPw(e.target.value)} />
<button type="button" className="sd-wif__eye" onClick={() => setShow(!show)} aria-pressed={show}>{show ? 'Hide' : 'Show'}</button>
</span>
<button type="submit" className="sd-wif__go" disabled={pw.length < 8 || !!busy}>Join</button>
</form>
{err > 0 && <p className="sd-wif__err" role="alert">Incorrect password. Try again.</p>}
</>
)}
</div>
)
})}
</div>
</>
)}
</div>
)
}.sd-wif { --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-wif[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-wif :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-wif :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-wif .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-wif { width: min(100%, 320px); border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; }
.sd-wif__top { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid var(--line); }
.sd-wif__top b { font-size: 15px; font-weight: 600; }
.sd-wif__sw { position: relative; width: 42px; height: 26px; border-radius: 99px; background: var(--line) !important; transition: background-color 160ms; }
.sd-wif__sw::after { content: ''; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px; border-radius: 99px; background: var(--fg); transition: transform 200ms cubic-bezier(0.3, 1.5, 0.4, 1); }
.sd-wif__sw[aria-checked='true'] { background: var(--strong) !important; }
.sd-wif__sw[aria-checked='true']::after { transform: translateX(16px); background: var(--on); }
.sd-wif__h { display: flex; align-items: center; gap: 8px; margin: 12px 16px 4px; font-size: 11.5px; font-weight: 600; color: var(--mute); text-transform: uppercase; letter-spacing: 0.04em; }
.sd-wif__spin { width: 11px; height: 11px; border-radius: 99px; border: 1.5px solid var(--line); border-top-color: var(--mute); animation: sd-wif-spin 900ms linear infinite; }
.sd-wif__list { padding: 0 6px 8px; max-height: 330px; overflow-y: auto; }
.sd-wif__net { border-radius: 12px; transition: background-color 120ms; }
.sd-wif__net[data-open] { background: var(--soft); }
.sd-wif__row { display: flex; align-items: center; gap: 12px; width: 100%; padding: 10px; text-align: left; border-radius: 12px; }
.sd-wif__row:hover { background: var(--soft); }
.sd-wif__sig { width: 20px; height: 16px; flex: none; }
.sd-wif__sig rect { fill: var(--line); }
.sd-wif__sig rect[data-on] { fill: var(--fg); }
.sd-wif__name { flex: 1; min-width: 0; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-wif__name small { display: block; font-size: 11.5px; color: var(--mute); }
.sd-wif__ic { width: 14px; height: 14px; flex: none; fill: none; stroke: var(--mute); stroke-width: 1.8; stroke-linecap: round; }
.sd-wif__ok { width: 16px; height: 16px; flex: none; fill: none; stroke: var(--fg); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
.sd-wif__busy { width: 14px; height: 14px; flex: none; border-radius: 99px; border: 2px solid var(--line); border-top-color: var(--fg); animation: sd-wif-spin 700ms linear infinite; }
.sd-wif__pw { display: flex; gap: 6px; padding: 0 10px 10px 42px; animation: sd-wif-in 220ms cubic-bezier(0.3, 1.2, 0.4, 1); }
.sd-wif__pw[data-shake] { animation: sd-wif-shake 380ms; }
.sd-wif__field { flex: 1; min-width: 0; display: flex; align-items: center; height: 36px; padding: 0 4px 0 10px; border-radius: 9px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-wif__field:focus-within { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-wif__field input { flex: 1; min-width: 0; border: 0; background: none; color: var(--fg); font: inherit; font-size: 13.5px; outline: 0; }
.sd-wif__eye { padding: 4px 6px; font-size: 11.5px; color: var(--mute) !important; }
.sd-wif__go { padding: 0 12px; height: 36px; border-radius: 9px; background: var(--strong) !important; color: var(--on) !important; font-size: 13px; font-weight: 600; }
.sd-wif__go:disabled { opacity: 0.4; cursor: not-allowed; }
.sd-wif__err { padding: 0 10px 10px 42px; margin-top: -4px; font-size: 12px; color: var(--fg); }
.sd-wif__fg { margin: 0 10px 10px 42px; padding: 6px 10px; border-radius: 8px; font-size: 12.5px; box-shadow: inset 0 0 0 1px var(--line); }
.sd-wif__off { padding: 60px 20px; text-align: center; color: var(--mute); font-size: 13px; }
@keyframes sd-wif-spin { to { transform: rotate(360deg); } }
@keyframes sd-wif-in { from { opacity: 0; transform: translateY(-4px); } }
@keyframes sd-wif-shake { 20%, 60% { transform: translateX(-6px); } 40%, 80% { transform: translateX(6px); } }
@media (prefers-reduced-motion: reduce) { .sd-wif *, .sd-wif *::before, .sd-wif *::after { animation: none !important; transition: none !important; } }Overview
Networks are sorted by signal. Choosing a secured one expands an inline password field with show/hide; open networks connect at once.
The connected network moves to the top with a check and a Forget action. A wrong password shakes and keeps the field open.
When to use
- Device setup, kiosk and IoT onboarding flows, settings screens.
When not to use
- Web apps that cannot actually join networks.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/wifi-networks.json$
npx shadcn@latest add https://sundaeui.dev/r/wifi-networks.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/wifi-networks.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/wifi-networks.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 wifi-networks.tsx and wifi-networks.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 { WifiNetworks } from '@/components/sundae/wifi-networks'
<WifiNetworks />Props
| Prop | Type | Default |
|---|---|---|
networks*SSID, signal 0–4, secured. | Network[] | — |
onConnectReturn false to reject. | (ssid: string, password?: string) => Promise<boolean> | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Each network is a button announcing name, signal strength and whether it is secured.
- Connection progress and errors are announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-wif; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
- Demo password for every secured network is “sundae123”.
FAQ
- Is Wi-Fi Networks free?
- Yes. Wi-Fi Networks is free for personal and commercial projects, with full source on this page.
- How do I install Wi-Fi Networks?
- Run npx shadcn@latest add https://sundaeui.dev/r/wifi-networks.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Wi-Fi Networks support keyboard and screen readers?
- Each network is a button announcing name, signal strength and whether it is secured. Connection progress and errors are announced.
- Does Wi-Fi Networks respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Password Strength
Forms · RefinedNotification Matrix
Forms · Refined- Pro
Shortcut Recorder
Forms · Refined Settings List
Forms · RefinedSign-in Card
Forms · RefinedAsync Switch
Forms · Refined