# Cyberdeck UI
> Copy-paste cyberpunk components for the web, in HTML/CSS and React.
A free, open-source library of cyberpunk UI components: neon buttons, glitch text, HUD cards, forms, menus, a command palette, backgrounds and more. Every component comes as plain HTML/CSS and as a typed React component, with no dependencies.
56 components in 9 categories. Free and open source (MIT). Made by Ricardo Gonçalves (https://riagoncalves.dev).
How to use a component:
1. Optionally load the fonts from Google Fonts: Orbitron (display), Rajdhani (body) and Share Tech Mono (mono).
2. Add tokens.css once, globally. It defines the --cy-* CSS custom properties (colours, fonts, corner size) that every component uses.
3. Copy the component: its HTML + CSS (+ a small vanilla JS file for interactive ones), or its React .tsx file + the same CSS.
Conventions: class names start with "cy-", CSS variables with "--cy-". No dependencies and no build step. Animations respect prefers-reduced-motion, and interactive components use native elements (, , checkboxes, radios, range inputs) with keyboard support and ARIA where needed.
## Design tokens (tokens.css)
Add this once, before any component.
```css
/*
* Cyberdeck UI — design tokens.
* Add this once (globally) before using any component.
*
* Fonts (optional, but the components are designed around them):
*
*
*
*/
:root {
/* Surfaces */
--cy-bg: #07050d;
--cy-surface: #0d0a17;
--cy-surface-2: #151024;
--cy-border: rgba(0, 240, 255, 0.18);
/* Neon accents */
--cy-cyan: #00f0ff;
--cy-pink: #ff2a6d;
--cy-yellow: #fcee0a;
--cy-purple: #b967ff;
--cy-green: #05ffa1;
/* Text */
--cy-text: #e5e7eb;
--cy-muted: #9ca3af;
--cy-dim: #6b7280;
/* Typography */
--cy-font-display: 'Orbitron', ui-sans-serif, system-ui, sans-serif;
--cy-font-body: 'Rajdhani', ui-sans-serif, system-ui, sans-serif;
--cy-font-mono: 'Share Tech Mono', ui-monospace, monospace;
/* Shape & motion */
--cy-cut: 14px; /* size of clipped corners */
--cy-ease: cubic-bezier(0.2, 0.8, 0.2, 1);
--cy-glow-cyan: 0 0 6px rgba(0, 240, 255, 0.6), 0 0 18px rgba(0, 240, 255, 0.35);
--cy-glow-pink: 0 0 6px rgba(255, 42, 109, 0.6), 0 0 18px rgba(255, 42, 109, 0.35);
}
```
## Components
### Buttons / Neon Button
URL: https://cyberdeck-ui.netlify.app/components/buttons#neon-button
The primary action: solid yellow with clipped corners, turning cyan on hover and focus.
React usage:
```tsx
Jack in
```
#### neon-button.html
```html
Jack in
Offline
```
#### neon-button.css
```css
.cy-btn-neon {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.8rem 1.75rem;
border: 0;
background: var(--cy-yellow);
color: #000;
font-family: var(--cy-font-display);
font-size: 0.875rem;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
cursor: pointer;
clip-path: polygon(
0 0, calc(100% - var(--cy-cut)) 0, 100% var(--cy-cut),
100% 100%, var(--cy-cut) 100%, 0 calc(100% - var(--cy-cut))
);
transition: background-color 0.2s var(--cy-ease), transform 0.2s var(--cy-ease);
}
/* clip-path also clips outlines, so focus uses the same colour change as hover */
.cy-btn-neon:hover:not(:disabled),
.cy-btn-neon:focus-visible {
background: var(--cy-cyan);
outline: none;
}
.cy-btn-neon:active:not(:disabled) {
transform: translateY(1px);
}
.cy-btn-neon:disabled {
opacity: 0.5;
cursor: not-allowed;
}
```
#### NeonButton.tsx
```tsx
import type { ButtonHTMLAttributes } from 'react';
import './neon-button.css';
export function NeonButton({ className = '', type = 'button', ...props }: ButtonHTMLAttributes) {
return ;
}
```
---
### Buttons / Outline Button
URL: https://cyberdeck-ui.netlify.app/components/buttons#outline-button
A secondary action with a neon border and glow. Comes in cyan and pink.
React usage:
```tsx
Get in touch
Abort
```
#### outline-button.html
```html
Get in touch
Abort
```
#### outline-button.css
```css
.cy-btn-outline {
--cy-btn: 0 240 255; /* RGB channels of the accent colour */
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.8rem 1.75rem;
border: 1px solid rgb(var(--cy-btn) / 0.6);
background: transparent;
color: rgb(var(--cy-btn));
font-family: var(--cy-font-display);
font-size: 0.875rem;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s, box-shadow 0.2s;
}
.cy-btn-outline--pink {
--cy-btn: 255 42 109;
}
.cy-btn-outline:hover,
.cy-btn-outline:focus-visible {
outline: none;
border-color: rgb(var(--cy-btn));
background: rgb(var(--cy-btn) / 0.1);
box-shadow: 0 0 20px rgb(var(--cy-btn) / 0.35), inset 0 0 12px rgb(var(--cy-btn) / 0.15);
}
.cy-btn-outline:disabled {
opacity: 0.5;
cursor: not-allowed;
}
```
#### OutlineButton.tsx
```tsx
import type { ButtonHTMLAttributes } from 'react';
import './outline-button.css';
interface Props extends ButtonHTMLAttributes {
tone?: 'cyan' | 'pink';
}
export function OutlineButton({ tone = 'cyan', className = '', type = 'button', ...props }: Props) {
const toneClass = tone === 'pink' ? 'cy-btn-outline--pink' : '';
return ;
}
```
---
### Buttons / Glitch Button
URL: https://cyberdeck-ui.netlify.app/components/buttons#glitch-button
The label splits into cyan and magenta slices on hover and keyboard focus.
React usage:
```tsx
Hack the grid
```
#### glitch-button.html
```html
Hack the grid
```
#### glitch-button.css
```css
.cy-btn-glitch {
position: relative;
padding: 0.85rem 2rem;
border: 1px solid var(--cy-pink);
background: var(--cy-surface);
color: var(--cy-text);
font-family: var(--cy-font-display);
font-size: 0.875rem;
font-weight: 700;
letter-spacing: 0.15em;
text-transform: uppercase;
cursor: pointer;
transition: box-shadow 0.2s, color 0.2s;
}
/* Two copies of the label, sliced and offset while hovered */
.cy-btn-glitch::before,
.cy-btn-glitch::after {
content: attr(data-text);
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: inherit;
clip-path: inset(0 0 100% 0);
pointer-events: none;
}
.cy-btn-glitch::before { color: var(--cy-cyan); }
.cy-btn-glitch::after { color: var(--cy-pink); }
.cy-btn-glitch:hover,
.cy-btn-glitch:focus-visible {
outline: none;
color: #fff;
box-shadow: 0 0 18px rgba(255, 42, 109, 0.45);
}
.cy-btn-glitch:hover::before,
.cy-btn-glitch:focus-visible::before {
animation: cy-btn-glitch-a 0.45s steps(2) infinite;
}
.cy-btn-glitch:hover::after,
.cy-btn-glitch:focus-visible::after {
animation: cy-btn-glitch-b 0.45s steps(2) infinite reverse;
}
@keyframes cy-btn-glitch-a {
0% { clip-path: inset(20% 0 60% 0); transform: translate(-4px, 0); }
25% { clip-path: inset(60% 0 10% 0); transform: translate(4px, 0); }
50% { clip-path: inset(40% 0 40% 0); transform: translate(-2px, 0); }
75% { clip-path: inset(80% 0 5% 0); transform: translate(3px, 0); }
100% { clip-path: inset(10% 0 75% 0); transform: translate(-3px, 0); }
}
@keyframes cy-btn-glitch-b {
0% { clip-path: inset(70% 0 10% 0); transform: translate(3px, 0); }
25% { clip-path: inset(15% 0 70% 0); transform: translate(-3px, 0); }
50% { clip-path: inset(50% 0 30% 0); transform: translate(2px, 0); }
75% { clip-path: inset(5% 0 85% 0); transform: translate(-4px, 0); }
100% { clip-path: inset(35% 0 45% 0); transform: translate(4px, 0); }
}
@media (prefers-reduced-motion: reduce) {
.cy-btn-glitch::before,
.cy-btn-glitch::after {
display: none;
}
}
```
#### GlitchButton.tsx
```tsx
import type { ButtonHTMLAttributes } from 'react';
import './glitch-button.css';
interface Props extends Omit, 'children'> {
/** Plain text: the glitch layers repeat it via data-text. */
children: string;
}
export function GlitchButton({ children, className = '', type = 'button', ...props }: Props) {
return (
{children}
);
}
```
---
### Buttons / Bracket Button
URL: https://cyberdeck-ui.netlify.app/components/buttons#bracket-button
A minimal terminal-style button whose brackets spread apart on hover.
React usage:
```tsx
Execute
```
#### bracket-button.html
```html
Execute
```
#### bracket-button.css
```css
.cy-btn-bracket {
position: relative;
padding: 0.6rem 1.5rem;
border: 0;
background: transparent;
color: var(--cy-text);
font-family: var(--cy-font-mono);
font-size: 1rem;
letter-spacing: 0.2em;
text-transform: uppercase;
cursor: pointer;
transition: color 0.2s, text-shadow 0.2s;
}
.cy-btn-bracket::before,
.cy-btn-bracket::after {
position: absolute;
top: 50%;
color: var(--cy-pink);
transform: translateY(-50%);
transition: transform 0.2s var(--cy-ease);
}
.cy-btn-bracket::before { content: '['; left: 0; }
.cy-btn-bracket::after { content: ']'; right: 0; }
.cy-btn-bracket:hover,
.cy-btn-bracket:focus-visible {
outline: none;
color: var(--cy-cyan);
text-shadow: var(--cy-glow-cyan);
}
.cy-btn-bracket:hover::before,
.cy-btn-bracket:focus-visible::before {
transform: translate(-6px, -50%);
}
.cy-btn-bracket:hover::after,
.cy-btn-bracket:focus-visible::after {
transform: translate(6px, -50%);
}
```
#### BracketButton.tsx
```tsx
import type { ButtonHTMLAttributes } from 'react';
import './bracket-button.css';
export function BracketButton({ className = '', type = 'button', ...props }: ButtonHTMLAttributes) {
return ;
}
```
---
### Buttons / Icon Button
URL: https://cyberdeck-ui.netlify.app/components/buttons#icon-button
A square, glowing icon-only button with a required accessible label.
React usage:
```tsx
```
#### icon-button.html
```html
```
#### icon-button.css
```css
.cy-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
padding: 0;
border: 1px solid rgba(0, 240, 255, 0.5);
background: rgba(7, 5, 13, 0.85);
color: var(--cy-cyan);
box-shadow: 0 0 12px rgba(0, 240, 255, 0.2);
cursor: pointer;
transition: background-color 0.2s, color 0.2s, box-shadow 0.2s;
}
.cy-icon-btn:hover,
.cy-icon-btn:focus-visible {
outline: none;
background: var(--cy-cyan);
color: #000;
box-shadow: 0 0 24px rgba(0, 240, 255, 0.6);
}
```
#### IconButton.tsx
```tsx
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import './icon-button.css';
interface Props extends Omit, 'children'> {
/** Accessible name; icon-only buttons have no visible text. */
label: string;
/** The icon, e.g. an inline SVG with aria-hidden="true". */
children: ReactNode;
}
export function IconButton({ label, children, className = '', type = 'button', ...props }: Props) {
return (
{children}
);
}
```
---
### Typography / Glitch Text
URL: https://cyberdeck-ui.netlify.app/components/typography#glitch-text
A headline that glitches into cyan and magenta every few seconds, or only on hover.
React usage:
```tsx
```
#### glitch-text.html
```html
Night Shift
```
#### glitch-text.css
```css
.cy-glitch {
position: relative;
display: inline-block;
margin: 0;
color: #fff;
font-family: var(--cy-font-display);
font-size: clamp(2.5rem, 8vw, 4.5rem);
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
text-shadow: var(--cy-glow-pink);
}
.cy-glitch::before,
.cy-glitch::after {
content: attr(data-text);
position: absolute;
inset: 0;
pointer-events: none;
mix-blend-mode: screen;
}
.cy-glitch::before {
color: var(--cy-cyan);
transform: translate(-2px, 0);
animation: cy-glitch-top 3.5s infinite steps(1);
}
.cy-glitch::after {
color: var(--cy-pink);
transform: translate(2px, 0);
animation: cy-glitch-bottom 3.5s infinite steps(1);
}
/* Only glitch while hovered */
.cy-glitch--hover::before,
.cy-glitch--hover::after {
animation-duration: 1.2s;
animation-play-state: paused;
}
.cy-glitch--hover:hover::before,
.cy-glitch--hover:hover::after {
animation-play-state: running;
}
@keyframes cy-glitch-top {
0%, 88%, 100% { clip-path: inset(0 0 100% 0); }
89% { clip-path: inset(10% 0 60% 0); transform: translate(-6px, -1px); }
91% { clip-path: inset(40% 0 35% 0); transform: translate(5px, 1px); }
93% { clip-path: inset(70% 0 5% 0); transform: translate(-3px, 0); }
95% { clip-path: inset(0 0 100% 0); }
}
@keyframes cy-glitch-bottom {
0%, 89%, 100% { clip-path: inset(100% 0 0 0); }
90% { clip-path: inset(55% 0 20% 0); transform: translate(6px, 1px); }
92% { clip-path: inset(15% 0 70% 0); transform: translate(-5px, -1px); }
94% { clip-path: inset(80% 0 2% 0); transform: translate(3px, 0); }
96% { clip-path: inset(100% 0 0 0); }
}
@media (prefers-reduced-motion: reduce) {
.cy-glitch::before,
.cy-glitch::after {
display: none;
}
}
```
#### GlitchText.tsx
```tsx
import './glitch-text.css';
interface Props {
text: string;
as?: 'h1' | 'h2' | 'h3' | 'p' | 'span';
/** Glitch only on hover instead of every few seconds. */
hoverOnly?: boolean;
className?: string;
}
export function GlitchText({ text, as: Tag = 'h1', hoverOnly = false, className = '' }: Props) {
const classes = `cy-glitch ${hoverOnly ? 'cy-glitch--hover' : ''} ${className}`.trim();
return (
{text}
);
}
```
---
### Typography / Neon Text
URL: https://cyberdeck-ui.netlify.app/components/typography#neon-text
Glowing sign text in four colours, with an optional loose-wire flicker.
React usage:
```tsx
Open 24/7
No signal
```
#### neon-text.html
```html
Open 24/7
No signal
```
#### neon-text.css
```css
.cy-neon {
--cy-neon: 0 240 255; /* RGB channels of the glow */
margin: 0;
color: #fff;
font-family: var(--cy-font-display);
font-size: 2rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
text-shadow:
0 0 4px #fff,
0 0 10px rgb(var(--cy-neon)),
0 0 24px rgb(var(--cy-neon)),
0 0 48px rgb(var(--cy-neon) / 0.6);
}
.cy-neon--pink { --cy-neon: 255 42 109; }
.cy-neon--yellow { --cy-neon: 252 238 10; }
.cy-neon--green { --cy-neon: 5 255 161; }
/* A sign with a loose wire */
.cy-neon--flicker {
animation: cy-neon-flicker 4s infinite;
}
@keyframes cy-neon-flicker {
0%, 19%, 21%, 23%, 25%, 54%, 56%, 100% { opacity: 1; }
20%, 24%, 55% { opacity: 0.35; text-shadow: none; }
}
@media (prefers-reduced-motion: reduce) {
.cy-neon--flicker {
animation: none;
}
}
```
#### NeonText.tsx
```tsx
import type { ReactNode } from 'react';
import './neon-text.css';
interface Props {
children: ReactNode;
tone?: 'cyan' | 'pink' | 'yellow' | 'green';
flicker?: boolean;
as?: 'p' | 'span' | 'h1' | 'h2' | 'h3';
className?: string;
}
export function NeonText({ children, tone = 'cyan', flicker = false, as: Tag = 'p', className = '' }: Props) {
const classes = [
'cy-neon',
tone !== 'cyan' && `cy-neon--${tone}`,
flicker && 'cy-neon--flicker',
className,
].filter(Boolean).join(' ');
return {children} ;
}
```
---
### Typography / Terminal Prompt
URL: https://cyberdeck-ui.netlify.app/components/typography#terminal-prompt
A mono label with a prompt arrow and a blinking cursor.
React usage:
```tsx
Hi, I'm
```
#### terminal-prompt.html
```html
Hi, I'm
```
#### terminal-prompt.css
```css
.cy-prompt {
margin: 0;
color: var(--cy-cyan);
font-family: var(--cy-font-mono);
font-size: 1rem;
letter-spacing: 0.3em;
text-transform: uppercase;
}
.cy-prompt::before {
content: '> ';
}
.cy-prompt::after {
content: '_';
margin-left: 2px;
animation: cy-prompt-blink 1s steps(1) infinite;
}
@keyframes cy-prompt-blink {
50% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.cy-prompt::after {
animation: none;
}
}
```
#### TerminalPrompt.tsx
```tsx
import type { ReactNode } from 'react';
import './terminal-prompt.css';
interface Props {
children: ReactNode;
className?: string;
}
export function TerminalPrompt({ children, className = '' }: Props) {
return {children}
;
}
```
---
### Typography / Section Heading
URL: https://cyberdeck-ui.netlify.app/components/typography#section-heading
Numbered section title with a glowing divider and subtitle. Glitches on hover.
React usage:
```tsx
```
#### section-heading.html
```html
// 01
Projects
Things I've built and launched
```
#### section-heading.css
```css
.cy-section-heading {
text-align: center;
}
.cy-section-heading__index {
display: block;
margin-bottom: 0.75rem;
color: var(--cy-pink);
font-family: var(--cy-font-mono);
font-size: 0.875rem;
letter-spacing: 0.3em;
}
.cy-section-heading__title {
position: relative;
display: inline-block;
margin: 0 0 1rem;
color: #fff;
font-family: var(--cy-font-display);
font-size: clamp(1.75rem, 5vw, 2.25rem);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
text-shadow: var(--cy-glow-cyan);
}
/* Glitches on hover */
.cy-section-heading__title::before,
.cy-section-heading__title::after {
content: attr(data-text);
position: absolute;
inset: 0;
pointer-events: none;
mix-blend-mode: screen;
clip-path: inset(0 0 100% 0);
}
.cy-section-heading__title::before { color: var(--cy-cyan); }
.cy-section-heading__title::after { color: var(--cy-pink); }
.cy-section-heading__title:hover::before {
animation: cy-sh-glitch 0.6s steps(2) infinite;
}
.cy-section-heading__title:hover::after {
animation: cy-sh-glitch 0.6s steps(2) infinite reverse;
}
@keyframes cy-sh-glitch {
0% { clip-path: inset(10% 0 60% 0); transform: translate(-4px, 0); }
33% { clip-path: inset(50% 0 25% 0); transform: translate(4px, 0); }
66% { clip-path: inset(75% 0 5% 0); transform: translate(-2px, 0); }
100% { clip-path: inset(30% 0 50% 0); transform: translate(3px, 0); }
}
.cy-section-heading__divider {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.cy-section-heading__divider span {
width: 4rem;
height: 1px;
background: linear-gradient(to right, transparent, rgba(0, 240, 255, 0.7));
}
.cy-section-heading__divider span:last-child {
transform: scaleX(-1);
}
.cy-section-heading__divider i {
width: 6px;
height: 6px;
background: var(--cy-yellow);
transform: rotate(45deg);
}
.cy-section-heading__subtitle {
margin: 0;
color: var(--cy-muted);
font-family: var(--cy-font-mono);
font-size: 1rem;
}
@media (prefers-reduced-motion: reduce) {
.cy-section-heading__title::before,
.cy-section-heading__title::after {
display: none;
}
}
```
#### SectionHeading.tsx
```tsx
import './section-heading.css';
interface Props {
title: string;
subtitle?: string;
/** Shown above the title as "// 01". */
index?: number;
as?: 'h1' | 'h2' | 'h3';
}
export function SectionHeading({ title, subtitle, index, as: Heading = 'h2' }: Props) {
return (
{index !== undefined && (
{`// ${String(index).padStart(2, '0')}`}
)}
{title}
{subtitle &&
{subtitle}
}
);
}
```
---
### Typography / Decrypt Text
URL: https://cyberdeck-ui.netlify.app/components/typography#decrypt-text
Scrambled glyphs resolve into the real text on load and on hover. Screen readers get the text immediately.
React usage:
```tsx
```
#### decrypt-text.html
```html
Access granted. Welcome back, operator.
```
#### decrypt-text.css
```css
.cy-decrypt {
margin: 0;
color: var(--cy-green);
font-family: var(--cy-font-mono);
font-size: 1.25rem;
letter-spacing: 0.08em;
cursor: default;
}
.cy-decrypt__noise {
color: var(--cy-pink);
opacity: 0.8;
}
.cy-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
```
#### decrypt-text.js
```js
// Scrambles [data-cy-decrypt] text into place on load and on hover.
// Screen readers get the final text straight away; the animation is aria-hidden.
(() => {
const GLYPHS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&@$*<>/';
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const randomGlyph = () => GLYPHS[Math.floor(Math.random() * GLYPHS.length)];
document.querySelectorAll('[data-cy-decrypt]').forEach((el) => {
const text = el.textContent.trim();
const label = document.createElement('span');
const visual = document.createElement('span');
const revealed = document.createElement('span');
const noise = document.createElement('span');
label.className = 'cy-sr-only';
label.textContent = text;
visual.setAttribute('aria-hidden', 'true');
noise.className = 'cy-decrypt__noise';
visual.append(revealed, noise);
el.replaceChildren(label, visual);
let timer;
const play = () => {
clearInterval(timer);
if (reduceMotion) {
revealed.textContent = text;
noise.textContent = '';
return;
}
let count = 0;
timer = setInterval(() => {
count += 1;
revealed.textContent = text.slice(0, count);
noise.textContent = [...text.slice(count)].map((c) => (c === ' ' ? ' ' : randomGlyph())).join('');
if (count >= text.length) clearInterval(timer);
}, 35);
};
el.addEventListener('mouseenter', play);
play();
});
})();
```
#### DecryptText.tsx
```tsx
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import './decrypt-text.css';
const GLYPHS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#%&@$*<>/';
const scramble = (text: string) =>
[...text].map((c) => (c === ' ' ? ' ' : GLYPHS[Math.floor(Math.random() * GLYPHS.length)])).join('');
interface Props {
text: string;
/** Milliseconds per revealed character. */
speed?: number;
className?: string;
}
export function DecryptText({ text, speed = 35, className = '' }: Props) {
const [count, setCount] = useState(0);
const [noise, setNoise] = useState('');
const timer = useRef | undefined>(undefined);
const play = useCallback(() => {
clearInterval(timer.current);
// With reduced motion, the first tick reveals everything.
const step = window.matchMedia('(prefers-reduced-motion: reduce)').matches ? text.length : 1;
let next = 0;
timer.current = setInterval(() => {
next = Math.min(text.length, next + step);
setCount(next);
setNoise(scramble(text.slice(next)));
if (next >= text.length) clearInterval(timer.current);
}, speed);
}, [text, speed]);
useEffect(() => {
play();
return () => clearInterval(timer.current);
}, [play]);
return (
{text}
{text.slice(0, count)}
{noise}
);
}
```
---
### Typography / Typewriter
URL: https://cyberdeck-ui.netlify.app/components/typography#typewriter
Types out a list of phrases one by one, deleting and looping. Screen readers get the full text once.
React usage:
```tsx
const phrases = ['Booting neural link…', 'Bypassing ICE…', 'Access granted.'];
```
#### typewriter.html
```html
Booting neural link… Bypassing ICE… Access granted.
```
#### typewriter.css
```css
.cy-typewriter {
min-height: 1.5em;
margin: 0;
color: var(--cy-green);
font-family: var(--cy-font-mono);
font-size: 1.35rem;
letter-spacing: 0.05em;
text-shadow: 0 0 8px rgba(5, 255, 161, 0.5);
}
.cy-typewriter__text {
white-space: pre;
}
.cy-typewriter__caret {
display: inline-block;
width: 0.6em;
height: 1.1em;
margin-left: 2px;
vertical-align: text-bottom;
background: currentColor;
box-shadow: 0 0 8px rgba(5, 255, 161, 0.8);
animation: cy-typewriter-blink 1s steps(1) infinite;
}
/* The full text for screen readers; the animated copy is hidden from them. */
.cy-typewriter__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@keyframes cy-typewriter-blink {
50% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.cy-typewriter__caret {
animation: none;
}
}
```
#### typewriter.js
```js
// Types each phrase, pauses, deletes it and moves on to the next, forever.
// With reduced motion, the first phrase is shown as is.
document.querySelectorAll('[data-cy-typewriter]').forEach((root) => {
const phrases = JSON.parse(root.dataset.cyTypewriter);
const output = root.querySelector('.cy-typewriter__text');
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
output.textContent = phrases[0];
return;
}
let phrase = 0;
let length = 0;
let deleting = false;
const tick = () => {
const text = phrases[phrase];
length += deleting ? -1 : 1;
output.textContent = text.slice(0, length);
let delay = deleting ? 30 : 70;
if (!deleting && length >= text.length) {
deleting = true;
delay = 1600;
} else if (deleting && length <= 0) {
deleting = false;
phrase = (phrase + 1) % phrases.length;
delay = 400;
}
setTimeout(tick, delay);
};
tick();
});
```
#### Typewriter.tsx
```tsx
'use client';
import { useEffect, useState } from 'react';
import './typewriter.css';
interface Props {
/** Define the array outside the component (or memoize it) so the animation doesn't restart on every render. */
phrases: string[];
/** Milliseconds per typed character. */
typeSpeed?: number;
/** Milliseconds per deleted character. */
deleteSpeed?: number;
/** Milliseconds a finished phrase stays on screen. */
pause?: number;
as?: 'p' | 'span' | 'h1' | 'h2' | 'h3';
}
export function Typewriter({ phrases, typeSpeed = 70, deleteSpeed = 30, pause = 1600, as: Tag = 'p' }: Props) {
const [text, setText] = useState('');
useEffect(() => {
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let phrase = 0;
let length = 0;
let deleting = false;
let timer = 0;
const tick = () => {
if (reducedMotion) {
setText(phrases[0] ?? '');
return;
}
const current = phrases[phrase] ?? '';
length += deleting ? -1 : 1;
setText(current.slice(0, length));
let delay = deleting ? deleteSpeed : typeSpeed;
if (!deleting && length >= current.length) {
deleting = true;
delay = pause;
} else if (deleting && length <= 0) {
deleting = false;
phrase = (phrase + 1) % phrases.length;
delay = 400;
}
timer = window.setTimeout(tick, delay);
};
timer = window.setTimeout(tick, 0);
return () => window.clearTimeout(timer);
}, [phrases, typeSpeed, deleteSpeed, pause]);
return (
{phrases.join(' ')}
{text}
);
}
```
---
### Typography / Ticker
URL: https://cyberdeck-ui.netlify.app/components/typography#ticker
A scrolling news ticker with a live label. Pauses on hover and stops for reduced motion.
React usage:
```tsx
```
#### ticker.html
```html
Live
Grid sector 7 back online
Rain advisory for the lower district
Transit line 3 delayed 12 min
New firmware 4.2.1 available
Market index +2.4%
Grid sector 7 back online
Rain advisory for the lower district
Transit line 3 delayed 12 min
New firmware 4.2.1 available
Market index +2.4%
```
#### ticker.css
```css
.cy-ticker {
--cy-ticker-duration: 30s;
display: flex;
width: 100%;
overflow: hidden;
border-block: 1px solid rgba(252, 238, 10, 0.35);
background: var(--cy-surface);
font-family: var(--cy-font-mono);
font-size: 0.9rem;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.cy-ticker__label {
position: relative;
z-index: 1;
display: flex;
flex-shrink: 0;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1.5rem 0.6rem 1rem;
background: var(--cy-yellow);
color: #000;
clip-path: polygon(0 0, 100% 0, calc(100% - 10px) 100%, 0 100%);
}
.cy-ticker__label::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--cy-pink);
animation: cy-ticker-pulse 1s ease-in-out infinite;
}
.cy-ticker__viewport {
display: flex;
overflow: hidden;
-webkit-mask-image: linear-gradient(90deg, transparent, #000 3rem, #000 calc(100% - 3rem), transparent);
mask-image: linear-gradient(90deg, transparent, #000 3rem, #000 calc(100% - 3rem), transparent);
}
.cy-ticker__track {
display: flex;
flex-shrink: 0;
margin: 0;
padding: 0;
list-style: none;
animation: cy-ticker-scroll var(--cy-ticker-duration) linear infinite;
}
.cy-ticker__track li {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 0.6rem 0 0.6rem 1.5rem;
color: var(--cy-text);
white-space: nowrap;
}
.cy-ticker__track li::before {
content: '◆';
color: var(--cy-pink);
font-size: 0.6em;
}
.cy-ticker:hover .cy-ticker__track,
.cy-ticker:focus-within .cy-ticker__track {
animation-play-state: paused;
}
@keyframes cy-ticker-scroll {
to { transform: translateX(-100%); }
}
@keyframes cy-ticker-pulse {
50% { opacity: 0.3; }
}
@media (prefers-reduced-motion: reduce) {
.cy-ticker__track,
.cy-ticker__label::before {
animation: none;
}
.cy-ticker__track[aria-hidden='true'] {
display: none;
}
.cy-ticker__viewport {
overflow-x: auto;
}
}
```
#### Ticker.tsx
```tsx
import type { CSSProperties, ReactNode } from 'react';
import './ticker.css';
type TickerStyle = CSSProperties & { '--cy-ticker-duration': string };
interface Props {
/** Use enough items to be wider than the ticker, or the loop shows a gap. */
items: ReactNode[];
label?: string;
/** Seconds for one full loop. */
duration?: number;
}
export function Ticker({ items, label = 'Live', duration = 30 }: Props) {
const style: TickerStyle = { '--cy-ticker-duration': `${duration}s` };
// The second copy makes the loop seamless and is hidden from screen readers.
const track = (copy: boolean) => (
{items.map((item, index) => {item} )}
);
return (
{label}
{track(false)}
{track(true)}
);
}
```
---
### Cards / HUD Card
URL: https://cyberdeck-ui.netlify.app/components/cards#hud-card
A project card with corner brackets that grow and glow on hover, plus tech tags.
React usage:
```tsx
An encrypted messaging relay.
```
#### hud-card.html
```html
Ghostline
An encrypted messaging relay that routes every packet through three anonymous nodes before it reaches the grid.
```
#### hud-card.css
```css
.cy-hud-card {
--cy-hud: var(--cy-cyan);
position: relative;
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 22rem;
padding: 1.5rem;
border: 1px solid rgba(0, 240, 255, 0.15);
background: rgba(13, 10, 23, 0.8);
font-family: var(--cy-font-body);
transition: border-color 0.3s, transform 0.3s, box-shadow 0.3s;
}
/* HUD corner brackets */
.cy-hud-card::before,
.cy-hud-card::after {
content: '';
position: absolute;
width: 14px;
height: 14px;
opacity: 0.5;
pointer-events: none;
transition: all 0.2s ease;
}
.cy-hud-card::before {
top: -1px;
left: -1px;
border-top: 2px solid var(--cy-hud);
border-left: 2px solid var(--cy-hud);
}
.cy-hud-card::after {
right: -1px;
bottom: -1px;
border-right: 2px solid var(--cy-hud);
border-bottom: 2px solid var(--cy-hud);
}
.cy-hud-card:hover,
.cy-hud-card:focus-within {
border-color: rgba(0, 240, 255, 0.5);
transform: translateY(-4px);
box-shadow: 0 0 30px rgba(0, 240, 255, 0.12);
}
.cy-hud-card:hover::before,
.cy-hud-card:hover::after,
.cy-hud-card:focus-within::before,
.cy-hud-card:focus-within::after {
width: 24px;
height: 24px;
opacity: 1;
filter: drop-shadow(0 0 4px var(--cy-hud));
}
.cy-hud-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.cy-hud-card__index {
color: var(--cy-pink);
font-family: var(--cy-font-mono);
font-size: 0.875rem;
letter-spacing: 0.1em;
}
.cy-hud-card__link {
color: rgba(255, 255, 255, 0.4);
transition: color 0.2s;
}
.cy-hud-card__link:hover,
.cy-hud-card__link:focus-visible {
color: var(--cy-cyan);
}
.cy-hud-card__title {
margin: 0;
color: #fff;
font-family: var(--cy-font-display);
font-size: 1.125rem;
letter-spacing: 0.08em;
text-transform: uppercase;
transition: color 0.2s, text-shadow 0.2s;
}
.cy-hud-card:hover .cy-hud-card__title {
color: var(--cy-cyan);
text-shadow: var(--cy-glow-cyan);
}
.cy-hud-card__body {
flex: 1;
margin: 0;
color: var(--cy-muted);
font-size: 1rem;
line-height: 1.6;
}
.cy-hud-card__tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.cy-hud-card__tags li {
padding: 0.25rem 0.6rem;
border: 1px solid rgba(0, 240, 255, 0.25);
background: rgba(0, 240, 255, 0.05);
color: rgba(0, 240, 255, 0.8);
font-family: var(--cy-font-mono);
font-size: 0.75rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
```
#### HudCard.tsx
```tsx
import type { ReactNode } from 'react';
import './hud-card.css';
interface Props {
title: string;
children: ReactNode;
/** Shown top-left as "[ 01 ]". */
index?: number;
tags?: string[];
href?: string;
linkLabel?: string;
}
export function HudCard({ title, children, index, tags = [], href, linkLabel = 'Open' }: Props) {
return (
{index !== undefined && `[ ${String(index).padStart(2, '0')} ]`}
{href && (
)}
{title}
{children}
{tags.length > 0 && (
{tags.map((tag) => {tag} )}
)}
);
}
```
---
### Cards / Skill Tile
URL: https://cyberdeck-ui.netlify.app/components/cards#skill-tile
Small icon tiles with a per-tile accent colour, for skills or features.
React usage:
```tsx
} />
} accent="var(--cy-pink)" />
```
#### skill-tile.html
```html
Frontend
Databases
DevOps
AI
```
#### skill-tile.css
```css
.cy-skill-grid {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin: 0;
padding: 0;
list-style: none;
}
.cy-skill-tile {
--cy-hud: var(--cy-cyan); /* accent; override per tile */
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
width: 6.5rem;
padding: 1rem 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(13, 10, 23, 0.8);
color: var(--cy-hud);
transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s;
}
.cy-skill-tile::before,
.cy-skill-tile::after {
content: '';
position: absolute;
width: 10px;
height: 10px;
opacity: 0.5;
transition: all 0.2s ease;
}
.cy-skill-tile::before {
top: -1px;
left: -1px;
border-top: 2px solid var(--cy-hud);
border-left: 2px solid var(--cy-hud);
}
.cy-skill-tile::after {
right: -1px;
bottom: -1px;
border-right: 2px solid var(--cy-hud);
border-bottom: 2px solid var(--cy-hud);
}
.cy-skill-tile span {
color: var(--cy-muted);
font-family: var(--cy-font-mono);
font-size: 0.75rem;
text-align: center;
transition: color 0.2s;
}
.cy-skill-tile:hover {
border-color: var(--cy-hud);
transform: translateY(-4px);
box-shadow: 0 0 24px color-mix(in srgb, var(--cy-hud) 25%, transparent);
}
.cy-skill-tile:hover::before,
.cy-skill-tile:hover::after {
width: 18px;
height: 18px;
opacity: 1;
}
.cy-skill-tile:hover span {
color: #fff;
}
```
#### SkillTile.tsx
```tsx
import type { CSSProperties, ReactNode } from 'react';
import './skill-tile.css';
type AccentStyle = CSSProperties & { '--cy-hud'?: string };
interface Props {
label: string;
/** Icon, e.g. an inline SVG with aria-hidden="true". */
icon: ReactNode;
/** Any CSS colour; defaults to cyan. */
accent?: string;
}
/** Render inside . */
export function SkillTile({ label, icon, accent }: Props) {
const style: AccentStyle = accent ? { '--cy-hud': accent } : {};
return (
{icon}
{label}
);
}
```
---
### Cards / Stat Card
URL: https://cyberdeck-ui.netlify.app/components/cards#stat-card
A big glowing number with a segmented meter and a caption.
React usage:
```tsx
```
#### stat-card.html
```html
Neural sync
72%
▲ 4.2% since last boot
```
#### stat-card.css
```css
.cy-stat {
--cy-stat: 0; /* 0–100 */
display: flex;
flex-direction: column;
gap: 0.6rem;
width: 100%;
max-width: 18rem;
padding: 1.25rem 1.5rem;
border: 1px solid var(--cy-border);
border-left: 3px solid var(--cy-cyan);
background: var(--cy-surface);
}
.cy-stat__label {
color: var(--cy-muted);
font-family: var(--cy-font-mono);
font-size: 0.8rem;
letter-spacing: 0.25em;
text-transform: uppercase;
}
.cy-stat__value {
color: #fff;
font-family: var(--cy-font-display);
font-size: 2.5rem;
font-weight: 900;
line-height: 1;
text-shadow: var(--cy-glow-cyan);
}
.cy-stat__value small {
margin-left: 0.15em;
color: var(--cy-cyan);
font-size: 0.5em;
}
.cy-stat__meter {
position: relative;
height: 10px;
background: repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.08) 0 8px, transparent 8px 11px);
}
.cy-stat__meter span {
position: absolute;
inset: 0 auto 0 0;
width: calc(var(--cy-stat) * 1%);
background: linear-gradient(90deg, var(--cy-cyan), var(--cy-pink));
-webkit-mask: repeating-linear-gradient(90deg, #000 0 8px, transparent 8px 11px);
mask: repeating-linear-gradient(90deg, #000 0 8px, transparent 8px 11px);
filter: drop-shadow(0 0 4px rgba(0, 240, 255, 0.6));
}
.cy-stat__delta {
color: var(--cy-green);
font-family: var(--cy-font-mono);
font-size: 0.8rem;
}
```
#### StatCard.tsx
```tsx
import type { CSSProperties } from 'react';
import './stat-card.css';
type StatStyle = CSSProperties & { '--cy-stat': number };
interface Props {
label: string;
value: number;
max?: number;
unit?: string;
/** Small caption under the meter, e.g. "▲ 4.2% since last boot". */
delta?: string;
}
export function StatCard({ label, value, max = 100, unit, delta }: Props) {
const percent = Math.min(100, Math.max(0, (value / max) * 100));
const style: StatStyle = { '--cy-stat': percent };
return (
{label}
{value}
{unit && {unit} }
{delta &&
{delta} }
);
}
```
---
### Cards / Profile Card
URL: https://cyberdeck-ui.netlify.app/components/cards#profile-card
A hexagonal avatar with a status dot, name, role and a row of stats.
React usage:
```tsx
```
#### profile-card.html
```html
KV
Online
Kira Vance
Netrunner · Level 42
Rep 9.8k
Gigs 142
Credits 88k
```
#### profile-card.css
```css
.cy-profile {
display: grid;
grid-template-columns: auto 1fr;
gap: 1rem 1.25rem;
align-items: center;
width: 100%;
max-width: 22rem;
padding: 1.5rem;
border: 1px solid var(--cy-border);
background: linear-gradient(135deg, rgba(185, 103, 255, 0.12), transparent 60%), var(--cy-surface);
font-family: var(--cy-font-body);
}
.cy-profile__avatar {
position: relative;
display: grid;
place-items: center;
width: 4.5rem;
height: 4.5rem;
/* Gradient "border": the hexagon behind the inner hexagon */
background: linear-gradient(135deg, var(--cy-cyan), var(--cy-pink));
clip-path: polygon(25% 3%, 75% 3%, 100% 50%, 75% 97%, 25% 97%, 0 50%);
}
.cy-profile__initials {
display: grid;
place-items: center;
width: calc(100% - 6px);
height: calc(100% - 6px);
background: var(--cy-surface-2);
color: #fff;
font-family: var(--cy-font-display);
font-size: 1.25rem;
font-weight: 700;
clip-path: inherit;
}
.cy-profile__status {
position: absolute;
right: 14%;
bottom: 10%;
width: 0.7rem;
height: 0.7rem;
border: 2px solid var(--cy-surface);
border-radius: 50%;
background: var(--cy-dim);
}
.cy-profile__status--online { background: var(--cy-green); box-shadow: 0 0 8px var(--cy-green); }
.cy-profile__status--away { background: var(--cy-yellow); }
.cy-profile__name {
margin: 0;
color: #fff;
font-family: var(--cy-font-display);
font-size: 1.1rem;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.cy-profile__role {
margin: 0.25rem 0 0;
color: var(--cy-purple);
font-family: var(--cy-font-mono);
font-size: 0.85rem;
}
.cy-profile__stats {
display: grid;
grid-column: 1 / -1;
grid-template-columns: repeat(3, 1fr);
margin: 0;
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding-top: 1rem;
text-align: center;
}
.cy-profile__stats div + div {
border-left: 1px solid rgba(255, 255, 255, 0.08);
}
.cy-profile__stats dt {
color: var(--cy-dim);
font-family: var(--cy-font-mono);
font-size: 0.7rem;
letter-spacing: 0.2em;
text-transform: uppercase;
}
.cy-profile__stats dd {
margin: 0.25rem 0 0;
color: var(--cy-cyan);
font-family: var(--cy-font-display);
font-size: 1.1rem;
font-weight: 700;
}
.cy-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
```
#### ProfileCard.tsx
```tsx
import './profile-card.css';
type Status = 'online' | 'away' | 'offline';
interface Props {
name: string;
role: string;
initials: string;
status?: Status;
stats?: { label: string; value: string }[];
}
const STATUS_LABELS: Record = {
online: 'Online',
away: 'Away',
offline: 'Offline',
};
export function ProfileCard({ name, role, initials, status = 'offline', stats = [] }: Props) {
return (
{initials}
{STATUS_LABELS[status]}
{stats.length > 0 && (
{stats.map((stat) => (
{stat.label}
{stat.value}
))}
)}
);
}
```
---
### Cards / Pricing Card
URL: https://cyberdeck-ui.netlify.app/components/cards#pricing-card
A pricing tier with a feature list and call to action. The featured variant switches to yellow.
React usage:
```tsx
```
#### pricing-card.html
```html
Street
€0 / forever
Everything you need to get on the grid.
3 active projects
Community support
Public uplinks
Start free
Most popular
Runner
€19 / month
For crews who ship every night.
Unlimited projects
Encrypted uplinks
Priority support
Custom domains
Jack in
```
#### pricing-card.css
```css
.cy-pricing {
position: relative;
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
max-width: 19rem;
padding: 1.75rem 1.5rem;
border: 1px solid var(--cy-border);
background: linear-gradient(180deg, rgba(0, 240, 255, 0.05), transparent 40%), var(--cy-surface);
color: var(--cy-text);
font-family: var(--cy-font-body);
clip-path: polygon(0 0, calc(100% - var(--cy-cut)) 0, 100% var(--cy-cut), 100% 100%, var(--cy-cut) 100%, 0 calc(100% - var(--cy-cut)));
}
.cy-pricing--featured {
border-color: rgba(252, 238, 10, 0.6);
background: linear-gradient(180deg, rgba(252, 238, 10, 0.08), transparent 45%), var(--cy-surface);
}
.cy-pricing__badge {
align-self: flex-start;
padding: 0.25rem 0.6rem;
background: var(--cy-yellow);
color: #000;
font-family: var(--cy-font-mono);
font-size: 0.7rem;
letter-spacing: 0.15em;
text-transform: uppercase;
}
.cy-pricing__tier {
margin: 0;
color: var(--cy-cyan);
font-family: var(--cy-font-display);
font-size: 1rem;
letter-spacing: 0.2em;
text-transform: uppercase;
}
.cy-pricing--featured .cy-pricing__tier {
color: var(--cy-yellow);
}
.cy-pricing__price {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin: 0;
}
.cy-pricing__amount {
color: #fff;
font-family: var(--cy-font-display);
font-size: 2.6rem;
font-weight: 900;
line-height: 1;
}
.cy-pricing__period {
color: var(--cy-dim);
font-family: var(--cy-font-mono);
font-size: 0.85rem;
}
.cy-pricing__desc {
margin: 0;
color: var(--cy-muted);
font-size: 1.05rem;
line-height: 1.5;
}
.cy-pricing__features {
display: grid;
gap: 0.55rem;
margin: 0;
padding: 1rem 0 0;
border-top: 1px dashed rgba(0, 240, 255, 0.2);
font-size: 1.05rem;
list-style: none;
}
.cy-pricing__features li {
display: flex;
gap: 0.6rem;
}
.cy-pricing__features li::before {
content: '▸';
color: var(--cy-green);
}
.cy-pricing__cta {
margin-top: auto;
padding: 0.8rem 1rem;
border: 1px solid var(--cy-cyan);
color: var(--cy-cyan);
font-family: var(--cy-font-mono);
font-size: 0.9rem;
letter-spacing: 0.2em;
text-align: center;
text-decoration: none;
text-transform: uppercase;
transition: background-color 0.15s, border-color 0.15s, color 0.15s;
}
.cy-pricing__cta:hover,
.cy-pricing__cta:focus-visible {
outline: none;
background: var(--cy-cyan);
color: #000;
}
.cy-pricing--featured .cy-pricing__cta {
border-color: var(--cy-yellow);
background: var(--cy-yellow);
color: #000;
}
.cy-pricing--featured .cy-pricing__cta:hover,
.cy-pricing--featured .cy-pricing__cta:focus-visible {
border-color: var(--cy-cyan);
background: var(--cy-cyan);
}
```
#### PricingCard.tsx
```tsx
import './pricing-card.css';
interface Props {
tier: string;
/** Pre-formatted, e.g. "€19". */
price: string;
period?: string;
description: string;
features: string[];
cta: { label: string; href: string };
/** Highlights the card in yellow. */
featured?: boolean;
badge?: string;
}
export function PricingCard({ tier, price, period, description, features, cta, featured = false, badge }: Props) {
return (
{badge && {badge} }
{tier}
{price}
{period && {period} }
{description}
{features.map((feature) => {feature} )}
{cta.label}
);
}
```
---
### Forms / Text Input
URL: https://cyberdeck-ui.netlify.app/components/forms#text-input
A labelled mono input with a cyan focus glow and an error state.
React usage:
```tsx
```
#### text-input.html
```html
Handle
```
#### text-input.css
```css
.cy-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.cy-label {
color: var(--cy-pink);
font-family: var(--cy-font-mono);
font-size: 0.8rem;
letter-spacing: 0.25em;
text-transform: uppercase;
}
.cy-field__error {
margin: 0;
color: var(--cy-pink);
font-family: var(--cy-font-mono);
font-size: 0.8rem;
}
.cy-input {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid rgba(0, 240, 255, 0.2);
border-radius: 0;
background: rgba(7, 5, 13, 0.8);
color: #fff;
font-family: var(--cy-font-mono);
font-size: 1rem;
transition: border-color 0.2s, box-shadow 0.2s;
}
.cy-input::placeholder {
color: #4b5563;
}
.cy-input:focus {
outline: none;
border-color: var(--cy-cyan);
box-shadow: 0 0 16px rgba(0, 240, 255, 0.25);
}
.cy-input[aria-invalid='true'] {
border-color: var(--cy-pink);
}
.cy-input[aria-invalid='true']:focus {
box-shadow: 0 0 16px rgba(255, 42, 109, 0.3);
}
```
#### TextInput.tsx
```tsx
import type { InputHTMLAttributes } from 'react';
import './text-input.css';
interface Props extends InputHTMLAttributes {
id: string;
label: string;
/** Shown under the field and announced to screen readers. */
error?: string;
}
export function TextInput({ id, label, error, className = '', type = 'text', ...props }: Props) {
const errorId = `${id}-error`;
return (
{label}
{error &&
{error}
}
);
}
```
---
### Forms / Textarea
URL: https://cyberdeck-ui.netlify.app/components/forms#textarea
The multi-line companion to the text input.
React usage:
```tsx
```
#### textarea.html
```html
Message
```
#### textarea.css
```css
.cy-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.cy-label {
color: var(--cy-pink);
font-family: var(--cy-font-mono);
font-size: 0.8rem;
letter-spacing: 0.25em;
text-transform: uppercase;
}
.cy-field__error {
margin: 0;
color: var(--cy-pink);
font-family: var(--cy-font-mono);
font-size: 0.8rem;
}
.cy-textarea {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid rgba(0, 240, 255, 0.2);
border-radius: 0;
background: rgba(7, 5, 13, 0.8);
color: #fff;
font-family: var(--cy-font-mono);
font-size: 1rem;
transition: border-color 0.2s, box-shadow 0.2s;
min-height: 7rem;
resize: vertical;
}
.cy-textarea::placeholder {
color: #4b5563;
}
.cy-textarea:focus {
outline: none;
border-color: var(--cy-cyan);
box-shadow: 0 0 16px rgba(0, 240, 255, 0.25);
}
```
#### Textarea.tsx
```tsx
import type { TextareaHTMLAttributes } from 'react';
import './textarea.css';
interface Props extends TextareaHTMLAttributes {
id: string;
label: string;
}
export function Textarea({ id, label, className = '', rows = 4, ...props }: Props) {
return (
{label}
);
}
```
---
### Forms / Search Input
URL: https://cyberdeck-ui.netlify.app/components/forms#search-input
A search field with an icon and a keyboard shortcut ("/" by default) that focuses it from anywhere.
React usage:
```tsx
setQuery(event.target.value)} />
```
#### search-input.html
```html
```
#### search-input.css
```css
.cy-search {
width: 100%;
max-width: 28rem;
}
.cy-search__label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.cy-search__field {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0 0.9rem;
border: 1px solid rgba(0, 240, 255, 0.3);
background: rgba(7, 5, 13, 0.8);
transition: border-color 0.2s, box-shadow 0.2s;
}
.cy-search__field:focus-within {
border-color: var(--cy-cyan);
box-shadow: 0 0 0 1px var(--cy-cyan), var(--cy-glow-cyan);
}
.cy-search__icon {
flex-shrink: 0;
width: 1.1rem;
height: 1.1rem;
fill: none;
stroke: var(--cy-cyan);
stroke-width: 2;
stroke-linecap: square;
}
.cy-search__input {
flex: 1;
min-width: 0;
padding: 0.85rem 0;
border: 0;
outline: 0;
background: transparent;
color: #fff;
font-family: var(--cy-font-mono);
font-size: 1rem;
letter-spacing: 0.05em;
}
.cy-search__input::placeholder {
color: #4b5563;
}
.cy-search__input::-webkit-search-cancel-button {
filter: hue-rotate(150deg) saturate(3);
cursor: pointer;
}
.cy-search__kbd {
padding: 0.1rem 0.45rem;
border: 1px solid rgba(0, 240, 255, 0.35);
border-bottom-width: 2px;
color: var(--cy-cyan);
font-family: var(--cy-font-mono);
font-size: 0.8rem;
transition: opacity 0.15s;
}
/* Hide the shortcut hint while typing. */
.cy-search__field:focus-within .cy-search__kbd,
.cy-search__input:not(:placeholder-shown) + .cy-search__kbd {
opacity: 0;
}
```
#### search-input.js
```js
// Press the shortcut key (e.g. "/") anywhere outside a text field to jump to the search input.
// Escape leaves the field.
document.querySelectorAll('[data-cy-search-shortcut]').forEach((input) => {
const shortcut = input.dataset.cySearchShortcut;
document.addEventListener('keydown', (event) => {
const target = event.target;
const typing =
target instanceof HTMLElement && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName));
if (event.key !== shortcut || typing || event.metaKey || event.ctrlKey || event.altKey) return;
event.preventDefault();
input.focus();
});
input.addEventListener('keydown', (event) => {
if (event.key === 'Escape') input.blur();
});
});
```
#### SearchInput.tsx
```tsx
'use client';
import { useEffect, useId, useRef } from 'react';
import type { InputHTMLAttributes } from 'react';
import './search-input.css';
interface Props extends Omit, 'type'> {
/** Accessible label, also used as the placeholder by default. */
label: string;
/** Key that focuses the input from anywhere on the page. Pass null to turn it off. */
shortcut?: string | null;
}
export function SearchInput({ label, shortcut = '/', id, placeholder = label, onKeyDown, ...props }: Props) {
const generatedId = useId();
const inputId = id ?? generatedId;
const input = useRef(null);
useEffect(() => {
if (!shortcut) return;
const onShortcut = (event: KeyboardEvent) => {
const target = event.target;
const typing =
target instanceof HTMLElement && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName));
if (event.key !== shortcut || typing || event.metaKey || event.ctrlKey || event.altKey) return;
event.preventDefault();
input.current?.focus();
};
document.addEventListener('keydown', onShortcut);
return () => document.removeEventListener('keydown', onShortcut);
}, [shortcut]);
return (
);
}
```
---
### Forms / Toggle Switch
URL: https://cyberdeck-ui.netlify.app/components/forms#toggle-switch
An on/off switch built on a real checkbox with role="switch".
React usage:
```tsx
```
#### toggle-switch.html
```html
Stealth mode
Auto-reboot
```
#### toggle-switch.css
```css
.cy-toggle {
display: inline-flex;
align-items: center;
gap: 0.75rem;
color: var(--cy-text);
font-family: var(--cy-font-mono);
font-size: 0.9rem;
letter-spacing: 0.1em;
text-transform: uppercase;
cursor: pointer;
}
/* Visually hidden but still focusable and announced */
.cy-toggle__input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.cy-toggle__track {
position: relative;
width: 3.25rem;
height: 1.6rem;
border: 1px solid rgba(0, 240, 255, 0.4);
background: var(--cy-surface);
transition: border-color 0.2s, background-color 0.2s, box-shadow 0.2s;
}
.cy-toggle__thumb {
position: absolute;
top: 3px;
left: 3px;
width: calc(1.6rem - 8px);
height: calc(1.6rem - 8px);
background: var(--cy-dim);
clip-path: polygon(0 0, 70% 0, 100% 30%, 100% 100%, 30% 100%, 0 70%);
transition: transform 0.2s var(--cy-ease), background-color 0.2s;
}
.cy-toggle__input:checked + .cy-toggle__track {
border-color: var(--cy-cyan);
background: rgba(0, 240, 255, 0.12);
box-shadow: 0 0 14px rgba(0, 240, 255, 0.35);
}
.cy-toggle__input:checked + .cy-toggle__track .cy-toggle__thumb {
background: var(--cy-cyan);
transform: translateX(1.65rem);
}
.cy-toggle__input:focus-visible + .cy-toggle__track {
outline: 2px solid var(--cy-yellow);
outline-offset: 3px;
}
.cy-toggle__input:disabled + .cy-toggle__track,
.cy-toggle__input:disabled ~ .cy-toggle__label {
opacity: 0.5;
}
```
#### ToggleSwitch.tsx
```tsx
import type { InputHTMLAttributes } from 'react';
import './toggle-switch.css';
interface Props extends Omit, 'type' | 'role'> {
label: string;
}
export function ToggleSwitch({ label, className = '', ...props }: Props) {
return (
{label}
);
}
```
---
### Forms / Checkbox
URL: https://cyberdeck-ui.netlify.app/components/forms#checkbox
A square magenta checkbox with an animated check mark.
React usage:
```tsx
Install cyberware
```
#### checkbox.html
```html
Install cyberware
Share telemetry
```
#### checkbox.css
```css
.cy-check {
display: inline-flex;
align-items: center;
gap: 0.75rem;
color: var(--cy-text);
font-family: var(--cy-font-body);
font-size: 1.05rem;
cursor: pointer;
}
.cy-check__input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.cy-check__box {
position: relative;
flex-shrink: 0;
width: 1.2rem;
height: 1.2rem;
border: 1px solid var(--cy-pink);
background: var(--cy-surface);
transition: background-color 0.15s, box-shadow 0.15s;
}
/* Check mark */
.cy-check__box::after {
content: '';
position: absolute;
top: 1px;
left: 6px;
width: 5px;
height: 10px;
border: solid #000;
border-width: 0 2px 2px 0;
transform: rotate(45deg) scale(0);
transition: transform 0.15s var(--cy-ease);
}
.cy-check__input:checked + .cy-check__box {
background: var(--cy-pink);
box-shadow: 0 0 12px rgba(255, 42, 109, 0.6);
}
.cy-check__input:checked + .cy-check__box::after {
transform: rotate(45deg) scale(1);
}
.cy-check__input:focus-visible + .cy-check__box {
outline: 2px solid var(--cy-yellow);
outline-offset: 3px;
}
```
#### Checkbox.tsx
```tsx
import type { InputHTMLAttributes, ReactNode } from 'react';
import './checkbox.css';
interface Props extends Omit, 'type'> {
children: ReactNode;
}
export function Checkbox({ children, className = '', ...props }: Props) {
return (
{children}
);
}
```
---
### Forms / Radio Group
URL: https://cyberdeck-ui.netlify.app/components/forms#radio-group
A fieldset of selectable rows with diamond markers and optional descriptions.
React usage:
```tsx
```
#### radio-group.html
```html
Choose your class
Runner Fast, quiet, always on the move
Fixer Knows everyone, owes no one
Street medic Locked: complete the tutorial
```
#### radio-group.css
```css
.cy-radio-group {
display: grid;
gap: 0.6rem;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.cy-radio-group legend {
margin-bottom: 0.75rem;
padding: 0;
color: var(--cy-pink);
font-family: var(--cy-font-mono);
font-size: 0.8rem;
letter-spacing: 0.25em;
text-transform: uppercase;
}
.cy-radio {
position: relative;
display: flex;
align-items: center;
gap: 1rem;
padding: 0.85rem 1rem;
border: 1px solid var(--cy-border);
background: var(--cy-surface);
color: var(--cy-text);
font-family: var(--cy-font-body);
cursor: pointer;
transition: border-color 0.15s, background-color 0.15s, box-shadow 0.15s;
}
.cy-radio:hover {
border-color: rgba(0, 240, 255, 0.45);
}
.cy-radio:has(:checked) {
border-color: var(--cy-cyan);
background: rgba(0, 240, 255, 0.06);
box-shadow: inset 3px 0 0 var(--cy-cyan);
}
.cy-radio:has(:disabled) {
cursor: not-allowed;
opacity: 0.45;
}
.cy-radio__input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
/* Diamond marker */
.cy-radio__mark {
position: relative;
flex-shrink: 0;
width: 0.85rem;
height: 0.85rem;
margin-inline: 0.15rem;
border: 1px solid var(--cy-cyan);
transform: rotate(45deg);
transition: box-shadow 0.15s;
}
.cy-radio__mark::after {
content: '';
position: absolute;
inset: 2px;
background: var(--cy-cyan);
transform: scale(0);
transition: transform 0.15s var(--cy-ease);
}
.cy-radio__input:checked + .cy-radio__mark {
box-shadow: 0 0 10px rgba(0, 240, 255, 0.6);
}
.cy-radio__input:checked + .cy-radio__mark::after {
transform: scale(1);
}
.cy-radio__input:focus-visible + .cy-radio__mark {
outline: 2px solid var(--cy-yellow);
outline-offset: 4px;
}
.cy-radio__label {
display: grid;
gap: 0.15rem;
font-size: 1.1rem;
font-weight: 600;
letter-spacing: 0.03em;
}
.cy-radio__label small {
color: var(--cy-dim);
font-size: 0.9rem;
font-weight: 500;
}
```
#### RadioGroup.tsx
```tsx
import type { ReactNode } from 'react';
import './radio-group.css';
export interface RadioOption {
value: T;
label: ReactNode;
description?: ReactNode;
disabled?: boolean;
}
interface Props {
legend: string;
name: string;
options: RadioOption[];
/** Controlled value. Leave undefined and use defaultValue for an uncontrolled group. */
value?: T;
defaultValue?: T;
onChange?: (value: T) => void;
}
export function RadioGroup({ legend, name, options, value, defaultValue, onChange }: Props) {
const controlled = value !== undefined;
return (
{legend}
{options.map((option) => (
onChange?.(option.value)}
/>
{option.label}
{option.description && {option.description} }
))}
);
}
```
---
### Forms / Range Slider
URL: https://cyberdeck-ui.netlify.app/components/forms#range-slider
A native range input with a glowing filled track, a diamond thumb and a live value.
React usage:
```tsx
```
#### range-slider.html
```html
```
#### range-slider.css
```css
.cy-range {
--cy-fill: 50%; /* updated by script from the input value */
width: 100%;
max-width: 22rem;
}
.cy-range__head {
display: flex;
justify-content: space-between;
margin-bottom: 0.75rem;
font-family: var(--cy-font-mono);
font-size: 0.85rem;
letter-spacing: 0.2em;
text-transform: uppercase;
}
.cy-range__head label { color: var(--cy-pink); }
.cy-range__head output { color: var(--cy-yellow); }
.cy-range__input {
width: 100%;
height: 1.5rem;
margin: 0;
background: transparent;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
}
.cy-range__input::-webkit-slider-runnable-track {
height: 4px;
background: linear-gradient(90deg, var(--cy-cyan) var(--cy-fill), rgba(255, 255, 255, 0.12) var(--cy-fill));
box-shadow: 0 0 8px rgba(0, 240, 255, 0.35);
}
.cy-range__input::-moz-range-track {
height: 4px;
background: linear-gradient(90deg, var(--cy-cyan) var(--cy-fill), rgba(255, 255, 255, 0.12) var(--cy-fill));
}
.cy-range__input::-webkit-slider-thumb {
width: 16px;
height: 16px;
margin-top: -6px;
border: 0;
background: var(--cy-yellow);
box-shadow: 0 0 10px rgba(252, 238, 10, 0.7);
transform: rotate(45deg);
-webkit-appearance: none;
appearance: none;
}
.cy-range__input::-moz-range-thumb {
width: 14px;
height: 14px;
border: 0;
border-radius: 0;
background: var(--cy-yellow);
box-shadow: 0 0 10px rgba(252, 238, 10, 0.7);
transform: rotate(45deg);
}
.cy-range__input:focus-visible {
outline: 2px solid var(--cy-yellow);
outline-offset: 4px;
}
```
#### range-slider.js
```js
// Keeps the filled part of the track and the in sync with the value.
document.querySelectorAll('.cy-range').forEach((range) => {
const input = range.querySelector('.cy-range__input');
const output = range.querySelector('output');
const update = () => {
const min = Number(input.min || 0);
const max = Number(input.max || 100);
const percent = ((Number(input.value) - min) / (max - min)) * 100;
range.style.setProperty('--cy-fill', `${percent}%`);
if (output) output.textContent = input.value;
};
input.addEventListener('input', update);
update();
});
```
#### RangeSlider.tsx
```tsx
'use client';
import { useState } from 'react';
import type { CSSProperties } from 'react';
import './range-slider.css';
type RangeStyle = CSSProperties & { '--cy-fill': string };
interface Props {
id: string;
label: string;
min?: number;
max?: number;
defaultValue?: number;
onChange?: (value: number) => void;
}
export function RangeSlider({ id, label, min = 0, max = 100, defaultValue = min, onChange }: Props) {
const [value, setValue] = useState(defaultValue);
const style: RangeStyle = { '--cy-fill': `${((value - min) / (max - min)) * 100}%` };
return (
);
}
```
---
### Forms / Select
URL: https://cyberdeck-ui.netlify.app/components/forms#select
A native select restyled to match the inputs, with a neon chevron.
React usage:
```tsx
```
#### select.html
```html
District
Neon Row
Old Harbor
Skyline
The Stacks
```
#### select.css
```css
.cy-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.cy-label {
color: var(--cy-pink);
font-family: var(--cy-font-mono);
font-size: 0.8rem;
letter-spacing: 0.25em;
text-transform: uppercase;
}
.cy-field__error {
margin: 0;
color: var(--cy-pink);
font-family: var(--cy-font-mono);
font-size: 0.8rem;
}
.cy-select {
position: relative;
}
.cy-select select {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid rgba(0, 240, 255, 0.2);
border-radius: 0;
background: rgba(7, 5, 13, 0.8);
color: #fff;
font-family: var(--cy-font-mono);
font-size: 1rem;
transition: border-color 0.2s, box-shadow 0.2s;
padding-right: 2.75rem;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
}
.cy-select select:focus {
outline: none;
border-color: var(--cy-cyan);
box-shadow: 0 0 16px rgba(0, 240, 255, 0.25);
}
.cy-select option {
background: var(--cy-surface);
}
/* Chevron */
.cy-select::after {
content: '';
position: absolute;
top: 50%;
right: 1.1rem;
width: 7px;
height: 7px;
border: solid var(--cy-cyan);
border-width: 0 2px 2px 0;
transform: translateY(-70%) rotate(45deg);
pointer-events: none;
}
```
#### Select.tsx
```tsx
import type { SelectHTMLAttributes } from 'react';
import './select.css';
interface Option {
value: string;
label: string;
}
interface Props extends SelectHTMLAttributes {
id: string;
label: string;
options: Option[];
}
export function Select({ id, label, options, ...props }: Props) {
return (
{label}
{options.map((option) => (
{option.label}
))}
);
}
```
---
### Navigation / Navbar
URL: https://cyberdeck-ui.netlify.app/components/navigation#navbar
A blurred top bar with a brand, mono links and a right-hand slot (here a language switcher).
React usage:
```tsx
PT}
/>
```
#### navbar.html
```html
```
#### navbar.css
```css
.cy-navbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem 2rem;
padding: 1rem 1.5rem;
border-bottom: 1px solid rgba(0, 240, 255, 0.2);
background: rgba(7, 5, 13, 0.8);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
}
.cy-navbar__brand {
color: #fff;
font-family: var(--cy-font-display);
font-size: 1.1rem;
font-weight: 900;
letter-spacing: 0.1em;
text-decoration: none;
text-transform: uppercase;
}
.cy-navbar__brand span {
color: var(--cy-pink);
}
.cy-navbar__links {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 2rem;
margin: 0;
padding: 0;
list-style: none;
}
.cy-navbar__links a,
.cy-navbar__lang a {
color: #d1d5db;
font-family: var(--cy-font-mono);
font-size: 0.85rem;
letter-spacing: 0.2em;
text-decoration: none;
text-transform: uppercase;
transition: color 0.2s, text-shadow 0.2s;
}
.cy-navbar__links a:hover,
.cy-navbar__links a:focus-visible,
.cy-navbar__links a[aria-current='page'],
.cy-navbar__lang a:hover {
color: var(--cy-cyan);
text-shadow: var(--cy-glow-cyan);
}
.cy-navbar__lang {
display: flex;
align-items: center;
gap: 0.5rem;
padding-left: 2rem;
border-left: 1px solid rgba(0, 240, 255, 0.2);
font-family: var(--cy-font-mono);
font-size: 0.85rem;
letter-spacing: 0.2em;
}
.cy-navbar__lang [aria-current] { color: var(--cy-yellow); }
.cy-navbar__lang i { color: rgba(255, 42, 109, 0.7); font-style: normal; }
```
#### Navbar.tsx
```tsx
import type { ReactNode } from 'react';
import './navbar.css';
interface NavLink {
label: string;
href: string;
current?: boolean;
}
interface Props {
brand: string;
/** The last word is highlighted in pink when brandAccent is set. */
brandAccent?: string;
brandHref?: string;
links: NavLink[];
/** Right-hand slot, e.g. a language switcher. */
actions?: ReactNode;
}
export function Navbar({ brand, brandAccent, brandHref = '/', links, actions }: Props) {
return (
);
}
```
---
### Navigation / Segmented Control
URL: https://cyberdeck-ui.netlify.app/components/navigation#segmented-control
Radio buttons styled as a compact switcher, e.g. for language or view options.
React usage:
```tsx
```
#### segmented-control.html
```html
Language
EN
PT
JP
```
#### segmented-control.css
```css
.cy-segmented {
display: inline-flex;
margin: 0;
padding: 0;
border: 1px solid rgba(0, 240, 255, 0.3);
}
.cy-segmented label {
cursor: pointer;
}
.cy-segmented label + label {
border-left: 1px solid rgba(255, 42, 109, 0.4);
}
.cy-segmented input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.cy-segmented span {
display: block;
padding: 0.45rem 1rem;
color: var(--cy-muted);
font-family: var(--cy-font-mono);
font-size: 0.85rem;
letter-spacing: 0.2em;
text-transform: uppercase;
transition: color 0.2s, background-color 0.2s;
}
.cy-segmented label:hover span {
color: var(--cy-cyan);
}
.cy-segmented input:checked + span {
background: rgba(0, 240, 255, 0.12);
color: var(--cy-yellow);
}
.cy-segmented input:focus-visible + span {
outline: 2px solid var(--cy-yellow);
outline-offset: -2px;
}
.cy-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
```
#### SegmentedControl.tsx
```tsx
import './segmented-control.css';
interface Option {
value: string;
label: string;
}
interface Props {
/** Accessible name for the group. */
label: string;
name: string;
options: Option[];
value?: string;
defaultValue?: string;
onChange?: (value: string) => void;
}
export function SegmentedControl({ label, name, options, value, defaultValue, onChange }: Props) {
return (
{label}
{options.map((option) => (
onChange?.(option.value)}
/>
{option.label}
))}
);
}
```
---
### Navigation / Tabs
URL: https://cyberdeck-ui.netlify.app/components/navigation#tabs
Accessible tabs with a glowing underline, arrow-key navigation and roving focus.
React usage:
```tsx
},
]}
/>
```
#### tabs.html
```html
Status
Logs
Network
All systems nominal. Core temperature at 41°C.
03:12:44 — Intrusion attempt blocked from 10.0.8.12.
Uplink 940 Mbps · Downlink 1.2 Gbps · 3 relays active.
```
#### tabs.css
```css
.cy-tabs {
width: 100%;
max-width: 32rem;
}
.cy-tabs__list {
display: flex;
border-bottom: 1px solid rgba(0, 240, 255, 0.2);
}
.cy-tabs__tab {
position: relative;
padding: 0.75rem 1.25rem;
border: 0;
background: none;
color: var(--cy-muted);
font-family: var(--cy-font-mono);
font-size: 0.85rem;
letter-spacing: 0.2em;
text-transform: uppercase;
cursor: pointer;
transition: color 0.2s, text-shadow 0.2s;
}
.cy-tabs__tab::after {
content: '';
position: absolute;
right: 0;
bottom: -1px;
left: 0;
height: 2px;
background: var(--cy-cyan);
box-shadow: 0 0 8px var(--cy-cyan);
transform: scaleX(0);
transition: transform 0.25s var(--cy-ease);
}
.cy-tabs__tab:hover {
color: var(--cy-text);
}
.cy-tabs__tab[aria-selected='true'] {
color: var(--cy-cyan);
text-shadow: var(--cy-glow-cyan);
}
.cy-tabs__tab[aria-selected='true']::after {
transform: scaleX(1);
}
.cy-tabs__tab:focus-visible {
outline: 2px solid var(--cy-yellow);
outline-offset: -2px;
}
.cy-tabs__panel {
padding: 1.25rem 0.25rem;
color: var(--cy-text);
font-family: var(--cy-font-body);
font-size: 1.05rem;
line-height: 1.6;
}
```
#### tabs.js
```js
// Accessible tabs: click to select, arrow keys/Home/End to move between tabs.
document.querySelectorAll('[data-cy-tabs]').forEach((root) => {
const tabs = [...root.querySelectorAll('[role="tab"]')];
const select = (tab) => {
tabs.forEach((t) => {
const selected = t === tab;
t.setAttribute('aria-selected', String(selected));
t.tabIndex = selected ? 0 : -1;
root.querySelector(`#${t.getAttribute('aria-controls')}`).hidden = !selected;
});
tab.focus();
};
tabs.forEach((tab, i) => {
tab.addEventListener('click', () => select(tab));
tab.addEventListener('keydown', (event) => {
const moves = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: tabs.length - 1 };
if (!(event.key in moves)) return;
event.preventDefault();
select(tabs[(moves[event.key] + tabs.length) % tabs.length]);
});
});
});
```
#### Tabs.tsx
```tsx
'use client';
import { useId, useRef, useState } from 'react';
import type { KeyboardEvent, ReactNode } from 'react';
import './tabs.css';
interface Tab {
id: string;
label: string;
content: ReactNode;
}
interface Props {
/** Accessible name for the tab list. */
label: string;
tabs: Tab[];
defaultTab?: string;
}
export function Tabs({ label, tabs, defaultTab = tabs[0]?.id }: Props) {
const [selected, setSelected] = useState(defaultTab);
const buttons = useRef<(HTMLButtonElement | null)[]>([]);
const baseId = useId();
const select = (index: number) => {
const next = (index + tabs.length) % tabs.length;
setSelected(tabs[next].id);
buttons.current[next]?.focus();
};
const onKeyDown = (event: KeyboardEvent, index: number) => {
const moves: Record = { ArrowRight: index + 1, ArrowLeft: index - 1, Home: 0, End: tabs.length - 1 };
if (!(event.key in moves)) return;
event.preventDefault();
select(moves[event.key]);
};
return (
{tabs.map((tab, i) => (
{ buttons.current[i] = el; }}
className='cy-tabs__tab'
role='tab'
id={`${baseId}-tab-${tab.id}`}
aria-controls={`${baseId}-panel-${tab.id}`}
aria-selected={selected === tab.id}
tabIndex={selected === tab.id ? 0 : -1}
onClick={() => select(i)}
onKeyDown={(event) => onKeyDown(event, i)}
>
{tab.label}
))}
{tabs.map((tab) => (
{tab.content}
))}
);
}
```
---
### Navigation / Breadcrumbs
URL: https://cyberdeck-ui.netlify.app/components/navigation#breadcrumbs
A mono breadcrumb trail with // separators.
React usage:
```tsx
```
#### breadcrumbs.html
```html
Home
Blog
Ghost in the shell script
```
#### breadcrumbs.css
```css
.cy-breadcrumbs ol {
display: flex;
flex-wrap: wrap;
align-items: center;
margin: 0;
padding: 0;
list-style: none;
font-family: var(--cy-font-mono);
font-size: 0.85rem;
letter-spacing: 0.15em;
text-transform: uppercase;
}
.cy-breadcrumbs li + li::before {
content: '//';
margin: 0 0.75rem;
color: rgba(255, 42, 109, 0.7);
}
.cy-breadcrumbs a {
color: var(--cy-muted);
text-decoration: none;
transition: color 0.2s;
}
.cy-breadcrumbs a:hover,
.cy-breadcrumbs a:focus-visible {
color: var(--cy-cyan);
}
.cy-breadcrumbs [aria-current='page'] {
color: var(--cy-yellow);
}
```
#### Breadcrumbs.tsx
```tsx
import './breadcrumbs.css';
interface Crumb {
label: string;
/** Omit for the current page. */
href?: string;
}
export function Breadcrumbs({ items }: { items: Crumb[] }) {
return (
{items.map((item) => (
{item.href ? {item.label} : {item.label} }
))}
);
}
```
---
### Navigation / Pagination
URL: https://cyberdeck-ui.netlify.app/components/navigation#pagination
Page links with a clipped highlight on the current page and automatic gaps.
React usage:
```tsx
`/blog?page=${page}`} />
```
#### pagination.html
```html
```
#### pagination.css
```css
.cy-pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
font-family: var(--cy-font-mono);
}
.cy-pagination__item {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.5rem;
height: 2.5rem;
padding: 0 0.5rem;
border: 1px solid rgba(0, 240, 255, 0.25);
color: var(--cy-muted);
font-size: 0.95rem;
text-decoration: none;
transition: color 0.2s, border-color 0.2s, box-shadow 0.2s;
}
.cy-pagination__item:hover,
.cy-pagination__item:focus-visible {
outline: none;
border-color: var(--cy-cyan);
color: var(--cy-cyan);
box-shadow: 0 0 12px rgba(0, 240, 255, 0.3);
}
.cy-pagination__item[aria-current='page'] {
border-color: var(--cy-yellow);
background: var(--cy-yellow);
color: #000;
clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 8px 100%, 0 calc(100% - 8px));
}
.cy-pagination__gap {
padding: 0 0.25rem;
color: var(--cy-dim);
}
```
#### Pagination.tsx
```tsx
import './pagination.css';
interface Props {
page: number;
totalPages: number;
hrefFor: (page: number) => string;
/** Pages shown either side of the current one. */
siblings?: number;
}
// 1 … 4 5 [6] 7 8 … 20
const pageList = (page: number, total: number, siblings: number): (number | 'gap')[] => {
const pages = new Set([1, total]);
for (let p = page - siblings; p <= page + siblings; p++) {
if (p >= 1 && p <= total) pages.add(p);
}
const sorted = [...pages].sort((a, b) => a - b);
return sorted.flatMap((p, i) => (i > 0 && p - sorted[i - 1] > 1 ? ['gap' as const, p] : [p]));
};
export function Pagination({ page, totalPages, hrefFor, siblings = 1 }: Props) {
return (
{page > 1 && (
‹
)}
{pageList(page, totalPages, siblings).map((item, i) =>
item === 'gap' ? (
…
) : (
{item}
)
)}
{page < totalPages && (
›
)}
);
}
```
---
### Navigation / Stepper
URL: https://cyberdeck-ui.netlify.app/components/navigation#stepper
Progress through a multi-step flow: completed, current and upcoming steps, with aria-current.
React usage:
```tsx
```
#### stepper.html
```html
Identity (completed)
Implants (completed)
Loadout
Deploy
```
#### stepper.css
```css
.cy-stepper {
display: flex;
width: 100%;
max-width: 40rem;
margin: 0;
padding: 0;
list-style: none;
counter-reset: cy-step;
}
.cy-stepper__step {
position: relative;
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
gap: 0.6rem;
min-width: 7rem;
padding-inline: 0.5rem;
color: var(--cy-dim);
font-family: var(--cy-font-mono);
font-size: 0.8rem;
letter-spacing: 0.12em;
text-align: center;
text-transform: uppercase;
counter-increment: cy-step;
}
/* Connector to the previous step */
.cy-stepper__step + .cy-stepper__step::before {
content: '';
position: absolute;
top: calc(1.1rem - 1px);
right: calc(50% + 1.6rem);
left: calc(-50% + 1.6rem);
height: 2px;
background: repeating-linear-gradient(90deg, var(--cy-dim) 0 6px, transparent 6px 10px);
opacity: 0.6;
}
.cy-stepper__step:is([data-state='complete'], [data-state='current'])::before {
background: var(--cy-green);
box-shadow: 0 0 6px rgba(5, 255, 161, 0.6);
opacity: 1;
}
.cy-stepper__marker {
display: grid;
place-items: center;
width: 2.2rem;
height: 2.2rem;
border: 1px solid currentColor;
background: var(--cy-bg);
font-size: 0.85rem;
transition: box-shadow 0.2s, background-color 0.2s;
}
.cy-stepper__marker::before {
content: counter(cy-step, decimal-leading-zero);
}
.cy-stepper__step[data-state='complete'] {
color: var(--cy-green);
}
.cy-stepper__step[data-state='complete'] .cy-stepper__marker::before {
content: '✓';
}
.cy-stepper__step[data-state='current'] {
color: var(--cy-cyan);
}
.cy-stepper__step[data-state='current'] .cy-stepper__marker {
background: #0a1a24;
box-shadow: var(--cy-glow-cyan);
}
.cy-stepper__step[data-state='current'] .cy-stepper__label {
color: #fff;
}
.cy-stepper__sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
```
#### Stepper.tsx
```tsx
import './stepper.css';
interface Props {
steps: string[];
/** Zero-based index of the current step. Earlier steps show as completed. */
current: number;
label?: string;
}
export function Stepper({ steps, current, label = 'Progress' }: Props) {
return (
{steps.map((step, index) => {
const state = index < current ? 'complete' : index === current ? 'current' : undefined;
return (
{step}
{state === 'complete' && (completed) }
);
})}
);
}
```
---
### Feedback / Alert
URL: https://cyberdeck-ui.netlify.app/components/feedback#alert
Info, success, warning and danger messages with the right ARIA role for each.
React usage:
```tsx
Secure channel to the grid is online.
ICE breached on node 7.
```
#### alert.html
```html
SYS
Uplink established
Secure channel to the grid is online.
WARN
Heat rising
Core temperature is above 80°C. Throttling in 30 seconds.
ERR
Intrusion detected
ICE breached on node 7. Isolating the subnet.
```
#### alert.css
```css
.cy-alert {
--cy-alert: 0 240 255; /* RGB channels; set by the tone modifiers */
display: grid;
grid-template-columns: auto 1fr;
gap: 1rem;
width: 100%;
max-width: 32rem;
padding: 1rem 1.25rem;
border: 1px solid rgb(var(--cy-alert) / 0.35);
border-left: 3px solid rgb(var(--cy-alert));
background: rgb(var(--cy-alert) / 0.06);
font-family: var(--cy-font-body);
}
.cy-alert--success { --cy-alert: 5 255 161; }
.cy-alert--warning { --cy-alert: 252 238 10; }
.cy-alert--danger { --cy-alert: 255 42 109; }
.cy-alert__tag {
align-self: start;
padding: 0.15rem 0.45rem;
background: rgb(var(--cy-alert));
color: #000;
font-family: var(--cy-font-mono);
font-size: 0.75rem;
letter-spacing: 0.1em;
}
.cy-alert__title {
margin: 0;
color: rgb(var(--cy-alert));
font-family: var(--cy-font-display);
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.cy-alert__body {
margin: 0.35rem 0 0;
color: var(--cy-muted);
font-size: 1rem;
line-height: 1.5;
}
```
#### Alert.tsx
```tsx
import type { ReactNode } from 'react';
import './alert.css';
type Tone = 'info' | 'success' | 'warning' | 'danger';
interface Props {
title: string;
children?: ReactNode;
tone?: Tone;
/** Short label in the badge, e.g. "SYS" or "ERR". */
tag?: string;
}
const DEFAULT_TAGS: Record = {
info: 'SYS',
success: 'OK',
warning: 'WARN',
danger: 'ERR',
};
export function Alert({ title, children, tone = 'info', tag = DEFAULT_TAGS[tone] }: Props) {
// Errors interrupt screen readers; everything else is announced politely.
const role = tone === 'danger' ? 'alert' : 'status';
return (
{tag}
{title}
{children &&
{children}
}
);
}
```
---
### Feedback / Progress Bar
URL: https://cyberdeck-ui.netlify.app/components/feedback#progress-bar
A segmented, glowing progress bar, plus an indeterminate scanning variant.
React usage:
```tsx
```
#### progress-bar.html
```html
```
#### progress-bar.css
```css
.cy-progress {
width: 100%;
max-width: 28rem;
}
.cy-progress__head {
display: flex;
justify-content: space-between;
margin-bottom: 0.6rem;
color: var(--cy-muted);
font-family: var(--cy-font-mono);
font-size: 0.85rem;
letter-spacing: 0.15em;
text-transform: uppercase;
}
.cy-progress__head span:last-child:not(:first-child) {
color: var(--cy-cyan);
}
.cy-progress__track {
--cy-progress: 0; /* 0–100 */
position: relative;
height: 14px;
overflow: hidden;
border: 1px solid rgba(0, 240, 255, 0.3);
background: var(--cy-surface);
}
.cy-progress__track span {
position: absolute;
inset: 2px auto 2px 2px;
width: calc((100% - 4px) * var(--cy-progress) / 100);
background: repeating-linear-gradient(90deg, var(--cy-cyan) 0 10px, transparent 10px 13px);
filter: drop-shadow(0 0 4px rgba(0, 240, 255, 0.8));
transition: width 0.3s var(--cy-ease);
}
.cy-progress__track--indeterminate span {
width: 30%;
background: repeating-linear-gradient(90deg, var(--cy-pink) 0 10px, transparent 10px 13px);
filter: drop-shadow(0 0 4px rgba(255, 42, 109, 0.8));
animation: cy-progress-scan 1.4s var(--cy-ease) infinite;
}
@keyframes cy-progress-scan {
from { transform: translateX(-100%); }
to { transform: translateX(340%); }
}
@media (prefers-reduced-motion: reduce) {
.cy-progress__track--indeterminate span {
width: calc(100% - 4px);
opacity: 0.5;
animation: none;
}
}
```
#### ProgressBar.tsx
```tsx
'use client';
import { useId } from 'react';
import type { CSSProperties } from 'react';
import './progress-bar.css';
type ProgressStyle = CSSProperties & { '--cy-progress': number };
interface Props {
label: string;
/** Omit for an indeterminate (unknown duration) bar. */
value?: number;
max?: number;
}
export function ProgressBar({ label, value, max = 100 }: Props) {
const labelId = useId();
const indeterminate = value === undefined;
const percent = indeterminate ? 0 : Math.round(Math.min(100, Math.max(0, (value / max) * 100)));
const style: ProgressStyle = { '--cy-progress': percent };
return (
{label}
{!indeterminate && {percent}% }
);
}
```
---
### Feedback / Loader
URL: https://cyberdeck-ui.netlify.app/components/feedback#loader
An equaliser-style loader with animated dots.
React usage:
```tsx
```
#### loader.html
```html
Booting
```
#### loader.css
```css
.cy-loader {
display: inline-flex;
align-items: center;
gap: 1rem;
}
.cy-loader__bars {
display: flex;
align-items: flex-end;
gap: 4px;
height: 2rem;
}
.cy-loader__bars i {
width: 5px;
height: 100%;
background: linear-gradient(to top, var(--cy-pink), var(--cy-cyan));
box-shadow: 0 0 8px rgba(0, 240, 255, 0.6);
transform-origin: bottom;
animation: cy-loader-bar 0.9s ease-in-out infinite;
}
.cy-loader__bars i:nth-child(2) { animation-delay: 0.1s; }
.cy-loader__bars i:nth-child(3) { animation-delay: 0.2s; }
.cy-loader__bars i:nth-child(4) { animation-delay: 0.3s; }
.cy-loader__bars i:nth-child(5) { animation-delay: 0.4s; }
@keyframes cy-loader-bar {
0%, 100% { transform: scaleY(0.25); }
50% { transform: scaleY(1); }
}
.cy-loader__text {
color: var(--cy-cyan);
font-family: var(--cy-font-mono);
font-size: 0.9rem;
letter-spacing: 0.3em;
text-transform: uppercase;
}
.cy-loader__text::after {
content: '';
display: inline-block;
width: 3ch;
text-align: left;
animation: cy-loader-dots 1.2s steps(1) infinite;
}
@keyframes cy-loader-dots {
0% { content: ''; }
25% { content: '.'; }
50% { content: '..'; }
75% { content: '...'; }
}
@media (prefers-reduced-motion: reduce) {
.cy-loader__bars i,
.cy-loader__text::after {
animation: none;
}
.cy-loader__text::after {
content: '...';
}
}
```
#### Loader.tsx
```tsx
import './loader.css';
export function Loader({ label = 'Loading' }: { label?: string }) {
return (
{label}
);
}
```
---
### Feedback / Skeleton
URL: https://cyberdeck-ui.netlify.app/components/feedback#skeleton
Placeholder lines and blocks with a scanning shimmer while content loads.
React usage:
```tsx
```
#### skeleton.html
```html
```
#### skeleton.css
```css
.cy-skeleton-group {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 0.7rem 1rem;
width: 100%;
max-width: 26rem;
padding: 1.25rem;
border: 1px solid var(--cy-border);
background: var(--cy-surface);
}
/* Lines span the full width, except the two next to a leading block. */
.cy-skeleton-group > .cy-skeleton {
grid-column: 1 / -1;
}
.cy-skeleton-group > .cy-skeleton--block {
grid-row: span 2;
grid-column: 1;
}
.cy-skeleton-group > .cy-skeleton--block + .cy-skeleton,
.cy-skeleton-group > .cy-skeleton--block + .cy-skeleton + .cy-skeleton {
grid-column: 2;
}
.cy-skeleton {
display: block;
width: var(--cy-skeleton-width, 100%);
height: var(--cy-skeleton-height, 0.75rem);
background:
linear-gradient(90deg, transparent 40%, rgba(0, 240, 255, 0.22) 50%, transparent 60%) 0 0 / 250% 100%,
var(--cy-surface-2);
animation: cy-skeleton-scan 1.6s linear infinite;
}
.cy-skeleton--block {
width: var(--cy-skeleton-width, 3.5rem);
height: var(--cy-skeleton-height, 3.5rem);
clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 10px 100%, 0 calc(100% - 10px));
}
@keyframes cy-skeleton-scan {
from { background-position: 100% 0, 0 0; }
to { background-position: 0 0, 0 0; }
}
@media (prefers-reduced-motion: reduce) {
.cy-skeleton {
animation: none;
}
}
```
#### Skeleton.tsx
```tsx
import type { CSSProperties, ReactNode } from 'react';
import './skeleton.css';
type SkeletonStyle = CSSProperties & { '--cy-skeleton-width'?: string; '--cy-skeleton-height'?: string };
interface SkeletonProps {
/** Any CSS width, e.g. "60%" or "8rem". */
width?: string;
height?: string;
/** block: a square with cut corners, for avatars and thumbnails. */
shape?: 'line' | 'block';
}
export function Skeleton({ width, height, shape = 'line' }: SkeletonProps) {
const style: SkeletonStyle = { '--cy-skeleton-width': width, '--cy-skeleton-height': height };
return ;
}
interface GroupProps {
/** Announced to screen readers while loading. */
label: string;
children: ReactNode;
}
export function SkeletonGroup({ label, children }: GroupProps) {
return (
{children}
);
}
```
---
### Feedback / Toast
URL: https://cyberdeck-ui.netlify.app/components/feedback#toast
Slide-in notifications that dismiss themselves. The React version is a provider plus a useToast() hook.
React usage:
```tsx
// Once, near the root:
{children}
// Anywhere inside:
const toast = useToast();
toast('Data shard uploaded to node 7');
```
#### toast.html
```html
Upload shard
```
#### toast.css
```css
.cy-toast-trigger {
padding: 0.7rem 1.4rem;
border: 1px solid rgba(0, 240, 255, 0.6);
background: transparent;
color: var(--cy-cyan);
font-family: var(--cy-font-mono);
font-size: 0.9rem;
letter-spacing: 0.15em;
text-transform: uppercase;
cursor: pointer;
}
.cy-toast-trigger:hover,
.cy-toast-trigger:focus-visible {
outline: none;
background: rgba(0, 240, 255, 0.1);
}
.cy-toast-region {
position: fixed;
right: 1.25rem;
bottom: 1.25rem;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 0.75rem;
width: min(22rem, calc(100vw - 2.5rem));
}
.cy-toast {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.75rem;
padding: 0.85rem 1rem;
border: 1px solid rgba(5, 255, 161, 0.4);
border-left: 3px solid var(--cy-green);
background: var(--cy-surface);
box-shadow: 0 0 24px rgba(5, 255, 161, 0.15);
color: var(--cy-text);
font-family: var(--cy-font-body);
font-size: 1rem;
clip-path: polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 0 100%);
animation: cy-toast-in 0.3s var(--cy-ease);
}
.cy-toast__tag {
color: var(--cy-green);
font-family: var(--cy-font-mono);
font-size: 0.75rem;
letter-spacing: 0.1em;
}
.cy-toast__close {
padding: 0.25rem;
border: 0;
background: none;
color: var(--cy-dim);
font-size: 1rem;
line-height: 1;
cursor: pointer;
}
.cy-toast__close:hover,
.cy-toast__close:focus-visible {
color: var(--cy-pink);
}
@keyframes cy-toast-in {
from { opacity: 0; transform: translateX(24px); }
to { opacity: 1; transform: translateX(0); }
}
@media (prefers-reduced-motion: reduce) {
.cy-toast {
animation: none;
}
}
```
#### toast.js
```js
// Shows a toast for any [data-cy-toast] button; toasts dismiss after 4 seconds.
(() => {
const region = document.querySelector('.cy-toast-region');
const showToast = (message) => {
const toast = document.createElement('div');
const tag = document.createElement('span');
const text = document.createElement('span');
const close = document.createElement('button');
toast.className = 'cy-toast';
tag.className = 'cy-toast__tag';
tag.textContent = '[OK]';
text.textContent = message;
close.className = 'cy-toast__close';
close.type = 'button';
close.setAttribute('aria-label', 'Dismiss');
close.textContent = '✕';
const dismiss = () => toast.remove();
close.addEventListener('click', dismiss);
setTimeout(dismiss, 4000);
toast.append(tag, text, close);
region.append(toast);
};
document.querySelectorAll('[data-cy-toast]').forEach((button) => {
button.addEventListener('click', () => showToast(button.dataset.cyToast));
});
})();
```
#### Toast.tsx
```tsx
'use client';
import { createContext, useCallback, useContext, useState } from 'react';
import type { ReactNode } from 'react';
import './toast.css';
interface Toast {
id: number;
message: string;
}
const ToastContext = createContext<((message: string) => void) | null>(null);
let nextId = 0;
/** Wrap your app (or a section) once; then call useToast() anywhere inside. */
export function ToastProvider({ children, duration = 4000 }: { children: ReactNode; duration?: number }) {
const [toasts, setToasts] = useState([]);
const dismiss = useCallback((id: number) => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, []);
const show = useCallback((message: string) => {
const id = nextId++;
setToasts((current) => [...current, { id, message }]);
setTimeout(() => dismiss(id), duration);
}, [dismiss, duration]);
return (
{children}
{toasts.map((toast) => (
[OK]
{toast.message}
dismiss(toast.id)}>✕
))}
);
}
export function useToast() {
const show = useContext(ToastContext);
if (!show) throw new Error('useToast must be used inside ');
return show;
}
```
---
### Feedback / Tooltip
URL: https://cyberdeck-ui.netlify.app/components/feedback#tooltip
A CSS-only tooltip that also opens on keyboard focus, linked with aria-describedby.
React usage:
```tsx
```
#### tooltip.html
```html
Encryption
AES-256 · keys rotated hourly
```
#### tooltip.css
```css
.cy-tooltip {
position: relative;
display: inline-block;
}
.cy-tooltip__trigger {
padding: 0.25rem 0;
border: 0;
border-bottom: 1px dashed var(--cy-cyan);
background: none;
color: var(--cy-cyan);
font-family: var(--cy-font-mono);
font-size: 1rem;
letter-spacing: 0.1em;
text-transform: uppercase;
cursor: help;
}
.cy-tooltip__bubble {
position: absolute;
bottom: calc(100% + 12px);
left: 50%;
z-index: 10;
padding: 0.5rem 0.75rem;
border: 1px solid var(--cy-pink);
background: var(--cy-surface);
box-shadow: 0 0 16px rgba(255, 42, 109, 0.3);
color: var(--cy-text);
font-family: var(--cy-font-mono);
font-size: 0.8rem;
white-space: nowrap;
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translate(-50%, 4px);
transition: opacity 0.15s, transform 0.15s, visibility 0.15s;
}
/* Arrow */
.cy-tooltip__bubble::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
width: 8px;
height: 8px;
border-right: 1px solid var(--cy-pink);
border-bottom: 1px solid var(--cy-pink);
background: var(--cy-surface);
transform: translate(-50%, -4px) rotate(45deg);
}
.cy-tooltip:hover .cy-tooltip__bubble,
.cy-tooltip:focus-within .cy-tooltip__bubble {
opacity: 1;
visibility: visible;
transform: translate(-50%, 0);
}
```
#### Tooltip.tsx
```tsx
'use client';
import { useId } from 'react';
import './tooltip.css';
interface Props {
/** The trigger's text. */
label: string;
/** What the tooltip says. */
content: string;
}
export function Tooltip({ label, content }: Props) {
const id = useId();
return (
{label}
{content}
);
}
```
---
### Data Display / Chip List
URL: https://cyberdeck-ui.netlify.app/components/data-display#chip-list
Small mono tags for technologies or categories, in cyan or pink.
React usage:
```tsx
```
#### chip-list.html
```html
Ruby on Rails
React
TypeScript
PostgreSQL
```
#### chip-list.css
```css
.cy-chips {
--cy-chip: 0 240 255; /* RGB channels */
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.cy-chips--pink { --cy-chip: 255 42 109; }
.cy-chips li {
padding: 0.25rem 0.65rem;
border: 1px solid rgb(var(--cy-chip) / 0.3);
background: rgb(var(--cy-chip) / 0.05);
color: rgb(var(--cy-chip) / 0.9);
font-family: var(--cy-font-mono);
font-size: 0.75rem;
letter-spacing: 0.05em;
text-transform: uppercase;
transition: background-color 0.2s, border-color 0.2s;
}
.cy-chips li:hover {
border-color: rgb(var(--cy-chip) / 0.6);
background: rgb(var(--cy-chip) / 0.15);
}
```
#### ChipList.tsx
```tsx
import './chip-list.css';
interface Props {
items: string[];
tone?: 'cyan' | 'pink';
}
export function ChipList({ items, tone = 'cyan' }: Props) {
if (items.length === 0) return null;
return (
{items.map((item) => {item} )}
);
}
```
---
### Data Display / Timeline
URL: https://cyberdeck-ui.netlify.app/components/data-display#timeline
A vertical timeline with diamond markers. The current entry pulses.
React usage:
```tsx
```
#### timeline.html
```html
2025 — Present
Senior Engineer @ Neotek Systems
Remote
Leading the platform team behind a real-time city grid monitor.
2021 — 2025
Software Engineer @ Halcyon Labs
Lisbon
Built the payments core and its end-to-end test suite.
```
#### timeline.css
```css
.cy-timeline {
margin: 0;
padding: 0;
list-style: none;
font-family: var(--cy-font-body);
}
.cy-timeline__item {
position: relative;
display: grid;
grid-template-columns: 1.5rem 1fr;
gap: 1.25rem;
padding-bottom: 2.5rem;
}
/* Connecting line */
.cy-timeline__item:not(:last-child)::before {
content: '';
position: absolute;
top: 1.4rem;
bottom: 0.25rem;
left: calc(0.75rem - 0.5px);
width: 1px;
background: linear-gradient(to bottom, rgba(0, 240, 255, 0.5), rgba(0, 240, 255, 0.05));
}
.cy-timeline__marker {
position: relative;
justify-self: center;
width: 12px;
height: 12px;
margin-top: 0.35rem;
border: 2px solid rgba(0, 240, 255, 0.7);
background: var(--cy-bg);
transform: rotate(45deg);
}
.cy-timeline__item--current .cy-timeline__marker {
border-color: var(--cy-pink);
background: var(--cy-pink);
box-shadow: 0 0 12px rgba(255, 42, 109, 0.8);
}
.cy-timeline__item--current .cy-timeline__marker::after {
content: '';
position: absolute;
inset: -6px;
background: rgba(255, 42, 109, 0.4);
animation: cy-timeline-ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite;
}
@keyframes cy-timeline-ping {
75%, 100% { opacity: 0; transform: scale(2); }
}
.cy-timeline__period {
color: var(--cy-yellow);
font-family: var(--cy-font-mono);
font-size: 0.875rem;
letter-spacing: 0.15em;
}
.cy-timeline__title {
margin: 0.25rem 0 0;
color: #fff;
font-family: var(--cy-font-display);
font-size: 1.1rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.cy-timeline__title span {
color: var(--cy-cyan);
}
.cy-timeline__meta {
margin: 0.25rem 0 0;
color: var(--cy-dim);
font-family: var(--cy-font-mono);
font-size: 0.85rem;
}
.cy-timeline__body {
margin: 0.75rem 0 0;
color: #d1d5db;
font-size: 1.05rem;
line-height: 1.6;
}
@media (prefers-reduced-motion: reduce) {
.cy-timeline__item--current .cy-timeline__marker::after {
animation: none;
}
}
```
#### Timeline.tsx
```tsx
import './timeline.css';
interface Entry {
period: string;
title: string;
org?: string;
meta?: string;
description?: string;
}
interface Props {
/** Newest first; the first entry is highlighted as current. */
items: Entry[];
highlightFirst?: boolean;
}
export function Timeline({ items, highlightFirst = true }: Props) {
return (
{items.map((item, i) => (
{item.period}
{item.title} {item.org && @ {item.org} }
{item.meta &&
{item.meta}
}
{item.description &&
{item.description}
}
))}
);
}
```
---
### Data Display / Terminal Window
URL: https://cyberdeck-ui.netlify.app/components/data-display#terminal-window
A fake terminal for commands and output, with coloured status lines and a blinking cursor.
React usage:
```tsx
```
#### terminal-window.html
```html
root@deck:~
$ ping grid.local
64 bytes from 10.0.0.1: time=0.42 ms
$ decrypt shard.bin
[!] ICE detected — rerouting...
[✓] Access granted
$
```
#### terminal-window.css
```css
.cy-terminal {
width: 100%;
max-width: 34rem;
border: 1px solid rgba(0, 240, 255, 0.3);
background: #05030a;
box-shadow: 0 0 30px rgba(0, 240, 255, 0.08);
}
.cy-terminal__bar {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.6rem 1rem;
border-bottom: 1px solid rgba(0, 240, 255, 0.2);
background: var(--cy-surface-2);
}
.cy-terminal__dots {
display: flex;
gap: 6px;
}
.cy-terminal__dots i {
width: 10px;
height: 10px;
background: var(--cy-pink);
}
.cy-terminal__dots i:nth-child(2) { background: var(--cy-yellow); }
.cy-terminal__dots i:nth-child(3) { background: var(--cy-green); }
.cy-terminal__title {
color: var(--cy-dim);
font-family: var(--cy-font-mono);
font-size: 0.8rem;
}
.cy-terminal__body {
margin: 0;
padding: 1rem 1.25rem;
overflow-x: auto;
color: var(--cy-text);
font-family: var(--cy-font-mono);
font-size: 0.9rem;
line-height: 1.7;
}
.cy-terminal__prompt { color: var(--cy-pink); }
.cy-terminal__ok { color: var(--cy-green); }
.cy-terminal__warn { color: var(--cy-yellow); }
.cy-terminal__error { color: var(--cy-pink); }
.cy-terminal__cursor {
display: inline-block;
width: 0.6em;
height: 1.1em;
background: var(--cy-cyan);
vertical-align: text-bottom;
animation: cy-terminal-blink 1s steps(1) infinite;
}
@keyframes cy-terminal-blink {
50% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.cy-terminal__cursor {
animation: none;
}
}
```
#### TerminalWindow.tsx
```tsx
import { Fragment } from 'react';
import './terminal-window.css';
type LineKind = 'command' | 'output' | 'ok' | 'warn' | 'error';
interface Line {
kind: LineKind;
text: string;
}
interface Props {
title?: string;
lines: Line[];
/** Show a blinking cursor on a final empty prompt. */
cursor?: boolean;
}
const CLASS_FOR: Partial> = {
ok: 'cy-terminal__ok',
warn: 'cy-terminal__warn',
error: 'cy-terminal__error',
};
export function TerminalWindow({ title = 'root@deck:~', lines, cursor = true }: Props) {
return (
{title}
{lines.map((line, i) => (
{line.kind === 'command' && $ }
{line.text}
{'\n'}
))}
{cursor && (
<>
$
>
)}
);
}
```
---
### Data Display / Data Table
URL: https://cyberdeck-ui.netlify.app/components/data-display#data-table
A typed, generic table with a caption, numeric columns and custom cell renderers.
React usage:
```tsx
r.handle}
columns={[
{ key: 'handle', header: 'Handle' },
{ key: 'ping', header: 'Ping', numeric: true, render: (r) => `${r.ping} ms` },
]}
/>
```
#### data-table.html
```html
Active runners
Handle
Role
Status
Ping
nyx Netrunner Online 12 ms
halo_9 Fixer Idle 48 ms
vex Techie Offline —
```
#### data-table.css
```css
.cy-table-wrap {
width: 100%;
overflow-x: auto;
border: 1px solid rgba(0, 240, 255, 0.2);
background: var(--cy-surface);
}
.cy-table {
width: 100%;
border-collapse: collapse;
font-family: var(--cy-font-body);
font-size: 1rem;
}
.cy-table caption {
padding: 0.85rem 1rem;
color: var(--cy-pink);
font-family: var(--cy-font-mono);
font-size: 0.8rem;
letter-spacing: 0.25em;
text-align: left;
text-transform: uppercase;
}
.cy-table th {
padding: 0.7rem 1rem;
border-bottom: 1px solid rgba(0, 240, 255, 0.3);
background: var(--cy-surface-2);
color: var(--cy-cyan);
font-family: var(--cy-font-mono);
font-size: 0.75rem;
font-weight: 400;
letter-spacing: 0.2em;
text-align: left;
text-transform: uppercase;
}
.cy-table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
color: var(--cy-text);
}
.cy-table tbody tr {
transition: background-color 0.15s;
}
.cy-table tbody tr:hover {
background: rgba(0, 240, 255, 0.05);
}
.cy-table tbody tr:last-child td {
border-bottom: 0;
}
.cy-table .cy-table__num {
font-family: var(--cy-font-mono);
text-align: right;
}
.cy-table__tag {
font-family: var(--cy-font-mono);
font-size: 0.8rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.cy-table__tag--ok { color: var(--cy-green); }
.cy-table__tag--warn { color: var(--cy-yellow); }
.cy-table__tag--off { color: var(--cy-dim); }
```
#### DataTable.tsx
```tsx
import type { ReactNode } from 'react';
import './data-table.css';
export interface Column {
key: keyof Row & string;
header: string;
/** Right-aligned, monospace: for numbers. */
numeric?: boolean;
render?: (row: Row) => ReactNode;
}
interface Props {
caption: string;
columns: Column[];
rows: Row[];
rowKey: (row: Row) => string;
}
export function DataTable({ caption, columns, rows, rowKey }: Props) {
return (
{caption}
{columns.map((column) => (
{column.header}
))}
{rows.map((row) => (
{columns.map((column) => (
{column.render ? column.render(row) : String(row[column.key] ?? '')}
))}
))}
);
}
```
---
### Data Display / Status Badge
URL: https://cyberdeck-ui.netlify.app/components/data-display#status-badge
Online, away, offline and alert badges with a glowing (pulsing) dot.
React usage:
```tsx
Breach
```
#### status-badge.html
```html
Online
Idle
Offline
Breach
```
#### status-badge.css
```css
.cy-status {
--cy-status: 107 114 128; /* RGB channels; set by the modifiers */
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.7rem;
border: 1px solid rgb(var(--cy-status) / 0.4);
background: rgb(var(--cy-status) / 0.08);
color: rgb(var(--cy-status));
font-family: var(--cy-font-mono);
font-size: 0.75rem;
letter-spacing: 0.15em;
text-transform: uppercase;
}
.cy-status::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: rgb(var(--cy-status));
box-shadow: 0 0 6px rgb(var(--cy-status));
}
.cy-status--online { --cy-status: 5 255 161; }
.cy-status--away { --cy-status: 252 238 10; }
.cy-status--alert { --cy-status: 255 42 109; }
.cy-status--online::before,
.cy-status--alert::before {
animation: cy-status-pulse 1.6s ease-in-out infinite;
}
@keyframes cy-status-pulse {
50% { opacity: 0.3; }
}
@media (prefers-reduced-motion: reduce) {
.cy-status::before {
animation: none;
}
}
```
#### StatusBadge.tsx
```tsx
import type { ReactNode } from 'react';
import './status-badge.css';
type Status = 'online' | 'away' | 'offline' | 'alert';
interface Props {
status: Status;
/** Defaults to the status name. */
children?: ReactNode;
}
export function StatusBadge({ status, children = status }: Props) {
return {children} ;
}
```
---
### Data Display / Radial Gauge
URL: https://cyberdeck-ui.netlify.app/components/data-display#radial-gauge
A circular meter with a tick ring and a glowing arc, in four colours. Uses role="meter".
React usage:
```tsx
```
#### radial-gauge.html
```html
```
#### radial-gauge.css
```css
.cy-gauge {
--cy-gauge: 0; /* 0–100 */
--cy-gauge-color: var(--cy-cyan);
position: relative;
display: inline-grid;
place-items: center;
width: 10rem;
aspect-ratio: 1;
}
.cy-gauge--pink { --cy-gauge-color: var(--cy-pink); }
.cy-gauge--yellow { --cy-gauge-color: var(--cy-yellow); }
.cy-gauge--green { --cy-gauge-color: var(--cy-green); }
.cy-gauge svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
transform: rotate(-90deg);
}
.cy-gauge circle {
fill: none;
}
/* 60 ticks around a circle of radius 56 (circumference ≈ 351.9) */
.cy-gauge__ticks {
stroke: var(--cy-gauge-color);
stroke-dasharray: 1px 4.865px;
stroke-opacity: 0.35;
stroke-width: 4;
}
.cy-gauge__track {
stroke: var(--cy-surface-2);
stroke-width: 8;
}
/* Radius 46: circumference ≈ 289.03, so 1% ≈ 2.89 */
.cy-gauge__value {
stroke: var(--cy-gauge-color);
stroke-dasharray: calc(var(--cy-gauge) * 2.8903px) 289.03px;
stroke-width: 8;
filter: drop-shadow(0 0 4px var(--cy-gauge-color));
transition: stroke-dasharray 0.6s var(--cy-ease);
}
.cy-gauge__readout {
position: relative;
display: grid;
justify-items: center;
text-align: center;
}
.cy-gauge__readout strong {
color: #fff;
font-family: var(--cy-font-display);
font-size: 2rem;
font-weight: 700;
line-height: 1;
text-shadow: 0 0 10px var(--cy-gauge-color);
}
.cy-gauge__readout small {
color: var(--cy-gauge-color);
font-size: 0.5em;
}
.cy-gauge__readout span {
margin-top: 0.35rem;
color: var(--cy-muted);
font-family: var(--cy-font-mono);
font-size: 0.7rem;
letter-spacing: 0.15em;
text-transform: uppercase;
}
@media (prefers-reduced-motion: reduce) {
.cy-gauge__value {
transition: none;
}
}
```
#### RadialGauge.tsx
```tsx
import type { CSSProperties } from 'react';
import './radial-gauge.css';
type GaugeStyle = CSSProperties & { '--cy-gauge': number };
interface Props {
label: string;
value: number;
max?: number;
/** Shown after the value. Pass an empty string for none. */
unit?: string;
tone?: 'cyan' | 'pink' | 'yellow' | 'green';
}
export function RadialGauge({ label, value, max = 100, unit = '%', tone = 'cyan' }: Props) {
const percent = Math.min(100, Math.max(0, (value / max) * 100));
const style: GaugeStyle = { '--cy-gauge': percent };
return (
{value}
{unit && {unit} }
{label}
);
}
```
---
### Data Display / Avatar
URL: https://cyberdeck-ui.netlify.app/components/data-display#avatar
A hexagonal avatar with a gradient ring, initials or a photo, a status dot and a stacked group.
React usage:
```tsx
```
#### avatar.html
```html
VK
JA
R
VK
JA
R
```
#### avatar.css
```css
.cy-avatar {
--cy-avatar-size: 3.5rem;
--cy-avatar-shape: polygon(25% 6.7%, 75% 6.7%, 100% 50%, 75% 93.3%, 25% 93.3%, 0 50%);
position: relative;
display: inline-block;
flex-shrink: 0;
width: var(--cy-avatar-size);
height: var(--cy-avatar-size);
}
.cy-avatar--sm {
--cy-avatar-size: 2.5rem;
}
.cy-avatar--lg {
--cy-avatar-size: 5rem;
}
/* Gradient hexagon ring */
.cy-avatar__frame {
display: block;
width: 100%;
height: 100%;
padding: 2px;
background: linear-gradient(135deg, var(--cy-cyan), var(--cy-purple) 50%, var(--cy-pink));
clip-path: var(--cy-avatar-shape);
}
.cy-avatar__frame > img,
.cy-avatar__initials {
display: grid;
place-items: center;
width: 100%;
height: 100%;
object-fit: cover;
background: var(--cy-surface-2);
clip-path: var(--cy-avatar-shape);
}
.cy-avatar__initials {
color: var(--cy-cyan);
font-family: var(--cy-font-display);
font-size: calc(var(--cy-avatar-size) * 0.3);
font-weight: 700;
letter-spacing: 0.05em;
text-shadow: 0 0 8px rgba(0, 240, 255, 0.6);
}
/* Status dot */
.cy-avatar[data-status]::after {
content: '';
position: absolute;
right: 4%;
bottom: 6%;
width: 24%;
height: 24%;
border: 2px solid var(--cy-bg);
border-radius: 50%;
background: var(--cy-dim);
}
.cy-avatar[data-status='online']::after {
background: var(--cy-green);
box-shadow: 0 0 8px var(--cy-green);
}
.cy-avatar[data-status='away']::after {
background: var(--cy-yellow);
box-shadow: 0 0 8px var(--cy-yellow);
}
.cy-avatar[data-status='busy']::after {
background: var(--cy-pink);
box-shadow: 0 0 8px var(--cy-pink);
}
.cy-avatar-group {
display: flex;
}
.cy-avatar-group > .cy-avatar + .cy-avatar {
margin-left: calc(var(--cy-avatar-size) * -0.15);
}
```
#### Avatar.tsx
```tsx
import './avatar.css';
type Status = 'online' | 'away' | 'busy' | 'offline';
interface Props {
name: string;
/** Photo URL. Without one, the initials are shown. */
src?: string;
size?: 'sm' | 'md' | 'lg';
status?: Status;
}
const initialsOf = (name: string) =>
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join('')
.toUpperCase();
export function Avatar({ name, src, size = 'md', status }: Props) {
return (
{src ? : {initialsOf(name)} }
);
}
```
---
### Data Display / Accordion
URL: https://cyberdeck-ui.netlify.app/components/data-display#accordion
Numbered collapsible sections on native , optionally one open at a time.
React usage:
```tsx
A portable rig for jacking into the net.
},
{ title: 'Do I need to install anything?', content: No. Copy and paste.
},
]}
/>
```
#### accordion.html
```html
What is a cyberdeck?
A portable rig for jacking into the net: keyboard, screen and a lot of questionable wiring.
Do I need to install anything?
No. Copy the HTML and CSS, or the React component, and you're done.
Can I change the colours?
Every colour comes from a design token. Override the tokens and the whole kit follows.
```
#### accordion.css
```css
.cy-accordion {
width: 100%;
max-width: 36rem;
border-top: 1px solid var(--cy-border);
counter-reset: cy-accordion;
}
.cy-accordion__item {
border-bottom: 1px solid var(--cy-border);
counter-increment: cy-accordion;
}
.cy-accordion__item summary {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 0.25rem;
color: var(--cy-text);
font-family: var(--cy-font-display);
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
list-style: none;
cursor: pointer;
transition: color 0.15s, text-shadow 0.15s;
}
.cy-accordion__item summary::-webkit-details-marker {
display: none;
}
/* 01, 02, 03… */
.cy-accordion__item summary::before {
content: counter(cy-accordion, decimal-leading-zero);
color: var(--cy-pink);
font-family: var(--cy-font-mono);
font-size: 0.8rem;
font-weight: 400;
}
.cy-accordion__item summary::after {
content: '+';
margin-left: auto;
color: var(--cy-cyan);
font-family: var(--cy-font-mono);
font-size: 1.25rem;
font-weight: 400;
transition: transform 0.2s var(--cy-ease);
}
.cy-accordion__item[open] summary::after {
transform: rotate(45deg);
}
.cy-accordion__item summary:hover,
.cy-accordion__item[open] summary {
color: var(--cy-cyan);
text-shadow: 0 0 8px rgba(0, 240, 255, 0.45);
}
.cy-accordion__item summary:focus-visible {
outline: 2px solid var(--cy-yellow);
outline-offset: 2px;
}
.cy-accordion__panel {
padding: 0 0.25rem 1.25rem 2.6rem;
color: var(--cy-muted);
font-family: var(--cy-font-body);
font-size: 1.05rem;
line-height: 1.6;
}
.cy-accordion__panel p {
margin: 0;
}
.cy-accordion__item[open] .cy-accordion__panel {
animation: cy-accordion-in 0.25s var(--cy-ease);
}
@keyframes cy-accordion-in {
from { opacity: 0; transform: translateY(-4px); }
}
@media (prefers-reduced-motion: reduce) {
.cy-accordion__item[open] .cy-accordion__panel {
animation: none;
}
}
```
#### Accordion.tsx
```tsx
import { useId } from 'react';
import type { ReactNode } from 'react';
import './accordion.css';
export interface AccordionItem {
title: string;
content: ReactNode;
}
interface Props {
items: AccordionItem[];
/** Only one item open at a time, using native exclusive . */
exclusive?: boolean;
/** Index of the item that starts open. */
defaultOpen?: number;
}
export function Accordion({ items, exclusive = false, defaultOpen }: Props) {
const name = useId();
return (
{items.map((item, index) => (
{item.title}
{item.content}
))}
);
}
```
---
### Data Display / Keyboard Key
URL: https://cyberdeck-ui.netlify.app/components/data-display#kbd
Glowing keycaps for shortcuts, single keys or combinations, using nested .
React usage:
```tsx
```
#### kbd.html
```html
Press Ctrl +K to open the command palette.
Esc to disconnect, ↑ ↓ to navigate.
```
#### kbd.css
```css
.cy-kbd {
display: inline-block;
min-width: 1.9em;
padding: 0.15em 0.5em;
border: 1px solid rgba(0, 240, 255, 0.45);
border-bottom-width: 3px;
background: linear-gradient(var(--cy-surface-2), var(--cy-surface));
box-shadow: 0 0 10px rgba(0, 240, 255, 0.12);
color: var(--cy-cyan);
font-family: var(--cy-font-mono);
font-size: 0.85em;
line-height: 1.3;
text-align: center;
text-shadow: 0 0 6px rgba(0, 240, 255, 0.6);
}
/* A key combination: Ctrl +K */
.cy-kbd-combo {
display: inline-flex;
align-items: center;
gap: 0.35em;
color: var(--cy-dim);
font-family: var(--cy-font-mono);
font-size: inherit;
}
/* Optional: body text around the keys. */
.cy-kbd-text {
margin: 0;
color: var(--cy-muted);
font-family: var(--cy-font-body);
font-size: 1.15rem;
}
```
#### Kbd.tsx
```tsx
import { Fragment } from 'react';
import './kbd.css';
interface Props {
/** One key, or several for a combination like ['Ctrl', 'K']. */
keys: string | string[];
}
export function Kbd({ keys }: Props) {
if (typeof keys === 'string') return {keys} ;
return (
{keys.map((key, index) => (
{index > 0 && '+'}
{key}
))}
);
}
```
---
### Overlays / Modal
URL: https://cyberdeck-ui.netlify.app/components/overlays#modal
A HUD-framed dialog using native : focus trapping, Escape to close and a blurred backdrop for free.
React usage:
```tsx
const [open, setOpen] = useState(false);
setOpen(false)}
title="Confirm upload"
actions={Upload }
>
Push this shard to the public net?
```
#### modal.html
```html
Upload shard
```
#### modal.css
```css
.cy-modal-open {
padding: 0.7rem 1.4rem;
border: 1px solid rgba(0, 240, 255, 0.6);
background: transparent;
color: var(--cy-cyan);
font-family: var(--cy-font-mono);
font-size: 0.9rem;
letter-spacing: 0.15em;
text-transform: uppercase;
cursor: pointer;
}
.cy-modal-open:hover,
.cy-modal-open:focus-visible {
outline: none;
background: rgba(0, 240, 255, 0.1);
}
.cy-modal {
width: calc(100% - 2rem);
max-width: 28rem;
padding: 0;
overflow: visible;
border: 1px solid rgba(0, 240, 255, 0.4);
background: var(--cy-surface);
box-shadow: 0 0 40px rgba(0, 240, 255, 0.15);
color: var(--cy-text);
font-family: var(--cy-font-body);
}
.cy-modal[open] {
animation: cy-modal-in 0.25s var(--cy-ease);
}
.cy-modal::backdrop {
background: rgba(7, 5, 13, 0.75);
-webkit-backdrop-filter: blur(3px);
backdrop-filter: blur(3px);
}
/* HUD corner brackets */
.cy-modal::before,
.cy-modal::after {
content: '';
position: absolute;
width: 20px;
height: 20px;
pointer-events: none;
}
.cy-modal::before {
top: -1px;
left: -1px;
border-top: 2px solid var(--cy-cyan);
border-left: 2px solid var(--cy-cyan);
}
.cy-modal::after {
right: -1px;
bottom: -1px;
border-right: 2px solid var(--cy-cyan);
border-bottom: 2px solid var(--cy-cyan);
}
.cy-modal__inner {
padding: 1.5rem;
}
.cy-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.cy-modal__header h2 {
margin: 0;
color: #fff;
font-family: var(--cy-font-display);
font-size: 1.1rem;
letter-spacing: 0.1em;
text-transform: uppercase;
text-shadow: var(--cy-glow-cyan);
}
.cy-modal__close {
border: 0;
background: none;
color: var(--cy-dim);
font-size: 1.1rem;
cursor: pointer;
}
.cy-modal__close:hover,
.cy-modal__close:focus-visible {
color: var(--cy-pink);
}
.cy-modal__body {
margin: 1rem 0 1.5rem;
color: var(--cy-muted);
font-size: 1.05rem;
line-height: 1.6;
}
.cy-modal__actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.cy-modal__btn {
padding: 0.6rem 1.25rem;
border: 1px solid rgba(255, 255, 255, 0.2);
background: transparent;
color: var(--cy-text);
font-family: var(--cy-font-mono);
font-size: 0.85rem;
letter-spacing: 0.15em;
text-transform: uppercase;
cursor: pointer;
}
.cy-modal__btn--primary {
border-color: var(--cy-yellow);
background: var(--cy-yellow);
color: #000;
}
.cy-modal__btn:hover,
.cy-modal__btn:focus-visible {
outline: none;
border-color: var(--cy-cyan);
}
.cy-modal__btn--primary:hover,
.cy-modal__btn--primary:focus-visible {
border-color: var(--cy-cyan);
background: var(--cy-cyan);
}
@keyframes cy-modal-in {
from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.cy-modal[open] {
animation: none;
}
}
```
#### modal.js
```js
// Opens from [data-cy-modal-open=""] buttons.
// Buttons inside