Runtime device capability detection for React. Measures actual rendering FPS via CSS, Canvas 2D, and WebGL benchmarks, classifies the device into a performance tier (high, medium, low, minimal), and lets you adapt animations, particle counts, image quality, and effect complexity per tier. SSR-safe, progressive-enhancement compatible, and ships drop-in fallbacks for Framer Motion and Lottie. Respects prefers-reduced-motion automatically.
Live Demo — Try it out and see how your device performs!
- Heavy animations on a wide device range — particle systems, scroll-driven effects, WebGL scenes, Framer Motion choreography that runs smoothly on desktop but tanks frame rate on mid-range Android.
- Lottie animations with a static-image fallback for low-end devices.
- Improving Core Web Vitals — reduce work during page load (LCP), avoid blocking the main thread (INP), and skip animations that cause layout shift (CLS) on slow devices.
- Accessibility — automatic
prefers-reduced-motionhandling without writing the media-query plumbing yourself. - Progressive enhancement — start static, upgrade to animations only on capable hardware.
CSS media queries can target screen size, prefers-reduced-motion, and connection type — but not actual rendering performance. Two devices with the same viewport can have a 5× difference in real frame rate. react-adaptive-perf runs short on-device benchmarks (CSS transforms, Canvas 2D, WebGL) and tiers the device based on measured FPS, so your adaptive logic responds to what the device can actually render — not what its user-agent string claims.
A MacBook Pro and a budget Android phone get the same 60-particle confetti animation. One runs it smoothly. The other drops to 15fps and drains the battery. This library tells you which is which.
npm install react-adaptive-perfimport { PerformanceProvider, useAdaptiveValue } from 'react-adaptive-perf';
function App() {
return (
<PerformanceProvider>
<ParticleEffect />
</PerformanceProvider>
);
}
function ParticleEffect() {
const particleCount = useAdaptiveValue({
high: 500,
medium: 200,
low: 50,
minimal: 0,
});
return <Particles count={particleCount} />;
}On mount, the library runs quick benchmarks (CSS transforms, Canvas 2D, WebGL) to measure actual rendering performance. Combined with hardware detection (CPU cores, device memory, GPU string, mobile flag), it assigns one of four performance tiers:
| Tier | Typical Device | Recommendation |
|---|---|---|
high |
Modern desktop, flagship phones | Full animations, all effects |
medium |
Mid-range devices, older flagships | Reduced particle counts, simpler transitions |
low |
Budget phones, old hardware | Minimal animations, static alternatives |
minimal |
Very old devices, reduced-motion preference | No animations |
The minimal tier is also applied when the user has prefers-reduced-motion enabled.
Wrap your app to enable detection.
<PerformanceProvider
config={{
progressiveEnhancement: true, // Start minimal, upgrade if capable
detailedBenchmark: true, // Run CSS + Canvas + WebGL benchmarks
benchmarkDuration: 200, // Duration per benchmark (ms)
persistOverride: true, // Remember user's manual tier selection
onTierDetected: (tier, hardware, benchmark) => {
console.log('Detected:', tier);
},
}}
>
<App />
</PerformanceProvider>Fine-tune benchmark behavior for better INP (Interaction to Next Paint):
<PerformanceProvider
config={{
// Yield to main thread every 50ms during benchmarks (default: 50)
// Allows user interactions while benchmark runs
// Set to 0 to disable yielding (continuous benchmark)
benchmarkYieldInterval: 50,
// Throttle progress callback updates (default: 100ms)
// Reduces re-renders during benchmark
// Set to 0 for real-time updates
progressThrottle: 100,
// Delay before starting benchmark (default: 0)
// Useful to let the page settle before measuring
benchmarkDelay: 500,
}}
>Access the current performance context.
const {
tier, // 'high' | 'medium' | 'low' | 'minimal'
isDetecting, // true while benchmarks are running
hardware, // { cpuCores, deviceMemory, gpu, isMobile, ... }
benchmark, // { css, canvas, webgl, composite }
shouldAnimate, // false if tier is 'minimal'
setTier, // Manually override tier
clearOverride, // Reset to auto-detected tier
} = usePerformance();Return different values based on the current tier.
const animationDuration = useAdaptiveValue({
high: 600,
medium: 300,
low: 150,
minimal: 0,
});
const quality = useAdaptiveValue({
high: 'ultra',
medium: 'high',
low: 'medium',
minimal: 'low',
});
// Values cascade: if 'low' isn't defined, it uses 'medium', then 'high'
const effects = useAdaptiveValue({
high: ['blur', 'glow', 'shadow'],
low: ['shadow'],
minimal: [],
});CSS transition wrapper that adapts to performance tier.
<AdaptiveMotion
animate={isHovered}
initial={{ scale: 1, opacity: 0.8 }}
target={{ scale: 1.1, opacity: 1 }}
className="card"
>
Hover me
</AdaptiveMotion>Switches between animated and static images based on the detected tier. src is the always-loaded static fallback; animatedSrc (and optionally videoSrc) is shown only when the device meets animationTier.
<AdaptiveImage
src="/hero.jpg" // Static fallback (required)
animatedSrc="/hero.gif" // Shown on medium+ tiers
animationTier="medium"
alt="Hero"
/>Wrap Framer Motion components to automatically reduce complexity on slower devices.
import { motion } from 'framer-motion';
import { AdaptiveFramerMotion } from 'react-adaptive-perf';
<AdaptiveFramerMotion
as={motion.div}
animate={{ scale: 1.1, rotate: 10 }}
transition={{ type: 'spring' }}
disableOn={['minimal']}
>
Animated content
</AdaptiveFramerMotion>Or use the hook for more control:
import { useAdaptiveFramerProps } from 'react-adaptive-perf';
function Card() {
const props = useAdaptiveFramerProps({
animate: { scale: 1.1, rotate: 10 },
transition: { type: 'spring', stiffness: 300 },
});
return <motion.div {...props}>Card</motion.div>;
}Automatically falls back to a static image on low-performance devices.
import { AdaptiveLottie } from 'react-adaptive-perf';
<AdaptiveLottie
animationData={heroAnimation}
fallbackSrc="/hero-static.webp"
animationTier="medium"
loop
autoplay
/>Requires lottie-web as a peer dependency.
Add a development overlay to visualise performance metrics and test different tiers.
import { DebugPanel } from 'react-adaptive-perf';
<DebugPanel
position="bottom-right"
devOnly // Auto-hide in production
showLiveFps // Real-time FPS counter
/>The panel shows:
- Current tier with live FPS
- Individual benchmark scores (CSS, Canvas, WebGL)
- Hardware info (CPU cores, RAM, GPU)
- Manual tier override controls
For a minimal indicator:
import { FPSBadge } from 'react-adaptive-perf';
<FPSBadge position="top-right" />By default, manual tier overrides persist to localStorage. Users who select "High" quality keep that preference across sessions.
const { setTier, clearOverride, isOverridden } = usePerformance();
// User selects a tier
setTier('high');
// Reset to auto-detected
clearOverride();Disable persistence:
<PerformanceProvider config={{ persistOverride: false }}>The detailed benchmark runs three tests:
| Test | Weight | What It Measures |
|---|---|---|
| CSS | 40% | DOM transforms, filters, opacity transitions |
| Canvas | 30% | 2D particle rendering with gradients |
| WebGL | 30% | GPU shader performance with 1000 points |
Results are combined into a composite FPS score that determines the tier.
const { benchmark } = usePerformance();
// {
// css: { fps: 58, jankScore: 0.02 },
// canvas: { fps: 52, jankScore: 0.05 },
// webgl: { fps: 45, jankScore: 0.08 },
// composite: { fps: 52, tier: 'medium' }
// }In addition to benchmarks, the library reads hardware signals:
const { hardware } = usePerformance();
// {
// cpuCores: 8,
// deviceMemory: 16,
// isMobile: false,
// gpu: 'ANGLE (Apple, Apple M1 Pro, OpenGL 4.1)',
// prefersReducedMotion: false,
// webglSupport: 'webgl2',
// maxTextureSize: 16384,
// screenRefreshRate: 120,
// devicePixelRatio: 2,
// }Fully typed. Key types:
import type {
PerformanceTier,
HardwareProfile,
BenchmarkResult,
DetailedBenchmarkResult,
AdaptiveFramerMotionProps,
AdaptiveLottieProps,
} from 'react-adaptive-perf';The recommended approach for best Core Web Vitals. Start with a fast, static experience, then upgrade to animations if the device can handle it.
<PerformanceProvider config={{ progressiveEnhancement: true }}>
<App />
</PerformanceProvider>How it works:
- Initial render - Starts with
tier: 'minimal'(no animations) - Page loads - Waits for
window.onloadto complete - Benchmark runs - Tests device capability when idle
- Upgrade only - If device scores well, tier upgrades (never downgrades)
Benefits:
- LCP - No animations blocking initial paint
- INP - Benchmark runs when page is idle
- CLS - No downgrades = no layout shifts from removing animations
You can combine with initialTier if you want to start at a different baseline:
<PerformanceProvider config={{ progressiveEnhancement: true, initialTier: 'low' }}>The library is SSR-safe. During server rendering and initial hydration, it uses initialTier (default: 'high', or 'minimal' with progressiveEnhancement). Detection runs after hydration completes.
To prevent hydration mismatches when rendering tier-specific content, use <WhenDetected>:
import { WhenDetected, usePerformance } from 'react-adaptive-perf';
function Hero() {
const { tier } = usePerformance();
return (
<WhenDetected fallback={<HeroSkeleton />}>
{tier === 'high' ? <HeavyAnimation /> : <LightAnimation />}
</WhenDetected>
);
}Or check isDetecting manually:
const { tier, isDetecting } = usePerformance();
if (isDetecting) return <Skeleton />;
return tier === 'high' ? <Heavy /> : <Light />;Works in all modern browsers. Gracefully degrades when APIs aren't available:
- WebGL benchmark skipped if WebGL unsupported
- Hardware memory detection falls back to estimates
- Reduced motion detection requires
matchMediasupport
MIT © Roland Farkas