Web3 — Token Balance Row (price · 24h · value)
A glassy Web3 wallet portfolio list where each token row pairs a gradient logo, monospace balance and unit price with a green or red 24h change, a sign-aware SVG sparkline and a right-aligned fiat value. A portfolio total header shows a weighted 24h delta, while controls let you sort by value or 24h change, toggle hide-small-balances, and watch every figure count up on load with hover-elevated rows. Mock data, fictional tokens, no on-chain calls.
MCP
代码
:root {
--bg: #0a0b0f;
--surface: #13151c;
--surface-2: #1b1e27;
--elevated: #23262f;
--text: #e9ecf2;
--muted: #8a90a2;
--line: rgba(255, 255, 255, 0.08);
--line-2: rgba(255, 255, 255, 0.16);
--accent: #7c5cff;
--accent-2: #00e0c6;
--accent-glow: rgba(124, 92, 255, 0.45);
--pos: #26d07c;
--neg: #ff4d6d;
--warn: #ffb347;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--r-pill: 999px;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100vh;
padding: 32px 16px 56px;
display: flex;
justify-content: center;
background:
radial-gradient(1100px 600px at 80% -10%, rgba(124, 92, 255, 0.16), transparent 60%),
radial-gradient(900px 520px at -10% 10%, rgba(0, 224, 198, 0.1), transparent 55%),
var(--bg);
color: var(--text);
font-family: "Space Grotesk", system-ui, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.mono {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-feature-settings: "tnum" 1;
}
.wallet {
width: 100%;
max-width: 460px;
display: flex;
flex-direction: column;
gap: 14px;
}
/* ---------- Portfolio header ---------- */
.port {
position: relative;
padding: 22px 22px 20px;
border-radius: var(--r-lg);
background:
linear-gradient(180deg, rgba(124, 92, 255, 0.12), rgba(0, 224, 198, 0.03)),
var(--surface);
border: 1px solid transparent;
background-clip: padding-box;
overflow: hidden;
}
.port[data-glow]::before {
content: "";
position: absolute;
inset: 0;
padding: 1px;
border-radius: inherit;
background: linear-gradient(135deg, var(--accent), var(--accent-2) 60%, transparent);
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
opacity: 0.7;
}
.port__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.port__id {
display: flex;
align-items: center;
gap: 11px;
}
.port__dot {
width: 34px;
height: 34px;
border-radius: var(--r-pill);
background: conic-gradient(from 140deg, var(--accent), var(--accent-2), var(--accent));
box-shadow: 0 0 0 4px rgba(124, 92, 255, 0.12), 0 0 18px var(--accent-glow);
flex: none;
}
.port__label {
margin: 0;
font-size: 12px;
color: var(--muted);
letter-spacing: 0.02em;
}
.port__addr {
margin: 2px 0 0;
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 13px;
color: var(--text);
}
.chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 11px;
font-size: 12px;
font-weight: 500;
color: var(--text);
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: var(--r-pill);
white-space: nowrap;
}
.chip__pip {
width: 7px;
height: 7px;
border-radius: var(--r-pill);
background: var(--accent-2);
box-shadow: 0 0 8px var(--accent-2);
}
.port__total {
margin: 18px 0 6px;
font-size: clamp(34px, 9vw, 44px);
font-weight: 700;
letter-spacing: -0.02em;
font-family: "JetBrains Mono", ui-monospace, monospace;
}
.port__delta {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 14px;
font-weight: 500;
}
.port__delta .arrow {
width: 0;
height: 0;
}
.port__delta.is-pos {
color: var(--pos);
}
.port__delta.is-neg {
color: var(--neg);
}
.port__delta .arrow {
border-left: 5px solid transparent;
border-right: 5px solid transparent;
}
.port__delta.is-pos .arrow {
border-bottom: 7px solid currentColor;
}
.port__delta.is-neg .arrow {
border-top: 7px solid currentColor;
}
.port__deltaVal {
font-family: "JetBrains Mono", ui-monospace, monospace;
}
.port__deltaLabel {
color: var(--muted);
font-weight: 400;
}
/* ---------- Controls ---------- */
.bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 2px;
}
.sort {
display: inline-flex;
padding: 4px;
gap: 2px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-pill);
}
.sort__btn {
appearance: none;
border: 0;
cursor: pointer;
padding: 7px 14px;
font: inherit;
font-size: 13px;
font-weight: 500;
color: var(--muted);
background: transparent;
border-radius: var(--r-pill);
transition: color 0.18s ease, background 0.18s ease;
}
.sort__btn:hover {
color: var(--text);
}
.sort__btn.is-active {
color: #0a0b0f;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
box-shadow: 0 4px 16px var(--accent-glow);
}
.switch {
display: inline-flex;
align-items: center;
gap: 9px;
cursor: pointer;
font-size: 13px;
color: var(--muted);
user-select: none;
}
.switch input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
}
.switch__track {
width: 38px;
height: 22px;
border-radius: var(--r-pill);
background: var(--surface-2);
border: 1px solid var(--line-2);
position: relative;
transition: background 0.2s ease, border-color 0.2s ease;
}
.switch__thumb {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: var(--r-pill);
background: var(--muted);
transition: transform 0.2s ease, background 0.2s ease;
}
.switch input:checked + .switch__track {
background: linear-gradient(135deg, var(--accent), var(--accent-2));
border-color: transparent;
}
.switch input:checked + .switch__track .switch__thumb {
transform: translateX(16px);
background: #0a0b0f;
}
.switch input:checked ~ .switch__text {
color: var(--text);
}
.switch input:focus-visible + .switch__track {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* ---------- Rows ---------- */
.rows {
list-style: none;
margin: 0;
padding: 6px;
display: flex;
flex-direction: column;
gap: 4px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
}
.row {
display: grid;
grid-template-columns: 40px 1fr auto;
align-items: center;
gap: 13px;
padding: 13px 12px;
border-radius: var(--r-md);
border: 1px solid transparent;
background: transparent;
transition: transform 0.18s ease, background 0.18s ease, border-color 0.18s ease,
box-shadow 0.18s ease, opacity 0.3s ease;
animation: rowIn 0.5s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.row:hover {
transform: translateY(-2px);
background: var(--surface-2);
border-color: var(--line-2);
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.4);
}
.row.is-hidden {
display: none;
}
@keyframes rowIn {
from {
opacity: 0;
transform: translateY(8px);
}
}
.logo {
width: 40px;
height: 40px;
border-radius: var(--r-pill);
display: grid;
place-items: center;
font-family: "JetBrains Mono", ui-monospace, monospace;
font-weight: 700;
font-size: 13px;
color: #0a0b0f;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18), 0 4px 14px rgba(0, 0, 0, 0.35);
flex: none;
}
.meta {
min-width: 0;
}
.meta__name {
margin: 0;
font-size: 14px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.meta__sub {
margin: 2px 0 0;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--muted);
}
.meta__bal {
font-family: "JetBrains Mono", ui-monospace, monospace;
}
.meta__sym {
color: var(--muted);
}
.fig {
text-align: right;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 5px;
}
.fig__val {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 14px;
font-weight: 600;
}
.fig__row {
display: flex;
align-items: center;
gap: 8px;
}
/* sparkline */
.spark {
width: 56px;
height: 18px;
display: block;
overflow: visible;
}
.spark path {
fill: none;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
.spark .fill {
stroke: none;
opacity: 0.16;
}
.change {
display: inline-flex;
align-items: center;
gap: 4px;
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 12px;
font-weight: 500;
min-width: 58px;
justify-content: flex-end;
}
.change .arr {
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
}
.change.is-pos {
color: var(--pos);
}
.change.is-neg {
color: var(--neg);
}
.change.is-pos .arr {
border-bottom: 6px solid currentColor;
}
.change.is-neg .arr {
border-top: 6px solid currentColor;
}
.change__price {
color: var(--muted);
font-size: 11px;
}
.foot {
margin: 4px 4px 0;
font-size: 11px;
color: var(--muted);
text-align: center;
}
/* focus */
button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* toast */
.toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translate(-50%, 16px);
padding: 11px 16px;
border-radius: var(--r-pill);
background: var(--elevated);
border: 1px solid var(--line-2);
color: var(--text);
font-size: 13px;
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.5);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease, transform 0.25s ease;
z-index: 30;
}
.toast.is-on {
opacity: 1;
transform: translate(-50%, 0);
}
/* ---------- Responsive ---------- */
@media (max-width: 520px) {
body {
padding: 18px 12px 48px;
}
.port__total {
font-size: 34px;
}
.row {
grid-template-columns: 36px 1fr auto;
gap: 10px;
padding: 12px 9px;
}
.logo {
width: 36px;
height: 36px;
font-size: 12px;
}
.spark {
display: none;
}
.meta__sub {
flex-wrap: wrap;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}(() => {
"use strict";
/* ---------------- Mock data (fictional) ---------------- */
// change24 = 24h % change. spark = relative trend points (drawn directly).
const TOKENS = [
{
sym: "ETH",
name: "Ethereum",
balance: 2.4187,
price: 3284.12,
change24: 1.84,
g: ["#7c5cff", "#00e0c6"],
spark: [5, 4, 6, 5, 7, 6, 8, 7, 9, 10],
},
{
sym: "USDC",
name: "USD Coin",
balance: 4120.5,
price: 1.0,
change24: 0.01,
g: ["#2775ca", "#5aa9ff"],
spark: [6, 6, 5, 6, 6, 5, 6, 6, 6, 6],
},
{
sym: "NOVA",
name: "Nova Protocol",
balance: 18450.0,
price: 0.4127,
change24: 12.63,
g: ["#ff7eb3", "#7c5cff"],
spark: [3, 4, 3, 5, 5, 6, 7, 8, 9, 11],
},
{
sym: "ARB",
name: "Arbiter",
balance: 1320.77,
price: 1.182,
change24: -3.41,
g: ["#28a0f0", "#1b3a57"],
spark: [9, 8, 9, 7, 8, 6, 7, 5, 6, 5],
},
{
sym: "LUMEN",
name: "Lumen Chain",
balance: 905.2,
price: 2.738,
change24: -8.07,
g: ["#ffb347", "#ff4d6d"],
spark: [10, 9, 8, 9, 7, 6, 7, 5, 4, 3],
},
{
sym: "DRIP",
name: "Driplet",
balance: 64.0,
price: 0.0091,
change24: 0.42,
g: ["#00e0c6", "#26d07c"],
spark: [5, 6, 5, 6, 5, 6, 6, 5, 6, 6],
},
];
const SMALL_THRESHOLD = 5; // hide rows under $5 fiat value
const $ = (s, r = document) => r.querySelector(s);
const rowsEl = $("#rows");
const portTotalEl = $("#portTotal");
const portDeltaEl = $("#portDelta");
let sortMode = "value";
let hideSmall = false;
/* ---------------- Helpers ---------------- */
const fiat = (n) =>
"$" +
n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const price = (n) =>
"$" +
n.toLocaleString("en-US", {
minimumFractionDigits: n < 1 ? 4 : 2,
maximumFractionDigits: n < 1 ? 4 : 2,
});
const amount = (n) =>
n.toLocaleString("en-US", {
minimumFractionDigits: 0,
maximumFractionDigits: n < 1 ? 4 : 2,
});
const pct = (n) => (n >= 0 ? "+" : "") + n.toFixed(2) + "%";
TOKENS.forEach((t) => (t.value = t.balance * t.price));
function toast(msg) {
const el = $("#toast");
el.textContent = msg;
el.classList.add("is-on");
clearTimeout(toast._t);
toast._t = setTimeout(() => el.classList.remove("is-on"), 2200);
}
/* ---------------- Sparkline (SVG path from points) ---------------- */
function sparkSVG(points, positive) {
const W = 56,
H = 18,
P = 1.5;
const min = Math.min(...points),
max = Math.max(...points);
const span = max - min || 1;
const step = (W - P * 2) / (points.length - 1);
const xy = points.map((v, i) => {
const x = P + i * step;
const y = P + (H - P * 2) * (1 - (v - min) / span);
return [x, y];
});
const line = xy.map(([x, y], i) => (i ? "L" : "M") + x.toFixed(1) + " " + y.toFixed(1)).join(" ");
const area = line + ` L${(W - P).toFixed(1)} ${H} L${P} ${H} Z`;
const color = positive ? "var(--pos)" : "var(--neg)";
return `
<svg class="spark" viewBox="0 0 ${W} ${H}" aria-hidden="true">
<path class="fill" d="${area}" style="fill:${color}"/>
<path d="${line}" style="stroke:${color}"/>
</svg>`;
}
/* ---------------- Render ---------------- */
function render() {
const sorted = [...TOKENS].sort((a, b) =>
sortMode === "value" ? b.value - a.value : b.change24 - a.change24
);
rowsEl.innerHTML = "";
let visibleCount = 0;
sorted.forEach((t, i) => {
const positive = t.change24 >= 0;
const small = t.value < SMALL_THRESHOLD;
const hidden = hideSmall && small;
if (!hidden) visibleCount++;
const li = document.createElement("li");
li.className = "row" + (hidden ? " is-hidden" : "");
li.style.animationDelay = i * 45 + "ms";
li.innerHTML = `
<span class="logo" style="background:linear-gradient(135deg, ${t.g[0]}, ${t.g[1]})">
${t.sym.slice(0, 3)}
</span>
<div class="meta">
<p class="meta__name">${t.name}</p>
<p class="meta__sub">
<span class="meta__bal mono">${amount(t.balance)}</span>
<span class="meta__sym">${t.sym}</span>
<span class="change__price mono">@ ${price(t.price)}</span>
</p>
</div>
<div class="fig">
<span class="fig__val mono" data-val="${t.value}">$0.00</span>
<div class="fig__row">
${sparkSVG(t.spark, positive)}
<span class="change ${positive ? "is-pos" : "is-neg"}">
<span class="arr" aria-hidden="true"></span>${pct(t.change24)}
</span>
</div>
</div>`;
rowsEl.appendChild(li);
});
countUp();
renderTotal();
if (hideSmall) {
const hiddenN = TOKENS.filter((t) => t.value < SMALL_THRESHOLD).length;
if (hiddenN) toast(`Hiding ${hiddenN} small balance${hiddenN > 1 ? "s" : ""}`);
}
return visibleCount;
}
/* ---------------- Portfolio total + weighted 24h ---------------- */
function renderTotal() {
const shown = hideSmall
? TOKENS.filter((t) => t.value >= SMALL_THRESHOLD)
: TOKENS;
const total = shown.reduce((s, t) => s + t.value, 0);
const prev = shown.reduce((s, t) => s + t.value / (1 + t.change24 / 100), 0);
const deltaPct = prev ? ((total - prev) / prev) * 100 : 0;
const deltaAbs = total - prev;
animateNumber(portTotalEl, total, (v) => fiat(v));
const positive = deltaPct >= 0;
portDeltaEl.className = "port__delta " + (positive ? "is-pos" : "is-neg");
portDeltaEl.querySelector(".port__deltaVal").textContent =
pct(deltaPct) + " " + (positive ? "+" : "") + fiat(deltaAbs).replace("$", "$");
}
/* ---------------- Animated count-up ---------------- */
function animateNumber(el, target, fmt, dur = 900) {
const start = performance.now();
const from = parseFloat(el.dataset.cur || "0");
function tick(now) {
const p = Math.min((now - start) / dur, 1);
const eased = 1 - Math.pow(1 - p, 3);
const v = from + (target - from) * eased;
el.textContent = fmt(v);
if (p < 1) requestAnimationFrame(tick);
else el.dataset.cur = target;
}
requestAnimationFrame(tick);
}
function countUp() {
rowsEl.querySelectorAll(".fig__val").forEach((el) => {
el.dataset.cur = "0";
animateNumber(el, parseFloat(el.dataset.val), (v) => fiat(v), 850);
});
}
/* ---------------- Events ---------------- */
document.querySelectorAll(".sort__btn").forEach((btn) => {
btn.addEventListener("click", () => {
const mode = btn.dataset.sort;
if (mode === sortMode) return;
sortMode = mode;
document.querySelectorAll(".sort__btn").forEach((b) => {
const active = b === btn;
b.classList.toggle("is-active", active);
b.setAttribute("aria-pressed", String(active));
});
render();
toast(mode === "value" ? "Sorted by portfolio value" : "Sorted by 24h change");
});
});
$("#hideSmall").addEventListener("change", (e) => {
hideSmall = e.target.checked;
render();
if (!hideSmall) toast("Showing all balances");
});
/* ---------------- Init ---------------- */
portTotalEl.dataset.cur = "0";
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Web3 — Token Balance Row</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="wallet" aria-label="Token portfolio">
<!-- Portfolio total header -->
<header class="port" data-glow>
<div class="port__top">
<div class="port__id">
<span class="port__dot" aria-hidden="true"></span>
<div>
<p class="port__label">Total balance</p>
<p class="port__addr" title="Connected wallet (mock)">0x7a3f…c41d</p>
</div>
</div>
<span class="chip" title="Fictional network">
<span class="chip__pip" aria-hidden="true"></span> Lumen Chain
</span>
</div>
<p class="port__total" id="portTotal" aria-live="polite">$0.00</p>
<div class="port__delta" id="portDelta">
<span class="arrow" aria-hidden="true"></span>
<span class="port__deltaVal">—</span>
<span class="port__deltaLabel">24h</span>
</div>
</header>
<!-- Controls -->
<section class="bar" aria-label="List controls">
<div class="sort" role="group" aria-label="Sort tokens by">
<button class="sort__btn is-active" data-sort="value" aria-pressed="true">
By value
</button>
<button class="sort__btn" data-sort="change" aria-pressed="false">
By 24h
</button>
</div>
<label class="switch" for="hideSmall">
<input type="checkbox" id="hideSmall" />
<span class="switch__track" aria-hidden="true"><span class="switch__thumb"></span></span>
<span class="switch__text">Hide small balances</span>
</label>
</section>
<!-- Token rows -->
<ul class="rows" id="rows" aria-label="Tokens"></ul>
<p class="foot">
UI-only simulation · mock data, fictional tokens. Prices do not update.
</p>
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Token Balance Row (price · 24h · value)
A self-contained wallet portfolio component built for a Crypto/Web3 design system. A gradient-bordered header shows the total balance, a truncated monospace address (0x7a3f…c41d), the connected network chip, and a weighted 24h delta in green or red. Below it, a glassy card lists six fictional tokens — ETH, USDC, NOVA, ARB, LUMEN and DRIP — each as a row with a gradient logo circle, name and symbol, a monospace balance and unit price, a 24h change with an up/down arrow, a sign-aware SVG sparkline, and a right-aligned fiat value.
Interactions are pure vanilla JS. A segmented toggle re-sorts the list by portfolio value or by 24h change; a switch hides small balances (under a fiat threshold) and recomputes the total; and every fiat figure animates with a cubic count-up on load and on each re-render. Rows lift on hover, the sparkline fill tracks the change sign, and a small toast() helper confirms each action.
All numbers, addresses, hashes and amounts use JetBrains Mono with tabular figures, while Space Grotesk carries the UI. The layout stays usable down to ~360px (sparklines collapse, the total scales) and respects reduced-motion preferences. There is no wallet connection, RPC, ethers/web3 library, or price feed — the data is hardcoded and static.
UI-only simulation — no real wallet, RPC, or on-chain calls. Mock data, fictional tokens.