UI Components Medium
Funnel Chart
A conversion funnel chart that visualizes step-by-step drop-off rates. Features animated trapezoid stages, percentage labels, drop-off callouts, and a horizontal comparison mode.
Open in Lab
MCP
vanilla-js svg react tailwind vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #0f1117;
--surface: #16181f;
--surface2: #1e2130;
--border: #2a2d3a;
--text: #e2e8f0;
--text-muted: #64748b;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 32px 24px;
}
.chart-page {
max-width: 600px;
margin: 0 auto;
}
.chart-header {
margin-bottom: 24px;
}
.chart-title {
font-size: 1.2rem;
font-weight: 700;
}
.chart-sub {
font-size: 0.78rem;
color: var(--text-muted);
margin-top: 3px;
}
.funnel-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.funnel-stage {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
animation: stageIn .5s ease both;
}
@keyframes stageIn {
from {
opacity: 0;
transform: scaleY(0.6);
}
to {
opacity: 1;
transform: none;
}
}
.funnel-bar {
height: 52px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 18px;
cursor: pointer;
transition: opacity .15s;
min-width: 60px;
}
.funnel-bar:hover {
opacity: 0.85;
}
.funnel-name {
font-size: 0.875rem;
font-weight: 700;
color: #fff;
}
.funnel-val {
font-size: 0.875rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.8);
}
.funnel-drop {
font-size: 0.72rem;
color: var(--text-muted);
margin-top: 3px;
display: flex;
align-items: center;
gap: 4px;
text-align: center;
}
.drop-pct {
color: #f87171;
font-weight: 700;
}
.chart-tooltip {
position: fixed;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
font-size: 0.78rem;
pointer-events: none;
z-index: 100;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}const STAGES = [
{ label: "Visitors", value: 10000, color: "#818cf8" },
{ label: "Sign Ups", value: 4200, color: "#6366f1" },
{ label: "Onboarded", value: 2800, color: "#a78bfa" },
{ label: "Active Users", value: 1600, color: "#8b5cf6" },
{ label: "Paid Customers", value: 640, color: "#7c3aed" },
];
const tooltip = document.getElementById("chartTooltip");
const wrap = document.getElementById("funnelWrap");
const maxW = 560;
const minW = 80;
STAGES.forEach((stage, i) => {
const pct = ((stage.value / STAGES[0].value) * 100).toFixed(1);
const w = minW + (maxW - minW) * (stage.value / STAGES[0].value);
const stageEl = document.createElement("div");
stageEl.className = "funnel-stage";
stageEl.style.animationDelay = `${i * 0.1}s`;
const bar = document.createElement("div");
bar.className = "funnel-bar";
bar.style.width = w + "px";
bar.style.background = stage.color;
bar.innerHTML = `<span class="funnel-name">${stage.label}</span><span class="funnel-val">${stage.value.toLocaleString()}</span>`;
bar.addEventListener("mouseenter", (e) => {
const conv = ((stage.value / STAGES[0].value) * 100).toFixed(1);
tooltip.innerHTML = `<strong>${stage.label}</strong><br/>${stage.value.toLocaleString()} users<br/>Overall: ${conv}%`;
tooltip.hidden = false;
tooltip.style.left = e.clientX + 12 + "px";
tooltip.style.top = e.clientY - 40 + "px";
});
bar.addEventListener("mousemove", (e) => {
tooltip.style.left = e.clientX + 12 + "px";
tooltip.style.top = e.clientY - 40 + "px";
});
bar.addEventListener("mouseleave", () => (tooltip.hidden = true));
stageEl.appendChild(bar);
if (i < STAGES.length - 1) {
const drop = STAGES[i].value - STAGES[i + 1].value;
const dropPct = ((drop / STAGES[i].value) * 100).toFixed(1);
const dropEl = document.createElement("div");
dropEl.className = "funnel-drop";
dropEl.innerHTML = `▼ <span class="drop-pct">-${dropPct}%</span> dropped (${drop.toLocaleString()})`;
stageEl.appendChild(dropEl);
}
wrap.appendChild(stageEl);
});<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>Funnel Chart</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="chart-page">
<div class="chart-header">
<h1 class="chart-title">Conversion Funnel</h1>
<p class="chart-sub">Signup → Activation → Revenue</p>
</div>
<div class="funnel-wrap" id="funnelWrap"></div>
</div>
<div class="chart-tooltip" id="chartTooltip" hidden></div>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
const STAGES = [
{ label: "Visitors", value: 10000, color: "#818cf8" },
{ label: "Sign Ups", value: 4200, color: "#6366f1" },
{ label: "Onboarded", value: 2800, color: "#a78bfa" },
{ label: "Active Users", value: 1600, color: "#8b5cf6" },
{ label: "Paid Customers", value: 640, color: "#7c3aed" },
];
type Tooltip = { stage: (typeof STAGES)[0]; x: number; y: number } | null;
export default function ChartFunnelRC() {
const [tooltip, setTooltip] = useState<Tooltip>(null);
const maxW = 520;
const minW = 80;
return (
<div className="min-h-screen bg-[#0d1117] p-6 flex justify-center">
<div className="w-full max-w-[640px]">
<div className="flex flex-col gap-0">
{STAGES.map((stage, i) => {
const pct = ((stage.value / STAGES[0].value) * 100).toFixed(1);
const w = minW + (maxW - minW) * (stage.value / STAGES[0].value);
const drop = i < STAGES.length - 1 ? STAGES[i].value - STAGES[i + 1].value : null;
const dropPct = drop ? ((drop / stage.value) * 100).toFixed(1) : null;
return (
<div
key={stage.label}
className="flex flex-col items-center"
style={{ animation: `funnelIn 0.4s ${i * 0.1}s ease both`, opacity: 0 }}
>
<div
className="flex items-center justify-between px-4 py-3 rounded-lg cursor-pointer transition-opacity hover:opacity-90"
style={{ width: w, background: stage.color, maxWidth: "100%" }}
onMouseEnter={(e) => setTooltip({ stage, x: e.clientX, y: e.clientY })}
onMouseMove={(e) =>
setTooltip((t) => (t ? { ...t, x: e.clientX, y: e.clientY } : null))
}
onMouseLeave={() => setTooltip(null)}
>
<span className="text-white font-semibold text-[13px]">{stage.label}</span>
<span className="text-white/80 text-[13px]">{stage.value.toLocaleString()}</span>
</div>
{drop !== null && (
<div className="flex items-center gap-2 py-1.5 text-[11px] text-[#484f58]">
<span className="text-[#f87171]">▼</span>
<span className="text-[#f87171] font-semibold">-{dropPct}%</span>
<span>dropped ({drop.toLocaleString()})</span>
</div>
)}
</div>
);
})}
</div>
{/* Summary row */}
<div className="flex justify-center gap-6 mt-6 pt-4 border-t border-[#21262d]">
{STAGES.map((s) => (
<div key={s.label} className="text-center">
<div className="text-[18px] font-bold" style={{ color: s.color }}>
{((s.value / STAGES[0].value) * 100).toFixed(0)}%
</div>
<div className="text-[10px] text-[#484f58] mt-0.5">{s.label}</div>
</div>
))}
</div>
</div>
{tooltip && (
<div
className="fixed pointer-events-none bg-[#161b22] border border-[#30363d] rounded-lg px-3 py-2 text-[12px] shadow-lg z-50"
style={{ left: tooltip.x + 12, top: tooltip.y - 40 }}
>
<div className="font-semibold" style={{ color: tooltip.stage.color }}>
{tooltip.stage.label}
</div>
<div className="text-[#8b949e]">{tooltip.stage.value.toLocaleString()} users</div>
<div className="text-[#8b949e]">
Overall: {((tooltip.stage.value / STAGES[0].value) * 100).toFixed(1)}%
</div>
</div>
)}
<style>{`@keyframes funnelIn{from{opacity:0;transform:scaleX(0.8)}to{opacity:1;transform:none}}`}</style>
</div>
);
}<script setup>
import { ref } from "vue";
const STAGES = [
{ label: "Visitors", value: 10000, color: "#818cf8" },
{ label: "Sign Ups", value: 4200, color: "#6366f1" },
{ label: "Onboarded", value: 2800, color: "#a78bfa" },
{ label: "Active Users", value: 1600, color: "#8b5cf6" },
{ label: "Paid Customers", value: 640, color: "#7c3aed" },
];
const maxW = 520;
const minW = 80;
const tooltip = ref(null);
function getWidth(stage) {
return minW + (maxW - minW) * (stage.value / STAGES[0].value);
}
function getDrop(i) {
if (i >= STAGES.length - 1) return null;
const drop = STAGES[i].value - STAGES[i + 1].value;
const pct = ((drop / STAGES[i].value) * 100).toFixed(1);
return { drop, pct };
}
function showTip(stage, e) {
tooltip.value = { stage, x: e.clientX, y: e.clientY };
}
function moveTip(e) {
if (tooltip.value) tooltip.value = { ...tooltip.value, x: e.clientX, y: e.clientY };
}
function hideTip() {
tooltip.value = null;
}
</script>
<template>
<div class="page">
<div class="container">
<div class="funnel">
<div v-for="(stage, i) in STAGES" :key="stage.label" class="stage-wrap"
:style="{ '--delay': i * 0.1 + 's' }">
<div class="stage-bar" :style="{ width: getWidth(stage) + 'px', background: stage.color }"
@mouseenter="showTip(stage, $event)" @mousemove="moveTip" @mouseleave="hideTip">
<span class="stage-label">{{ stage.label }}</span>
<span class="stage-val">{{ stage.value.toLocaleString() }}</span>
</div>
<div v-if="getDrop(i)" class="drop-info">
<span class="drop-arrow">▼</span>
<span class="drop-pct">-{{ getDrop(i).pct }}%</span>
<span>dropped ({{ getDrop(i).drop.toLocaleString() }})</span>
</div>
</div>
</div>
<div class="summary">
<div v-for="s in STAGES" :key="s.label" class="summary-item">
<div class="summary-pct" :style="{ color: s.color }">{{ (s.value / STAGES[0].value * 100).toFixed(0) }}%</div>
<div class="summary-label">{{ s.label }}</div>
</div>
</div>
</div>
<div v-if="tooltip" class="tip" :style="{ left: tooltip.x+12+'px', top: tooltip.y-40+'px' }">
<div class="tip-label" :style="{ color: tooltip.stage.color }">{{ tooltip.stage.label }}</div>
<div class="tip-detail">{{ tooltip.stage.value.toLocaleString() }} users</div>
<div class="tip-detail">Overall: {{ (tooltip.stage.value / STAGES[0].value * 100).toFixed(1) }}%</div>
</div>
</div>
</template>
<style scoped>
.page { min-height: 100vh; background: #0d1117; padding: 1.5rem; display: flex; justify-content: center; }
.container { width: 100%; max-width: 640px; }
.funnel { display: flex; flex-direction: column; gap: 0; }
.stage-wrap {
display: flex; flex-direction: column; align-items: center;
animation: funnelIn 0.4s ease both;
animation-delay: var(--delay, 0s);
}
.stage-bar {
display: flex; align-items: center; justify-content: space-between;
padding: 0.75rem 1rem; border-radius: 0.5rem; cursor: pointer;
transition: opacity 0.15s; max-width: 100%;
}
.stage-bar:hover { opacity: 0.9; }
.stage-label { color: white; font-weight: 600; font-size: 13px; }
.stage-val { color: rgba(255,255,255,0.8); font-size: 13px; }
.drop-info { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 11px; color: #484f58; }
.drop-arrow { color: #f87171; }
.drop-pct { color: #f87171; font-weight: 600; }
.summary { display: flex; justify-content: center; gap: 1.5rem; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid #21262d; }
.summary-item { text-align: center; }
.summary-pct { font-size: 18px; font-weight: 700; }
.summary-label { font-size: 10px; color: #484f58; margin-top: 2px; }
.tip {
position: fixed; pointer-events: none; background: #161b22; border: 1px solid #30363d;
border-radius: 0.5rem; padding: 0.5rem 0.75rem; font-size: 12px;
box-shadow: 0 10px 15px -3px rgba(0,0,0,0.3); z-index: 50;
}
.tip-label { font-weight: 600; }
.tip-detail { color: #8b949e; }
@keyframes funnelIn {
from { opacity: 0; transform: scaleX(0.8); }
to { opacity: 1; transform: none; }
}
</style><script>
let tooltip = null;
const STAGES = [
{ label: "Visitors", value: 10000, color: "#818cf8" },
{ label: "Sign Ups", value: 4200, color: "#6366f1" },
{ label: "Onboarded", value: 2800, color: "#a78bfa" },
{ label: "Active Users", value: 1600, color: "#8b5cf6" },
{ label: "Paid Customers", value: 640, color: "#7c3aed" },
];
const maxW = 520;
const minW = 80;
function getWidth(stage) {
return minW + (maxW - minW) * (stage.value / STAGES[0].value);
}
function getDrop(i) {
if (i >= STAGES.length - 1) return null;
const drop = STAGES[i].value - STAGES[i + 1].value;
const pct = ((drop / STAGES[i].value) * 100).toFixed(1);
return { drop, pct };
}
function showTip(stage, e) {
tooltip = { stage, x: e.clientX, y: e.clientY };
}
function moveTip(e) {
if (tooltip) tooltip = { ...tooltip, x: e.clientX, y: e.clientY };
}
function hideTip() {
tooltip = null;
}
</script>
<style>
.page { min-height: 100vh; background: #0d1117; padding: 1.5rem; display: flex; justify-content: center; }
.container { width: 100%; max-width: 640px; }
.funnel { display: flex; flex-direction: column; gap: 0; }
.stage-wrap {
display: flex; flex-direction: column; align-items: center;
animation: funnelIn 0.4s ease both;
animation-delay: var(--delay, 0s);
}
.stage-bar {
display: flex; align-items: center; justify-content: space-between;
padding: 0.75rem 1rem; border-radius: 0.5rem; cursor: pointer;
transition: opacity 0.15s; max-width: 100%;
}
.stage-bar:hover { opacity: 0.9; }
.stage-label { color: white; font-weight: 600; font-size: 13px; }
.stage-val { color: rgba(255,255,255,0.8); font-size: 13px; }
.drop-info { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 11px; color: #484f58; }
.drop-arrow { color: #f87171; }
.drop-pct { color: #f87171; font-weight: 600; }
.summary { display: flex; justify-content: center; gap: 1.5rem; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid #21262d; }
.summary-item { text-align: center; }
.summary-pct { font-size: 18px; font-weight: 700; }
.summary-label { font-size: 10px; color: #484f58; margin-top: 2px; }
.tip {
position: fixed; pointer-events: none; background: #161b22; border: 1px solid #30363d;
border-radius: 0.5rem; padding: 0.5rem 0.75rem; font-size: 12px;
box-shadow: 0 10px 15px -3px rgba(0,0,0,0.3); z-index: 50;
}
.tip-label { font-weight: 600; }
.tip-detail { color: #8b949e; }
@keyframes funnelIn {
from { opacity: 0; transform: scaleX(0.8); }
to { opacity: 1; transform: none; }
}
</style>
<div class="page">
<div class="container">
<div class="funnel">
{#each STAGES as stage, i}
<div class="stage-wrap" style="--delay: {i * 0.1}s;">
<div class="stage-bar" style="width:{getWidth(stage)}px; background:{stage.color};"
on:mouseenter={e => showTip(stage, e)} on:mousemove={moveTip} on:mouseleave={hideTip}>
<span class="stage-label">{stage.label}</span>
<span class="stage-val">{stage.value.toLocaleString()}</span>
</div>
{#if getDrop(i)}
{@const d = getDrop(i)}
<div class="drop-info">
<span class="drop-arrow">▼</span>
<span class="drop-pct">-{d.pct}%</span>
<span>dropped ({d.drop.toLocaleString()})</span>
</div>
{/if}
</div>
{/each}
</div>
<div class="summary">
{#each STAGES as s}
<div class="summary-item">
<div class="summary-pct" style="color:{s.color}">{(s.value / STAGES[0].value * 100).toFixed(0)}%</div>
<div class="summary-label">{s.label}</div>
</div>
{/each}
</div>
</div>
{#if tooltip}
<div class="tip" style="left:{tooltip.x+12}px; top:{tooltip.y-40}px;">
<div class="tip-label" style="color:{tooltip.stage.color}">{tooltip.stage.label}</div>
<div class="tip-detail">{tooltip.stage.value.toLocaleString()} users</div>
<div class="tip-detail">Overall: {(tooltip.stage.value / STAGES[0].value * 100).toFixed(1)}%</div>
</div>
{/if}
</div>Features
- Trapezoid stages — each stage narrows proportionally to value
- Animated build — stages animate in sequentially on load
- Drop-off labels — shows % lost between each stage
- Hover detail — tooltip with stage name, absolute value, and conversion %
- Color gradient — stages tinted progressively for visual flow
How it works
- Each stage’s width is computed as
(value / maxValue) * containerWidth - SVG
<polygon>forms the trapezoid connecting stage top and bottom widths - Stage labels and drop-off callouts are absolutely positioned
<text>nodes - Sequential
animation-delaycreates the staggered entrance effect