Game — Character / Hero Roster (select grid)
A neon-dark hero roster page for a fictional shooter, Hollow Reign by Nullforge. Browse eleven heroes as rarity-ringed portrait cards, filter by Tank, DPS, or Support role chips, search by name, and toggle a rarity sort. Selecting a hero drives a featured panel with splash art, lore, difficulty pips, and animated stat bars, plus an ability strip with hover tooltips. Pure HTML, CSS, and vanilla JS with no build step or dependencies.
MCP
Code
:root {
--bg: #0a0b10;
--bg-2: #12131c;
--panel: #171926;
--panel-2: #1f2233;
--text: #e7e9f3;
--muted: #9aa0bf;
--line: rgba(231, 233, 243, 0.1);
--line-2: rgba(231, 233, 243, 0.18);
--accent: #00e5ff;
--accent-2: #7c4dff;
--accent-3: #ff3d71;
--success: #36e27a;
--warn: #ffc857;
--danger: #ff4d4d;
--glow: 0 0 18px rgba(0, 229, 255, 0.45);
--r-sm: 6px;
--r-md: 10px;
--r-lg: 16px;
--rarity-legendary: #ffc857;
--rarity-epic: #7c4dff;
--rarity-rare: #00e5ff;
--font-display: "Orbitron", sans-serif;
--font-body: "Inter", sans-serif;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--font-body);
background:
radial-gradient(900px 480px at 80% -10%, rgba(124, 77, 255, 0.16), transparent 60%),
radial-gradient(720px 420px at 8% 4%, rgba(0, 229, 255, 0.1), transparent 55%),
var(--bg);
color: var(--text);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
.page {
max-width: 1240px;
margin: 0 auto;
padding: 20px 24px 56px;
}
/* ---------- Topbar ---------- */
.topbar {
display: flex;
align-items: center;
gap: 24px;
padding: 12px 18px;
background: linear-gradient(180deg, var(--panel) 0%, rgba(23, 25, 38, 0.6) 100%);
border: 1px solid var(--line);
border-radius: var(--r-md);
position: relative;
}
.topbar::after {
content: "";
position: absolute;
inset: auto 18px -1px;
height: 1px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: 0.5;
}
.brand {
display: flex;
align-items: baseline;
gap: 10px;
flex: 1;
min-width: 0;
}
.brand-mark {
color: var(--accent);
text-shadow: var(--glow);
font-size: 18px;
}
.brand-name {
font-family: var(--font-display);
font-weight: 900;
letter-spacing: 0.12em;
font-size: 17px;
white-space: nowrap;
}
.brand-name em {
font-style: normal;
color: var(--accent);
}
.brand-tag {
font-size: 11px;
color: var(--muted);
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.topnav {
display: flex;
gap: 4px;
}
.topnav-link {
font-size: 13px;
font-weight: 600;
color: var(--muted);
text-decoration: none;
padding: 8px 14px;
border-radius: var(--r-sm);
transition: color 0.18s, background 0.18s;
}
.topnav-link:hover {
color: var(--text);
background: rgba(231, 233, 243, 0.06);
}
.topnav-link.is-active {
color: var(--accent);
background: rgba(0, 229, 255, 0.08);
box-shadow: inset 0 -2px 0 var(--accent);
}
/* ---------- Buttons ---------- */
.btn {
font-family: var(--font-display);
font-weight: 700;
font-size: 13px;
letter-spacing: 0.08em;
text-transform: uppercase;
border: 1px solid var(--line-2);
color: var(--text);
background: var(--panel-2);
padding: 11px 20px;
cursor: pointer;
clip-path: polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px);
transition: transform 0.15s, box-shadow 0.2s, background 0.2s, border-color 0.2s;
}
.btn:hover {
transform: translateY(-1px);
}
.btn:active {
transform: translateY(0);
}
.btn-cta {
background: linear-gradient(135deg, var(--accent) 0%, #00b3d6 100%);
color: #04141a;
border-color: transparent;
box-shadow: var(--glow);
}
.btn-cta:hover {
box-shadow: 0 0 26px rgba(0, 229, 255, 0.65);
}
.btn-ghost {
background: transparent;
}
.btn-ghost:hover {
border-color: var(--accent);
box-shadow: var(--glow);
}
.btn-ghost[aria-pressed="true"] {
border-color: var(--accent);
color: var(--accent);
background: rgba(0, 229, 255, 0.08);
box-shadow: var(--glow);
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 2px;
}
/* ---------- Page head ---------- */
.hero-head {
padding: 36px 4px 8px;
}
.page-title {
font-family: var(--font-display);
font-weight: 900;
font-size: clamp(30px, 5vw, 46px);
letter-spacing: 0.06em;
margin: 0 0 6px;
text-transform: uppercase;
background: linear-gradient(90deg, var(--text) 30%, var(--accent) 85%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.page-sub {
margin: 0;
color: var(--muted);
font-size: 15px;
max-width: 56ch;
}
.page-sub strong {
color: var(--accent-2);
}
/* ---------- Layout ---------- */
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 380px;
gap: 22px;
margin-top: 22px;
align-items: start;
}
/* ---------- Filters ---------- */
.filters {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.chips {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.chip {
font-family: var(--font-display);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
background: var(--panel);
border: 1px solid var(--line);
padding: 8px 16px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
clip-path: polygon(8px 0, 100% 0, 100% calc(100% - 8px), calc(100% - 8px) 100%, 0 100%, 0 8px);
transition: color 0.18s, border-color 0.18s, box-shadow 0.2s, background 0.2s;
}
.chip:hover {
color: var(--text);
border-color: var(--line-2);
}
.chip.is-active {
color: var(--accent);
border-color: var(--accent);
background: rgba(0, 229, 255, 0.08);
box-shadow: var(--glow);
}
.chip-tank.is-active {
color: var(--warn);
border-color: var(--warn);
background: rgba(255, 200, 87, 0.08);
box-shadow: 0 0 18px rgba(255, 200, 87, 0.35);
}
.chip-dps.is-active {
color: var(--accent-3);
border-color: var(--accent-3);
background: rgba(255, 61, 113, 0.08);
box-shadow: 0 0 18px rgba(255, 61, 113, 0.4);
}
.chip-support.is-active {
color: var(--success);
border-color: var(--success);
background: rgba(54, 226, 122, 0.08);
box-shadow: 0 0 18px rgba(54, 226, 122, 0.35);
}
.chip-ico {
font-size: 13px;
}
.filter-tools {
display: flex;
gap: 10px;
align-items: center;
}
.search {
display: flex;
align-items: center;
gap: 8px;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 0 12px;
transition: border-color 0.18s, box-shadow 0.2s;
}
.search:focus-within {
border-color: var(--accent);
box-shadow: var(--glow);
}
.search-ico {
color: var(--muted);
font-size: 16px;
}
.search input {
background: transparent;
border: 0;
color: var(--text);
font-family: var(--font-body);
font-size: 14px;
padding: 10px 0;
width: 170px;
}
.search input::placeholder {
color: var(--muted);
}
.search input:focus {
outline: none;
}
.result-count {
margin: 0 0 12px;
font-size: 12px;
color: var(--muted);
letter-spacing: 0.06em;
text-transform: uppercase;
}
/* ---------- Grid + cards ---------- */
.grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 14px;
}
.card {
background: linear-gradient(180deg, var(--panel) 0%, var(--bg-2) 100%);
border: 1px solid var(--line);
clip-path: polygon(12px 0, 100% 0, 100% calc(100% - 12px), calc(100% - 12px) 100%, 0 100%, 0 12px);
padding: 14px 12px 12px;
cursor: pointer;
text-align: center;
position: relative;
transition: transform 0.18s, border-color 0.2s, box-shadow 0.25s, background 0.2s;
animation: cardIn 0.35s ease both;
}
@keyframes cardIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card:hover {
transform: translateY(-4px);
border-color: var(--line-2);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.5);
}
.card[aria-selected="true"] {
border-color: var(--accent);
box-shadow: var(--glow), inset 0 0 24px rgba(0, 229, 255, 0.07);
}
.card[aria-selected="true"]::after {
content: "";
position: absolute;
inset: 0 0 auto;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
}
.portrait {
width: 86px;
height: 86px;
margin: 0 auto 10px;
border-radius: 50%;
display: grid;
place-items: center;
font-family: var(--font-display);
font-weight: 900;
font-size: 30px;
color: rgba(255, 255, 255, 0.92);
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.55);
background: linear-gradient(140deg, var(--p1, #233), var(--p2, #112));
position: relative;
box-shadow: inset 0 -14px 22px rgba(0, 0, 0, 0.4);
}
/* rarity ring */
.portrait::before {
content: "";
position: absolute;
inset: -5px;
border-radius: 50%;
border: 2px solid var(--ring, var(--line-2));
box-shadow: 0 0 12px var(--ring-glow, transparent);
transition: box-shadow 0.25s;
}
.card:hover .portrait::before,
.card[aria-selected="true"] .portrait::before {
box-shadow: 0 0 18px var(--ring-glow, rgba(0, 229, 255, 0.4));
}
.rarity-legendary {
--ring: var(--rarity-legendary);
--ring-glow: rgba(255, 200, 87, 0.45);
}
.rarity-epic {
--ring: var(--rarity-epic);
--ring-glow: rgba(124, 77, 255, 0.5);
}
.rarity-rare {
--ring: var(--rarity-rare);
--ring-glow: rgba(0, 229, 255, 0.4);
}
.card-name {
font-family: var(--font-display);
font-weight: 700;
font-size: 13px;
letter-spacing: 0.05em;
margin: 0 0 4px;
text-transform: uppercase;
}
.card-role {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.card-role .role-ico {
font-size: 12px;
}
.role-tank .role-ico {
color: var(--warn);
}
.role-dps .role-ico {
color: var(--accent-3);
}
.role-support .role-ico {
color: var(--success);
}
.card-rarity {
position: absolute;
top: 8px;
right: 10px;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ring, var(--muted));
}
/* ---------- Empty state ---------- */
.empty {
text-align: center;
padding: 48px 16px;
border: 1px dashed var(--line-2);
border-radius: var(--r-lg);
margin-top: 8px;
}
.empty-title {
font-family: var(--font-display);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
margin: 0 0 6px;
}
.empty-sub {
color: var(--muted);
font-size: 14px;
margin: 0 0 18px;
}
/* ---------- Featured panel ---------- */
.featured {
background: linear-gradient(180deg, var(--panel) 0%, var(--bg-2) 100%);
border: 1px solid var(--line-2);
clip-path: polygon(18px 0, 100% 0, 100% calc(100% - 18px), calc(100% - 18px) 100%, 0 100%, 0 18px);
position: sticky;
top: 20px;
overflow: hidden;
}
.splash {
height: 190px;
position: relative;
display: grid;
place-items: center;
background: linear-gradient(150deg, var(--p1, #1c2433), var(--p2, #0b0f18));
border-bottom: 1px solid var(--line-2);
overflow: hidden;
transition: background 0.4s;
}
.splash::before {
content: "";
position: absolute;
inset: 0;
background:
radial-gradient(420px 200px at 50% 110%, rgba(0, 0, 0, 0.55), transparent 70%),
repeating-linear-gradient(0deg, transparent 0 3px, rgba(255, 255, 255, 0.02) 3px 4px);
}
.splash-letter {
font-family: var(--font-display);
font-weight: 900;
font-size: 120px;
line-height: 1;
color: rgba(255, 255, 255, 0.14);
letter-spacing: -0.04em;
user-select: none;
}
.splash-glyph {
position: absolute;
bottom: 12px;
right: 16px;
font-size: 30px;
color: rgba(255, 255, 255, 0.85);
text-shadow: 0 0 14px rgba(0, 229, 255, 0.6);
}
.splash-scan {
position: absolute;
inset: 0;
background: linear-gradient(180deg, transparent 0%, rgba(0, 229, 255, 0.08) 48%, rgba(0, 229, 255, 0.18) 50%, rgba(0, 229, 255, 0.08) 52%, transparent 100%);
background-size: 100% 240px;
background-repeat: no-repeat;
animation: scan 4.5s linear infinite;
pointer-events: none;
}
@keyframes scan {
from {
background-position: 0 -240px;
}
to {
background-position: 0 430px;
}
}
.featured-body {
padding: 18px 18px 20px;
}
.featured-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.featured-role {
margin: 0;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
}
.featured-name {
font-family: var(--font-display);
font-weight: 900;
font-size: 26px;
letter-spacing: 0.05em;
margin: 2px 0 0;
text-transform: uppercase;
}
.featured-epithet {
margin: 2px 0 0;
font-size: 13px;
color: var(--muted);
font-style: italic;
}
.rarity-badge {
font-family: var(--font-display);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
padding: 6px 10px;
border: 1px solid var(--ring, var(--line-2));
color: var(--ring, var(--muted));
box-shadow: 0 0 12px var(--ring-glow, transparent);
clip-path: polygon(6px 0, 100% 0, 100% calc(100% - 6px), calc(100% - 6px) 100%, 0 100%, 0 6px);
white-space: nowrap;
}
.featured-lore {
font-size: 13.5px;
color: var(--muted);
margin: 14px 0;
min-height: 60px;
}
.featured-meta {
display: flex;
gap: 24px;
padding: 12px 0;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.meta-label {
display: block;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 5px;
}
.meta-value {
font-size: 13px;
font-weight: 600;
}
.diff-pips {
display: inline-flex;
gap: 5px;
}
.pip {
width: 16px;
height: 7px;
background: var(--panel-2);
border: 1px solid var(--line-2);
transform: skewX(-18deg);
display: inline-block;
}
.pip.on {
background: var(--accent);
border-color: var(--accent);
box-shadow: 0 0 8px rgba(0, 229, 255, 0.55);
}
/* ---------- Stats ---------- */
.stats {
display: grid;
gap: 10px;
margin: 16px 0 18px;
}
.stat {
display: grid;
grid-template-columns: 70px 1fr 32px;
align-items: center;
gap: 10px;
}
.stat-name {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
}
.stat-track {
height: 8px;
background: var(--bg-2);
border: 1px solid var(--line);
overflow: hidden;
transform: skewX(-18deg);
display: block;
}
.stat-fill {
display: block;
height: 100%;
width: 0%;
transition: width 0.7s cubic-bezier(0.22, 1, 0.36, 1);
}
.stat[data-stat="power"] .stat-fill {
background: linear-gradient(90deg, var(--accent-3), #ff7a9e);
box-shadow: 0 0 10px rgba(255, 61, 113, 0.5);
}
.stat[data-stat="defense"] .stat-fill {
background: linear-gradient(90deg, var(--warn), #ffe09a);
box-shadow: 0 0 10px rgba(255, 200, 87, 0.45);
}
.stat[data-stat="mobility"] .stat-fill {
background: linear-gradient(90deg, var(--accent), #7df3ff);
box-shadow: 0 0 10px rgba(0, 229, 255, 0.5);
}
.stat[data-stat="utility"] .stat-fill {
background: linear-gradient(90deg, var(--accent-2), #ab8bff);
box-shadow: 0 0 10px rgba(124, 77, 255, 0.5);
}
.stat-num {
font-family: var(--font-display);
font-weight: 700;
font-size: 13px;
text-align: right;
color: var(--text);
}
.featured-actions {
display: flex;
gap: 10px;
}
.featured-actions .btn-cta {
flex: 1;
}
/* ---------- Abilities ---------- */
.abilities {
padding: 16px 18px 20px;
border-top: 1px solid var(--line-2);
background: rgba(10, 11, 16, 0.45);
}
.abilities-title {
margin: 0 0 12px;
font-family: var(--font-display);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
}
.ability-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.ability {
position: relative;
width: 58px;
height: 58px;
background: linear-gradient(160deg, var(--panel-2), var(--bg-2));
border: 1px solid var(--line-2);
clip-path: polygon(9px 0, 100% 0, 100% calc(100% - 9px), calc(100% - 9px) 100%, 0 100%, 0 9px);
cursor: pointer;
display: grid;
place-items: center;
color: var(--text);
font-size: 20px;
transition: border-color 0.2s, box-shadow 0.25s, transform 0.15s;
}
.ability:hover,
.ability:focus-visible {
border-color: var(--accent);
box-shadow: var(--glow);
transform: translateY(-2px);
}
.ability.is-ult {
border-color: var(--accent-2);
}
.ability.is-ult:hover,
.ability.is-ult:focus-visible {
border-color: var(--accent-2);
box-shadow: 0 0 18px rgba(124, 77, 255, 0.55);
}
.ability-key {
position: absolute;
bottom: 2px;
right: 5px;
font-family: var(--font-display);
font-size: 9px;
font-weight: 700;
color: var(--accent);
}
.ability.is-ult .ability-key {
color: var(--accent-2);
}
/* tooltip */
.ability-tip {
position: absolute;
bottom: calc(100% + 10px);
left: 50%;
transform: translateX(-50%) translateY(4px);
width: 200px;
background: var(--bg-2);
border: 1px solid var(--accent);
box-shadow: var(--glow), 0 12px 30px rgba(0, 0, 0, 0.6);
border-radius: var(--r-sm);
padding: 10px 12px;
font-size: 12px;
text-align: left;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.18s, transform 0.18s, visibility 0.18s;
z-index: 10;
}
.ability:hover .ability-tip,
.ability:focus-visible .ability-tip {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
}
.ability-tip::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: var(--accent);
}
.tip-name {
display: block;
font-family: var(--font-display);
font-weight: 700;
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 4px;
}
.tip-desc {
display: block;
color: var(--muted);
line-height: 1.45;
}
.tip-cd {
display: block;
margin-top: 6px;
font-size: 11px;
font-weight: 700;
color: var(--warn);
}
/* leftmost tooltips shouldn't clip */
.ability:first-child .ability-tip {
left: 0;
transform: translateX(0) translateY(4px);
}
.ability:first-child:hover .ability-tip,
.ability:first-child:focus-visible .ability-tip {
transform: translateX(0) translateY(0);
}
.ability:first-child .ability-tip::after {
left: 24px;
}
/* ---------- Footer ---------- */
.foot {
margin-top: 40px;
padding-top: 18px;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 12px;
text-align: center;
}
.foot p {
margin: 0;
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translateX(-50%) translateY(16px);
background: var(--panel-2);
border: 1px solid var(--accent);
box-shadow: var(--glow), 0 12px 32px rgba(0, 0, 0, 0.6);
color: var(--text);
font-size: 13px;
font-weight: 600;
padding: 12px 20px;
clip-path: polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px);
opacity: 0;
visibility: hidden;
transition: opacity 0.25s, transform 0.25s, visibility 0.25s;
z-index: 100;
max-width: calc(100vw - 40px);
}
.toast.show {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
}
/* ---------- Responsive ---------- */
@media (max-width: 980px) {
.layout {
grid-template-columns: 1fr;
}
.featured {
position: static;
}
.topnav {
display: none;
}
}
@media (max-width: 520px) {
.page {
padding: 12px 14px 40px;
}
.topbar {
gap: 12px;
padding: 10px 12px;
}
.brand-tag {
display: none;
}
.brand-name {
font-size: 14px;
}
.btn {
padding: 10px 14px;
font-size: 12px;
}
.hero-head {
padding-top: 24px;
}
.filters {
flex-direction: column;
align-items: stretch;
}
.filter-tools {
width: 100%;
}
.search {
flex: 1;
}
.search input {
width: 100%;
}
.grid {
grid-template-columns: repeat(auto-fill, minmax(128px, 1fr));
gap: 10px;
}
.portrait {
width: 70px;
height: 70px;
font-size: 24px;
}
.splash {
height: 150px;
}
.splash-letter {
font-size: 90px;
}
.featured-actions {
flex-direction: column;
}
.stat {
grid-template-columns: 62px 1fr 28px;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}/* Hollow Reign — Hero Roster (demo data + interactions, fictional content) */
(() => {
"use strict";
const ROLE_META = {
tank: { label: "Tank", glyph: "⛨" },
dps: { label: "DPS", glyph: "⚔" },
support: { label: "Support", glyph: "✚" },
};
const RARITY_ORDER = { legendary: 0, epic: 1, rare: 2 };
const HEROES = [
{
id: "kargath",
name: "Kargath",
epithet: "The Iron Bulwark",
role: "tank",
rarity: "legendary",
difficulty: 1,
origin: "Bastion of Vhal",
colors: ["#5a4a1e", "#171307"],
lore:
"Forged in the siege-foundries of Vhal, Kargath traded his heartbeat for a reactor core. He holds the line not because he must — but because nothing behind him deserves to burn.",
stats: { power: 58, defense: 95, mobility: 34, utility: 62 },
abilities: [
{ key: "Q", icon: "🛡", name: "Aegis Wall", cd: "8s", desc: "Project a kinetic barrier that absorbs 420 damage for allies behind it." },
{ key: "E", icon: "⚓", name: "Gravlock", cd: "12s", desc: "Anchor in place, gaining 40% damage reduction and taunting nearby enemies." },
{ key: "⇧", icon: "💢", name: "Shockstep", cd: "10s", desc: "Lunge forward, knocking back enemies and slowing them by 30%." },
{ key: "R", icon: "🌋", name: "Coreburst", cd: "90s", ult: true, desc: "Vent the reactor: massive AoE knock-up and 6s of fortified armor." },
],
},
{
id: "nyra",
name: "Nyra Sol",
epithet: "Dawn's Last Arrow",
role: "dps",
rarity: "epic",
difficulty: 2,
origin: "Sunspire Reach",
colors: ["#7a2740", "#1a0a12"],
lore:
"The last ranger of a city that no longer exists, Nyra hunts the Hollow with arrows fletched from her home's ashes. Every shot is a name remembered.",
stats: { power: 88, defense: 38, mobility: 76, utility: 44 },
abilities: [
{ key: "Q", icon: "🏹", name: "Cinder Volley", cd: "6s", desc: "Loose three burning arrows that apply stacking ignite." },
{ key: "E", icon: "🪤", name: "Snare Line", cd: "14s", desc: "Plant a tripwire that roots the first enemy to cross it for 1.5s." },
{ key: "⇧", icon: "💨", name: "Ash Step", cd: "9s", desc: "Dash and leave a decoy of smoke that draws enemy fire." },
{ key: "R", icon: "☄", name: "Daybreak", cd: "80s", ult: true, desc: "Charge a piercing solar shot that deals more damage per enemy hit." },
],
},
{
id: "vex9",
name: "Vex-9",
epithet: "Null Protocol",
role: "dps",
rarity: "legendary",
difficulty: 3,
origin: "Nullforge Black Site",
colors: ["#1b4e5e", "#06141a"],
lore:
"An autonomous duelist frame that escaped decommissioning with nine personality shards — eight of them hostile. Vex-9 fights like a glitch given purpose.",
stats: { power: 94, defense: 30, mobility: 92, utility: 36 },
abilities: [
{ key: "Q", icon: "⚡", name: "Splitstrike", cd: "5s", desc: "Blink-slash through a target, dealing damage on entry and exit." },
{ key: "E", icon: "🌀", name: "Echo Frame", cd: "16s", desc: "Spawn a mirror copy that repeats your last ability at 50% power." },
{ key: "⇧", icon: "🩻", name: "Phase Skew", cd: "11s", desc: "Become untargetable for 0.8s; the next hit crits." },
{ key: "R", icon: "💀", name: "Nine Shards", cd: "100s", ult: true, desc: "All shards manifest at once — nine strikes across nearby enemies." },
],
},
{
id: "maris",
name: "Maris Thorne",
epithet: "The Tidebinder",
role: "support",
rarity: "epic",
difficulty: 2,
origin: "Drowned Quarter",
colors: ["#1e4d46", "#07140f"],
lore:
"Maris keeps her squad alive with water stolen from a sea that swallowed her family. She does not call it magic. She calls it a debt being repaid.",
stats: { power: 42, defense: 50, mobility: 48, utility: 90 },
abilities: [
{ key: "Q", icon: "💧", name: "Mendtide", cd: "4s", desc: "Send a wave that heals allies it passes through for 180 HP." },
{ key: "E", icon: "🫧", name: "Undertow", cd: "13s", desc: "Pull an enemy toward you and slow them by 40% for 2s." },
{ key: "⇧", icon: "🌊", name: "Slipstream", cd: "10s", desc: "Surf a current forward, cleansing slows from nearby allies." },
{ key: "R", icon: "⛲", name: "High Tide", cd: "95s", ult: true, desc: "Flood the area: allies inside heal rapidly and gain 25% move speed." },
],
},
{
id: "dargoth",
name: "Dargoth",
epithet: "Hollow-Touched",
role: "tank",
rarity: "epic",
difficulty: 2,
origin: "The Sundered Crown",
colors: ["#3c2a5e", "#0e0a1a"],
lore:
"Half consumed by the Hollow, Dargoth wears the corruption like armor. Where the void took his arm, it left a weapon — and a whisper he refuses to obey.",
stats: { power: 66, defense: 86, mobility: 40, utility: 52 },
abilities: [
{ key: "Q", icon: "🦾", name: "Void Grasp", cd: "9s", desc: "Extend the hollow arm to grab and yank an enemy to melee range." },
{ key: "E", icon: "🕳", name: "Abyssal Hide", cd: "15s", desc: "Convert 30% of damage taken into shields for 4s." },
{ key: "⇧", icon: "👣", name: "Dread March", cd: "12s", desc: "Stride forward unstoppably, fearing minions in your path." },
{ key: "R", icon: "🌑", name: "Crown of Nothing", cd: "110s", ult: true, desc: "Silence and tether all enemies in a wide ring to your position." },
],
},
{
id: "lumen",
name: "Lumen",
epithet: "The Last Lighthouse",
role: "support",
rarity: "legendary",
difficulty: 3,
origin: "Aster Observatory",
colors: ["#5e5320", "#161204"],
lore:
"A sentient beacon that walked off its cliff when the ships stopped coming. Lumen now guides heroes instead of hulls, burning brighter the darker it gets.",
stats: { power: 50, defense: 44, mobility: 56, utility: 96 },
abilities: [
{ key: "Q", icon: "🔆", name: "Guidelight", cd: "5s", desc: "Mark an ally: they gain 15% damage and heal when they hit marked foes." },
{ key: "E", icon: "🚨", name: "Warning Flare", cd: "12s", desc: "Reveal enemies in an area and blind those facing the flare." },
{ key: "⇧", icon: "✨", name: "Drift Beam", cd: "8s", desc: "Glide along a light ray; allies you pass gain a small shield." },
{ key: "R", icon: "🌟", name: "First Light", cd: "120s", ult: true, desc: "Resurrect the most recently fallen ally at 60% HP." },
],
},
{
id: "sable",
name: "Sable Riot",
epithet: "Queen of the Undercity",
role: "dps",
rarity: "rare",
difficulty: 1,
origin: "Neon Drift Undercity",
colors: ["#5e1e3a", "#16060e"],
lore:
"Street racer, smuggler, folk hero. Sable's twin scatterguns are named Rent and Revenge, and she's behind on neither.",
stats: { power: 80, defense: 42, mobility: 70, utility: 30 },
abilities: [
{ key: "Q", icon: "🔫", name: "Double Trouble", cd: "4s", desc: "Fire both scatterguns in a cone for heavy close-range damage." },
{ key: "E", icon: "🧨", name: "Rude Welcome", cd: "12s", desc: "Toss a sticky charge that detonates after 2s or on reactivation." },
{ key: "⇧", icon: "🛹", name: "Kickflip", cd: "7s", desc: "Vault backward off the nearest surface, reloading instantly." },
{ key: "R", icon: "🎆", name: "Riot Act", cd: "75s", ult: true, desc: "Unload an endless magazine for 5s with no reload and 30% lifesteal." },
],
},
{
id: "brann",
name: "Brann Okoro",
epithet: "The Field Medic",
role: "support",
rarity: "rare",
difficulty: 1,
origin: "Vanguard Expedition VII",
colors: ["#1e5e35", "#06150c"],
lore:
"Brann patched soldiers through three Hollow incursions with a toolkit and bad jokes. His drones do the flying; he does the caring.",
stats: { power: 38, defense: 56, mobility: 44, utility: 84 },
abilities: [
{ key: "Q", icon: "🚑", name: "Medidrone", cd: "6s", desc: "Deploy a drone that heals the lowest-HP ally for 220 over 4s." },
{ key: "E", icon: "💉", name: "Stim Dart", cd: "10s", desc: "Grant an ally 20% attack speed and slow immunity for 3s." },
{ key: "⇧", icon: "🧰", name: "Triage Pack", cd: "14s", desc: "Drop a med kit that allies can pick up for a burst heal." },
{ key: "R", icon: "🛰", name: "Mercy Uplink", cd: "100s", ult: true, desc: "All drones overcharge: team-wide healing beam for 6s." },
],
},
{
id: "ironmaw",
name: "Ironmaw",
epithet: "The Walking Vault",
role: "tank",
rarity: "rare",
difficulty: 1,
origin: "Scrapline Foundry",
colors: ["#444c5e", "#0d0f14"],
lore:
"A salvage mech rebuilt by Undercity kids from bank-vault plating and stubbornness. Ironmaw remembers being a door, and doors do not move.",
stats: { power: 52, defense: 92, mobility: 28, utility: 48 },
abilities: [
{ key: "Q", icon: "🚪", name: "Vault Slam", cd: "8s", desc: "Slam both arms down, stunning enemies in front for 1s." },
{ key: "E", icon: "🔩", name: "Bolted Down", cd: "16s", desc: "Become immovable and reflect 20% of damage taken." },
{ key: "⇧", icon: "🛞", name: "Scrap Roll", cd: "11s", desc: "Curl and roll forward, blocking projectiles while moving." },
{ key: "R", icon: "🏦", name: "Lockdown", cd: "105s", ult: true, desc: "Erect vault walls around an area, trapping everyone inside with you." },
],
},
{
id: "echolin",
name: "Echo Lin",
epithet: "The Soundbreaker",
role: "dps",
rarity: "epic",
difficulty: 3,
origin: "Resonance Conservatory",
colors: ["#2a3a6e", "#090c1a"],
lore:
"A concert violinist who weaponized resonance after the Hollow ate every frequency she loved. Her solos shatter armor — and the occasional eardrum.",
stats: { power: 84, defense: 36, mobility: 64, utility: 58 },
abilities: [
{ key: "Q", icon: "🎻", name: "Staccato", cd: "5s", desc: "Fire three piercing notes; the third note deals double damage." },
{ key: "E", icon: "🔇", name: "Dead Air", cd: "13s", desc: "Create a zone of silence that mutes enemy abilities for 2s." },
{ key: "⇧", icon: "🎵", name: "Glissando", cd: "9s", desc: "Slide along a sound wave, phasing through units." },
{ key: "R", icon: "📢", name: "Crescendo", cd: "85s", ult: true, desc: "Build a 3s chord, then release a screen-wide shockwave." },
],
},
{
id: "thessaly",
name: "Thessaly",
epithet: "Warden of the Bloom",
role: "tank",
rarity: "epic",
difficulty: 2,
origin: "Verdant Scar",
colors: ["#2e5e1e", "#0a1406"],
lore:
"Where the Hollow burned the great forest, one warden grew back wrong — bark like shield plate, roots like grappling lines, patience like winter.",
stats: { power: 60, defense: 88, mobility: 36, utility: 66 },
abilities: [
{ key: "Q", icon: "🌿", name: "Rootline", cd: "9s", desc: "Lash a root that roots the target and pulls you to them." },
{ key: "E", icon: "🌳", name: "Heartwood", cd: "14s", desc: "Regenerate 8% max HP over 4s; armor doubled while channeling." },
{ key: "⇧", icon: "🍃", name: "Mulch Charge", cd: "12s", desc: "Charge forward, leaving healing spores in your wake." },
{ key: "R", icon: "🌺", name: "Worldbloom", cd: "115s", ult: true, desc: "A colossal flower erupts, knocking enemies aside and sheltering allies." },
],
},
];
/* ---------- DOM refs ---------- */
const $ = (sel) => document.querySelector(sel);
const grid = $("#grid");
const emptyEl = $("#empty");
const resultCount = $("#result-count");
const searchInput = $("#search");
const sortBtn = $("#sort-rarity");
const chipEls = Array.from(document.querySelectorAll(".chip"));
const toastEl = $("#toast");
const state = {
role: "all",
query: "",
sortByRarity: false,
selectedId: HEROES[0].id,
};
/* ---------- Toast helper ---------- */
let toastTimer = null;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove("show"), 2400);
}
/* ---------- Filtering / sorting ---------- */
function visibleHeroes() {
let list = HEROES.filter((h) => {
const roleOk = state.role === "all" || h.role === state.role;
const q = state.query.trim().toLowerCase();
const queryOk =
!q ||
h.name.toLowerCase().includes(q) ||
h.epithet.toLowerCase().includes(q);
return roleOk && queryOk;
});
if (state.sortByRarity) {
list = list
.slice()
.sort(
(a, b) =>
RARITY_ORDER[a.rarity] - RARITY_ORDER[b.rarity] ||
a.name.localeCompare(b.name)
);
}
return list;
}
/* ---------- Render grid ---------- */
function renderGrid() {
const list = visibleHeroes();
grid.innerHTML = "";
list.forEach((hero, i) => {
const li = document.createElement("li");
li.className = `card rarity-${hero.rarity} role-${hero.role}`;
li.id = `card-${hero.id}`;
li.setAttribute("role", "option");
li.setAttribute("tabindex", "0");
li.setAttribute(
"aria-selected",
hero.id === state.selectedId ? "true" : "false"
);
li.setAttribute(
"aria-label",
`${hero.name}, ${ROLE_META[hero.role].label}, ${hero.rarity}`
);
li.style.animationDelay = `${Math.min(i * 28, 280)}ms`;
li.dataset.id = hero.id;
li.innerHTML = `
<span class="card-rarity">${hero.rarity}</span>
<span class="portrait" style="--p1:${hero.colors[0]};--p2:${hero.colors[1]}" aria-hidden="true">${hero.name.charAt(0)}</span>
<p class="card-name">${hero.name}</p>
<span class="card-role"><span class="role-ico" aria-hidden="true">${ROLE_META[hero.role].glyph}</span>${ROLE_META[hero.role].label}</span>
`;
li.addEventListener("click", () => selectHero(hero.id));
li.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
selectHero(hero.id);
}
});
grid.appendChild(li);
});
emptyEl.hidden = list.length > 0;
grid.hidden = list.length === 0;
resultCount.textContent = `${list.length} of ${HEROES.length} heroes shown`;
}
/* ---------- Featured panel ---------- */
function selectHero(id, opts = {}) {
const hero = HEROES.find((h) => h.id === id);
if (!hero) return;
state.selectedId = id;
// grid selection rings
grid.querySelectorAll(".card").forEach((card) => {
card.setAttribute(
"aria-selected",
card.dataset.id === id ? "true" : "false"
);
});
// splash
const splash = $("#splash");
splash.style.setProperty("--p1", hero.colors[0]);
splash.style.setProperty("--p2", hero.colors[1]);
$("#splash-letter").textContent = hero.name.charAt(0);
$("#splash-glyph").textContent = ROLE_META[hero.role].glyph;
// identity
$("#f-role").textContent = ROLE_META[hero.role].label;
$("#f-name").textContent = hero.name;
$("#f-epithet").textContent = hero.epithet;
$("#f-lore").textContent = hero.lore;
$("#f-origin").textContent = hero.origin;
const rarityEl = $("#f-rarity");
rarityEl.textContent = hero.rarity;
rarityEl.className = `rarity-badge rarity-${hero.rarity}`;
// difficulty pips
const diffEl = $("#f-difficulty");
diffEl.setAttribute("aria-label", `Difficulty ${hero.difficulty} of 3`);
diffEl.querySelectorAll(".pip").forEach((pip, i) => {
pip.classList.toggle("on", i < hero.difficulty);
});
// stat bars: reset to 0 then animate to value
document.querySelectorAll(".stat").forEach((row) => {
const key = row.dataset.stat;
const fill = row.querySelector(".stat-fill");
const num = row.querySelector(".stat-num");
const value = hero.stats[key];
fill.style.transition = "none";
fill.style.width = "0%";
// force reflow so the bar re-animates on every selection
void fill.offsetWidth;
fill.style.transition = "";
requestAnimationFrame(() => {
fill.style.width = `${value}%`;
});
animateNumber(num, value);
});
renderAbilities(hero);
if (!opts.silent) {
toast(`${hero.name} — ${hero.epithet}`);
}
}
function animateNumber(el, target) {
const start = performance.now();
const dur = 600;
function tick(now) {
const t = Math.min((now - start) / dur, 1);
const eased = 1 - Math.pow(1 - t, 3);
el.textContent = Math.round(target * eased);
if (t < 1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
/* ---------- Abilities ---------- */
function renderAbilities(hero) {
const row = $("#ability-row");
row.innerHTML = "";
hero.abilities.forEach((ab) => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = `ability${ab.ult ? " is-ult" : ""}`;
btn.setAttribute("aria-label", `${ab.name} (${ab.key}) — ${ab.desc}`);
btn.innerHTML = `
<span aria-hidden="true">${ab.icon}</span>
<span class="ability-key" aria-hidden="true">${ab.key}</span>
<span class="ability-tip" role="tooltip">
<span class="tip-name">${ab.name}${ab.ult ? " · ULT" : ""}</span>
<span class="tip-desc">${ab.desc}</span>
<span class="tip-cd">Cooldown ${ab.cd}</span>
</span>
`;
btn.addEventListener("click", () =>
toast(`${ab.name} previewed — equip ${hero.name} to use it in-game.`)
);
row.appendChild(btn);
});
}
/* ---------- Events ---------- */
chipEls.forEach((chip) => {
chip.addEventListener("click", () => {
state.role = chip.dataset.role;
chipEls.forEach((c) => {
const active = c === chip;
c.classList.toggle("is-active", active);
c.setAttribute("aria-pressed", String(active));
});
renderGrid();
});
});
searchInput.addEventListener("input", () => {
state.query = searchInput.value;
renderGrid();
});
sortBtn.addEventListener("click", () => {
state.sortByRarity = !state.sortByRarity;
sortBtn.setAttribute("aria-pressed", String(state.sortByRarity));
renderGrid();
toast(
state.sortByRarity
? "Sorted by rarity — Legendary first."
: "Rarity sort off — default order restored."
);
});
$("#clear-filters").addEventListener("click", () => {
state.role = "all";
state.query = "";
searchInput.value = "";
chipEls.forEach((c) => {
const active = c.dataset.role === "all";
c.classList.toggle("is-active", active);
c.setAttribute("aria-pressed", String(active));
});
renderGrid();
toast("Filters cleared.");
});
$("#select-hero").addEventListener("click", () => {
const hero = HEROES.find((h) => h.id === state.selectedId);
toast(`${hero.name} locked in. Queueing for the Sundered Crown…`);
});
$("#wishlist-skin").addEventListener("click", () => {
const hero = HEROES.find((h) => h.id === state.selectedId);
toast(`"${hero.epithet}" skin added to your wishlist.`);
});
$("#play-now").addEventListener("click", () => {
toast("Launching Hollow Reign… (demo)");
});
/* ---------- Init ---------- */
document.getElementById("roster-total").textContent = String(HEROES.length);
renderGrid();
selectHero(state.selectedId, { silent: true });
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Hollow Reign — Hero Roster</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=Orbitron:wght@500;700;900&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="page">
<!-- Top bar -->
<header class="topbar" aria-label="Game header">
<div class="brand">
<span class="brand-mark" aria-hidden="true">◆</span>
<span class="brand-name">HOLLOW <em>REIGN</em></span>
<span class="brand-tag">by Nullforge</span>
</div>
<nav class="topnav" aria-label="Primary">
<a href="#" class="topnav-link">Home</a>
<a href="#" class="topnav-link is-active" aria-current="page">Heroes</a>
<a href="#" class="topnav-link">Maps</a>
<a href="#" class="topnav-link">Seasons</a>
</nav>
<button class="btn btn-cta" id="play-now" type="button">Play Now</button>
</header>
<!-- Page heading -->
<section class="hero-head">
<h1 class="page-title">Hero Roster</h1>
<p class="page-sub">Season 7 — <strong>Embers of the Hollow</strong>. Choose your vanguard from <span id="roster-total">0</span> heroes across three combat roles.</p>
</section>
<div class="layout">
<!-- Left: roster column -->
<section class="roster-col" aria-label="Hero roster">
<!-- Filters -->
<div class="filters" role="group" aria-label="Roster filters">
<div class="chips" role="group" aria-label="Filter by role">
<button class="chip is-active" type="button" data-role="all" aria-pressed="true">All</button>
<button class="chip chip-tank" type="button" data-role="tank" aria-pressed="false"><span class="chip-ico" aria-hidden="true">⛨</span> Tank</button>
<button class="chip chip-dps" type="button" data-role="dps" aria-pressed="false"><span class="chip-ico" aria-hidden="true">⚔</span> DPS</button>
<button class="chip chip-support" type="button" data-role="support" aria-pressed="false"><span class="chip-ico" aria-hidden="true">✚</span> Support</button>
</div>
<div class="filter-tools">
<div class="search">
<span class="search-ico" aria-hidden="true">⌕</span>
<input id="search" type="search" placeholder="Search heroes…" aria-label="Search heroes by name" autocomplete="off" />
</div>
<button class="btn btn-ghost" id="sort-rarity" type="button" aria-pressed="false" title="Sort by rarity">
<span aria-hidden="true">◇</span> Rarity sort
</button>
</div>
</div>
<p class="result-count" id="result-count" role="status" aria-live="polite"></p>
<!-- Grid (cards injected by script.js) -->
<ul class="grid" id="grid" role="listbox" aria-label="Select a hero"></ul>
<div class="empty" id="empty" hidden>
<p class="empty-title">No heroes found</p>
<p class="empty-sub">Try a different name or clear the role filter.</p>
<button class="btn btn-ghost" id="clear-filters" type="button">Clear filters</button>
</div>
</section>
<!-- Right: featured hero -->
<aside class="featured" id="featured" aria-label="Featured hero" aria-live="polite">
<div class="splash" id="splash">
<span class="splash-glyph" id="splash-glyph" aria-hidden="true">⛨</span>
<span class="splash-letter" id="splash-letter" aria-hidden="true">K</span>
<div class="splash-scan" aria-hidden="true"></div>
</div>
<div class="featured-body">
<div class="featured-top">
<div>
<p class="featured-role" id="f-role">Tank</p>
<h2 class="featured-name" id="f-name">Kargath</h2>
<p class="featured-epithet" id="f-epithet">The Iron Bulwark</p>
</div>
<span class="rarity-badge" id="f-rarity">Legendary</span>
</div>
<p class="featured-lore" id="f-lore">Lore loading…</p>
<div class="featured-meta">
<div class="meta-block">
<span class="meta-label">Difficulty</span>
<span class="diff-pips" id="f-difficulty" aria-label="Difficulty 2 of 3">
<i class="pip"></i><i class="pip"></i><i class="pip"></i>
</span>
</div>
<div class="meta-block">
<span class="meta-label">Origin</span>
<span class="meta-value" id="f-origin">—</span>
</div>
</div>
<div class="stats" id="stats" aria-label="Hero stats">
<div class="stat" data-stat="power">
<span class="stat-name">Power</span>
<span class="stat-track"><span class="stat-fill"></span></span>
<span class="stat-num">0</span>
</div>
<div class="stat" data-stat="defense">
<span class="stat-name">Defense</span>
<span class="stat-track"><span class="stat-fill"></span></span>
<span class="stat-num">0</span>
</div>
<div class="stat" data-stat="mobility">
<span class="stat-name">Mobility</span>
<span class="stat-track"><span class="stat-fill"></span></span>
<span class="stat-num">0</span>
</div>
<div class="stat" data-stat="utility">
<span class="stat-name">Utility</span>
<span class="stat-track"><span class="stat-fill"></span></span>
<span class="stat-num">0</span>
</div>
</div>
<div class="featured-actions">
<button class="btn btn-cta" id="select-hero" type="button">Select Hero</button>
<button class="btn btn-ghost" id="wishlist-skin" type="button">Wishlist Skin</button>
</div>
</div>
<!-- Abilities strip -->
<div class="abilities" aria-label="Hero abilities">
<p class="abilities-title">Ability Kit</p>
<div class="ability-row" id="ability-row"></div>
</div>
</aside>
</div>
<footer class="foot">
<p>Hollow Reign © 2026 Nullforge Interactive — fictional demo content.</p>
</footer>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Character / Hero Roster (select grid)
A full hero-select screen styled as an in-game HUD for the fictional shooter Hollow Reign by Nullforge. The left column holds a responsive grid of portrait cards — each with a CSS gradient portrait, a rarity ring (Legendary, Epic, Rare), a role icon, and a name. Role chips for Tank, DPS, and Support filter the grid, a search field matches by name or epithet, and a rarity-sort toggle reorders the roster Legendary-first.
The right panel features the selected hero: animated splash backdrop, role and epithet, lore blurb, difficulty pips, origin, and four stat bars (Power, Defense, Mobility, Utility) that reset and re-fill with an eased count-up on every selection. Below it, an ability strip shows each hero’s four-skill kit with neon hover tooltips describing the ability and its cooldown, plus a highlighted ultimate.
Everything runs on vanilla JS: chip and search filtering, click- and keyboard-driven selection, animated stat bars, ability tooltips, and a small toast() helper for feedback. Cards animate in on filter changes, the panel sticks on desktop and stacks on mobile, and the layout holds together down to ~360px. No frameworks, no build step.
Illustrative UI only — fictional games, studios, characters, and data. Not engine integrations.