A connection card built around an oversized switch. The thumb is a wide white plate that carries the status and a shield line; it slides sideways with a small overshoot and uncovers a red dot on one side or a green dot on the other. Above it are the chosen server, a latency chip, a download reading that keeps moving while connected and a strip of bars showing recent speed. Three server buttons reconnect to another city through a short connecting state with an amber dot. Turning it off drops the reading to zero and greys the bars. The card is 340 pixels wide at most and shrinks to fit small phones.
// Adapted from Animata (https://animata.design/docs/widget/vpn-widget)
// Copyright (c) Animata. MIT License: https://we.inc/components/licenses
import { useEffect, useState } from "react";
import { ShieldAlert, ShieldCheck } from "lucide-react";
const servers = [
{ city: "Lisbon", latency: 24, speed: 86 },
{ city: "Toronto", latency: 71, speed: 62 },
{ city: "Osaka", latency: 148, speed: 41 },
];
const BARS = 18;
type Status = "off" | "connecting" | "on";
export default function VpnConnectionWidgetWithSlidingStatusSwitch() {
const [serverIndex, setServerIndex] = useState(0);
const [status, setStatus] = useState<Status>("on");
const server = servers[serverIndex];
const [speed, setSpeed] = useState(server.speed);
const [history, setHistory] = useState<number[]>(() => Array.from({ length: BARS }, (_, i) => 0.55 + 0.35 * Math.abs(Math.sin(i * 1.7))));
const connected = status === "on";
// Connecting takes a moment, like a real handshake.
useEffect(() => {
if (status !== "connecting") return;
const timer = window.setTimeout(() => setStatus("on"), 900);
return () => window.clearTimeout(timer);
}, [status, serverIndex]);
// While connected the reading wanders around the server's usual speed and feeds the little chart.
useEffect(() => {
if (!connected) {
setSpeed(0);
setHistory((current) => current.map(() => 0.06));
return;
}
const sample = () => {
const level = 0.6 + Math.random() * 0.4;
setSpeed(server.speed * (0.84 + level * 0.2));
setHistory((current) => [...current.slice(1), level]);
};
sample();
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const timer = window.setInterval(sample, 700);
return () => window.clearInterval(timer);
}, [connected, server.speed]);
const toggle = () => setStatus((current) => (current === "off" ? "connecting" : "off"));
const label = status === "on" ? "Connected" : status === "connecting" ? "Connecting" : "Disconnected";
return (
<div className="bg-slate-200 px-4 py-14 sm:py-20">
<div className="mx-auto w-full max-w-[340px] rounded-[2rem] bg-sky-950 p-1 shadow-2xl shadow-sky-950/40">
<div className="flex items-center justify-between px-5 pb-3 pt-4">
<div className="flex items-center gap-2 text-amber-300">
<svg viewBox="0 0 20 20" className="h-5 w-5" fill="currentColor" aria-hidden="true">
<path d="M4 2.5a.75.75 0 0 1 .75.75V4h9.7a.75.75 0 0 1 .6 1.2L13.2 7.75l1.85 2.55a.75.75 0 0 1-.6 1.2h-9.7v5.75a.75.75 0 0 1-1.5 0v-14A.75.75 0 0 1 4 2.5Z" />
</svg>
<p className="text-sm font-semibold tracking-tight">{server.city}</p>
</div>
<p className="text-xs font-medium text-sky-200">Harbour VPN</p>
</div>
<div className="rounded-[1.75rem] bg-white p-5">
<div className="flex items-center justify-between">
<span className={`inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs font-semibold tabular-nums ${connected ? "bg-emerald-100 text-emerald-800" : "bg-slate-100 text-slate-700"}`}>
<span className={`h-2 w-2 rounded-full ${connected ? "animate-pulse bg-emerald-500 motion-reduce:animate-none" : "bg-slate-400"}`} aria-hidden="true" />
{connected ? `${server.latency} ms` : status === "connecting" ? "Handshake" : "Offline"}
</span>
<span className="text-xs text-slate-600">Download</span>
</div>
<div className="mt-3 flex items-end justify-between gap-4">
<p className="text-5xl font-semibold tabular-nums tracking-tight text-slate-900">
{speed.toFixed(1)}
<span className="ml-1 text-sm font-medium text-slate-600">Mbps</span>
</p>
<div className="flex h-12 items-end gap-[3px]" aria-hidden="true">
{history.map((level, index) => (
<span
key={index}
className={`w-[3px] origin-bottom rounded-full transition-transform duration-500 ease-out motion-reduce:transition-none ${connected ? "bg-sky-500" : "bg-slate-300"}`}
style={{ height: "100%", transform: `scaleY(${level})`, opacity: 0.35 + (index / BARS) * 0.65 }}
/>
))}
</div>
</div>
<fieldset className="mt-5">
<legend className="sr-only">Server</legend>
<div className="grid grid-cols-3 gap-1.5">
{servers.map((item, index) => (
<label
key={item.city}
className={`flex h-10 cursor-pointer items-center justify-center rounded-xl text-sm font-medium transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-sky-600 ${
index === serverIndex ? "bg-sky-950 text-white" : "bg-slate-100 text-slate-700 hover:bg-slate-200"
}`}
>
<input
type="radio"
name="vpn-server"
aria-label={item.city}
className="sr-only"
checked={index === serverIndex}
onChange={() => {
setServerIndex(index);
if (status !== "off") setStatus("connecting");
}}
/>
{item.city}
</label>
))}
</div>
</fieldset>
{/* The switch: a wide thumb slides sideways and uncovers a red dot or a green one. */}
<button
type="button"
role="switch"
aria-checked={status !== "off"}
aria-label="VPN connection"
onClick={toggle}
className="relative mt-4 flex h-14 w-full items-center justify-between overflow-hidden rounded-2xl bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-600 focus-visible:ring-offset-2"
>
<span className="flex h-14 w-10 items-center justify-center" aria-hidden="true">
<span className={`h-2.5 w-2.5 rounded-full bg-red-500 transition-opacity duration-200 ${status === "off" ? "opacity-100" : "opacity-0"}`} />
</span>
<span className="flex h-14 w-10 items-center justify-center" aria-hidden="true">
<span className={`h-2.5 w-2.5 rounded-full transition-opacity duration-200 ${status === "on" ? "bg-emerald-500 opacity-100" : status === "connecting" ? "animate-pulse bg-amber-400 opacity-100 motion-reduce:animate-none" : "opacity-0"}`} />
</span>
<span
className={`absolute inset-y-1 left-1 right-10 flex flex-col items-center justify-center rounded-xl bg-white shadow-sm ring-1 ring-slate-200 transition-transform duration-300 ease-[cubic-bezier(0.3,1.4,0.5,1)] motion-reduce:transition-none ${
status === "off" ? "translate-x-9" : "translate-x-0"
}`}
>
<span className="text-sm font-semibold text-slate-900">{label}</span>
<span className={`inline-flex items-center gap-1 text-xs font-medium ${status === "off" ? "text-red-700" : "text-sky-700"}`}>
{status === "off" ? <ShieldAlert size={12} aria-hidden="true" /> : <ShieldCheck size={12} aria-hidden="true" />}
{status === "on" ? "Your traffic is encrypted" : status === "connecting" ? "Securing the tunnel" : "Not protected"}
</span>
</span>
</button>
</div>
</div>
</div>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/vpn-connection-widget-with-sliding-status-switch.json
The switch is a button with a red dot fixed at its left end and a green dot at its right. The thumb is an absolutely positioned plate that leaves one dot's width uncovered. A horizontal translate moves it from one end to the other, hiding one dot and showing the other.
Replace the timer in the first effect with your own call. Set the status to `connecting` when the user presses the switch, then to `on` or back to `off` when the call succeeds or fails. Feed real speed samples into `setSpeed` and `setHistory`.
Yes. Copy the button with `role="switch"` and keep a status value in state. It needs nothing from the card around it apart from the two shield icons, which come from lucide-react and can be swapped for your own.
Real connections fluctuate, so a number that never moves looks fake. The demo picks a new value near the server's typical speed several times a second and pushes it onto the bar strip.
Every bar is full height and squashed with `scaleY` from its bottom edge. Transforms do not trigger layout, so eighteen bars updating every 700 milliseconds cost very little.
Adapted from VPN Widget in Animata, used under its open-source licence. Licences and credits
Related: Settings Panel with Toggle Switches and Save Bar, Sun to Moon Theme Toggle with Circular Reveal, Segmented Control with Spring Pill and Morphing Views.
More toggles and switches or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.