# 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 ``` #### 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 ``` #### 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 ``` #### 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 ( ); } ``` --- ### 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 ``` #### 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 ``` #### 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 ( ); } ``` --- ### 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}

); } ``` --- ### 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(' ')} ); } ``` --- ### 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%
``` #### 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
[ 01 ]

Ghostline

An encrypted messaging relay that routes every packet through three anonymous nodes before it reaches the grid.

  • Rust
  • WebRTC
  • Redis
``` #### 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
    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 (
    {STATUS_LABELS[status]}

    {name}

    {role}

    {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
    ``` #### 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

    Enter a valid email address.

    ``` #### 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 (
    {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.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 (