Demo 01
FPS Health Bar
Videogame parallelogram vitals HUD with cue-driven damage, pulse, and moving armor segments
loading cue…
Source
health-bar.tsx
import { useEffect, useId, useRef, useState, type CSSProperties, type PointerEvent } from 'react'import { LazyMotion, domAnimation, m, useMotionValue, useSpring } from 'motion/react'import { defineCue, observeCue } from 'motion-cues'import { useMountEffect } from '../../hooks/useMountEffect'import { useAutoPlay } from '../../hooks/useAutoPlay'import DemoControls from './DemoControls'type HealthStatus = 'full' | 'high' | 'medium' | 'low' | 'critical'const healthCue = defineCue('health', {critical: 0.02,low: 0.15,medium: 0.34,high: 0.67,full: 1,} satisfies Record<HealthStatus, number>)const AMMO_THRESHOLDS = [0.88, 0.72, 0.56, 0.4, 0.24, 0.08] as constconst BLASTER_OFFSETS = [-18, 6, 22] as constconst statusAccent: Record<HealthStatus, string> = {full: '#f4f4f4',high: '#f4f4f4',medium: '#ffb020',low: '#ff5d2f',critical: '#ff2d55',}function clamp01(value: number) {return Math.max(0, Math.min(1, value))}function getHealthStatus(value: number): HealthStatus {if (value >= 0.96) return 'full'if (value >= 0.67) return 'high'if (value >= 0.34) return 'medium'if (value >= 0.15) return 'low'return 'critical'}function statusClass(status: HealthStatus) {return `game-health game-health--${status}`}type HealthHudStyle = CSSProperties & {'--health-accent': string'--hurt-opacity': string'--aim-x': string'--aim-y': string}export default function HealthBar() {const progress = useMotionValue(1)const spring = useSpring(progress, { stiffness: 150, damping: 24 })const [combinedVitals, setCombinedVitals] = useState(1)const [impact, setImpact] = useState(false)const [aim, setAim] = useState({ x: 50, y: 47 })const previousVitalsRef = useRef(1)const shieldMaskId = useId().replace(/:/g, '')const { isPlaying, toggle, containerRef } = useAutoPlay(progress, { duration: 5600, from: 1, to: 0.02 })useMountEffect(() => observeCue(spring, healthCue, {onEnter: () => setImpact(true),}))useMountEffect(() => spring.on('change', (value: number) => {const nextVitals = clamp01(value)if (nextVitals < previousVitalsRef.current - 0.04) setImpact(true)previousVitalsRef.current = nextVitalssetCombinedVitals(nextVitals)}))useEffect(() => {if (!impact) returnconst timeout = window.setTimeout(() => setImpact(false), 260)return () => window.clearTimeout(timeout)}, [impact])const handleAim = (event: PointerEvent<HTMLElement>) => {const bounds = event.currentTarget.getBoundingClientRect()setAim({x: clamp01((event.clientX - bounds.left) / bounds.width) * 100,y: clamp01((event.clientY - bounds.top) / bounds.height) * 100,})}const handleFire = () => {progress.set(clamp01(progress.get() - 0.075))setImpact(true)}const status = getHealthStatus(combinedVitals)const shield = clamp01((combinedVitals - 0.52) / 0.48)const health = clamp01(combinedVitals / 0.52)const hurtOpacity = clamp01((0.58 - combinedVitals) / 0.58)const heartbeatSpeed = status === 'critical' ? 0.42 : status === 'low' ? 0.58 : status === 'medium' ? 0.78 : 1.05const activeRounds = AMMO_THRESHOLDS.map((threshold) => combinedVitals >= threshold)const hudVars: HealthHudStyle = {'--health-accent': statusAccent[status],'--hurt-opacity': String(hurtOpacity),'--aim-x': `${aim.x}%`,'--aim-y': `${aim.y}%`,}return (<LazyMotion features={domAnimation}><m.sectionref={containerRef}className={statusClass(status)}style={hudVars}aria-label="Interactive FPS health HUD demo"onPointerMove={handleAim}onPointerDown={handleFire}><div className="game-health__vignette" aria-hidden="true" /><m.div className={`game-health__blood${impact ? ' is-hit' : ''}`} aria-hidden="true" /><m.divclassName="game-health__crosshair"aria-hidden="true"animate={impact ? { scale: [1, 0.86, 1.08, 1] } : { scale: 1 }}transition={{ duration: 0.22 }}><span /></m.div><div className={`game-health__incoming${impact ? ' is-active' : ''}`} aria-hidden="true">{BLASTER_OFFSETS.map((offset, index) => (<m.spankey={offset}style={{ '--blast-y': `${offset}vh` } as CSSProperties}animate={impact ? { opacity: [0, 1, 0], x: ['34vw', '0vw'], scaleX: [0.2, 1, 0.35] } : { opacity: 0 }}transition={{ duration: 0.34, delay: index * 0.045, ease: 'easeOut' }}/>))}</div><div className="game-health__ammo" aria-label={`${activeRounds.filter(Boolean).length} rounds active`}>{activeRounds.map((active, index) => (<m.spankey={index}className={active ? 'is-active' : ''}animate={active && impact ? { y: [0, -2, 0], opacity: [1, 0.68, 1] } : { y: 0, opacity: active ? 1 : 0.22 }}transition={{ duration: 0.18, delay: index * 0.025 }}/>))}</div><section className="game-health__hud-deck" aria-label={`${status} health. Shield line drains first, then health drains.`}><div className="game-health__corebar"><div className="game-health__health-track" role="meter" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(health * 100)} aria-label="Health level"><m.i animate={{ scaleX: health }} transition={{ duration: 0.18, ease: 'easeOut' }} /><svg className="game-health__shield-line" viewBox="0 0 100 24" preserveAspectRatio="none" aria-hidden="true"><defs><linearGradient id={`${shieldMaskId}-stroke`} x1="0" x2="1" y1="0" y2="1"><stop offset="0%" stopColor="#ffffff" /><stop offset="48%" stopColor="#9ee9ff" /><stop offset="100%" stopColor="#ffffff" stopOpacity="0.18" /></linearGradient><linearGradient id={`${shieldMaskId}-shim`} x1="0" x2="1" y1="0" y2="0"><stop offset="0%" stopColor="#ffffff" stopOpacity="0" /><stop offset="45%" stopColor="#ffffff" stopOpacity="0" /><stop offset="52%" stopColor="#ffffff" stopOpacity="0.95" /><stop offset="62%" stopColor="#9ee9ff" stopOpacity="0.18" /><stop offset="100%" stopColor="#ffffff" stopOpacity="0" /></linearGradient><mask id={`${shieldMaskId}-mask`} maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="24"><m.rectx="0"y="0"height="24"fill="#fff"animate={{ width: Math.max(4, shield * 100) }}transition={{ duration: 0.2, ease: 'easeOut' }}/></mask></defs><path className="game-health__shield-base" d="M5 3 H98 L94 21" /><g mask={`url(#${shieldMaskId}-mask)`}><path className="game-health__shield-active" d="M5 3 H98 L94 21" stroke={`url(#${shieldMaskId}-stroke)`} />… 21 more lines