React Tuning Panels with Leva
Complete guide for building parameter tuning panels in React using Leva.
Installation
npm install leva
# or
pnpm add leva
Basic Setup
import { useControls } from 'leva';
function Component() {
const { opacity, scale } = useControls({
opacity: { value: 1, min: 0, max: 1, step: 0.01 },
scale: { value: 1, min: 0.1, max: 3, step: 0.1 },
});
return <div style={{ opacity, transform: `scale(${scale})` }} />;
}
Control Types
Number (Slider)
const { value } = useControls({
value: { value: 50, min: 0, max: 100, step: 1 },
});
Number (Scrubber)
const { value } = useControls({
value: 50, // Scrubber when no constraints
});
Range (Two-Handle Slider)
const { range } = useControls({
range: { value: [25, 75], min: 0, max: 100 },
});
Color
const { color } = useControls({
color: '#ff0000',
colorRgb: { r: 255, g: 0, b: 0 },
colorRgba: { r: 255, g: 0, b: 0, a: 0.5 },
});
Select/Dropdown
const { easing } = useControls({
easing: { value: 'easeOut', options: ['linear', 'easeIn', 'easeOut', 'easeInOut'] },
});
// With labeled options
const { easing } = useControls({
easing: {
value: 'ease-out',
options: {
'Linear': 'linear',
'Ease In': 'ease-in',
'Ease Out': 'ease-out',
},
},
});
Boolean
const { enabled } = useControls({
enabled: true,
});
Vector2/Vector3
const { position } = useControls({
position: { value: { x: 0, y: 0 }, step: 1 },
});
const { position3d } = useControls({
position3d: { value: { x: 0, y: 0, z: 0 }, step: 1 },
});
Organization
Folders (Collapsible Groups)
import { useControls, folder } from 'leva';
const values = useControls({
animation: folder({
duration: { value: 300, min: 0, max: 2000 },
easing: { value: 'easeOut', options: ['linear', 'easeIn', 'easeOut'] },
}),
layout: folder({
padding: { value: 16, min: 0, max: 64 },
gap: { value: 8, min: 0, max: 32 },
}, { collapsed: true }), // Start collapsed
});
Buttons
import { useControls, button } from 'leva';
const values = useControls({
// ... other controls
'Reset': button(() => resetValues()),
'Export': button(() => exportForLLM()),
});
Panel Positioning
import { Leva } from 'leva';
<Leva
collapsed={false}
flat={false}
theme={{
sizes: { rootWidth: '320px' },
colors: { accent1: '#6366f1' },
}}
/>
Conditional Rendering
const { type } = useControls({ type: { value: 'A', options: ['A', 'B'] } });
const typeAControls = useControls(
'Type A Options',
{ specific: 100 },
{ render: (get) => get('type') === 'A' }
);
Debug Mode Patterns
Environment Variable
const DevTools = lazy(() => import('./TuningPanel'));
function App() {
return (
<>
<MainContent />
{process.env.NODE_ENV === 'development' && (
<Suspense fallback={null}>
<DevTools />
</Suspense>
)}
</>
);
}
Keyboard Shortcut (⌘⇧D)
const [showPanel, setShowPanel] = useState(false);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.metaKey && e.shiftKey && e.key === 'D') {
setShowPanel(prev => !prev);
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
URL Parameter
const showDevTools = new URLSearchParams(window.location.search).has('debug');
LLM Export Implementation
CRITICAL: Track defaults and filter to show only changed values.
export default function TunableComponent() {
// CRITICAL: Store defaults at component level for comparison
const defaults = {
duration: 300,
delay: 0,
stiffness: 100,
damping: 10,
opacity: 1.0,
};
const config = useControls({
animation: folder({
duration: { value: 300, min: 0, max: 2000, step: 10 },
delay: { value: 0, min: 0, max: 1000, step: 10 },
}),
physics: folder({
stiffness: { value: 100, min: 0, max: 300, step: 1 },
damping: { value: 10, min: 0, max: 100, step: 1 },
}),
visual: folder({
opacity: { value: 1.0, min: 0, max: 1, step: 0.01 },
}),
'Export for LLM': button(() => {
const formatted = `## Tuned Parameters
Apply these values to the component:
\`\`\`typescript
const config = {
${Object.entries(config)
.filter(([key]) => key !== 'Export for LLM')
.map(([key, val]) => ` ${key}: ${JSON.stringify(val)},`)
.join('\n')}
};
\`\`\`
### Changes from Defaults
${Object.entries(config)
.filter(([key, val]) => {
const defaultVal = defaults[key as keyof typeof defaults];
if (defaultVal === undefined) return false;
const numVal = Number(val);
const numDefault = Number(defaultVal);
if (!isNaN(numVal) && !isNaN(numDefault)) {
return Math.abs(numVal - numDefault) > 0.001;
}
return val !== defaultVal;
})
.map(([key, val]) => {
const defaultVal = defaults[key as keyof typeof defaults];
const displayKey = key.replace(/([A-Z])/g, ' $1')
.replace(/^./, str => str.toUpperCase());
const formattedDefault = typeof defaultVal === 'number'
? defaultVal.toFixed(2) : defaultVal;
const formattedVal = typeof val === 'number'
? val.toFixed(2) : val;
return `- ${displayKey}: ${formattedDefault} → ${formattedVal}`;
})
.join('\n') || '(No changes from defaults)'}
`;
navigator.clipboard.writeText(formatted);
alert('Tuned parameters copied to clipboard!');
}),
});
return <Component {...config} />;
}
Common Mistakes
❌ DON'T hardcode defaults in the export string:
// BAD - Shows "Default: 300 → ${config.duration}" even if unchanged
`- Duration: 300 → ${config.duration}`
❌ DON'T use strict equality for numbers:
// BAD - Floating point comparison may fail
.filter(([key, val]) => val !== defaults[key])
❌ DON'T forget to store defaults:
// BAD - No defaults object to compare against
const config = useControls({
duration: { value: 300, min: 0, max: 2000 },
});
✅ DO store defaults and filter properly:
// GOOD - Proper defaults tracking and comparison
const defaults = { duration: 300, delay: 0 };
const config = useControls({ /* ... */ });
const changed = Object.entries(config).filter(([k, v]) =>
Math.abs(Number(v) - Number(defaults[k])) > 0.001
);
Persistence Patterns
localStorage
useEffect(() => {
localStorage.setItem('tuning-values', JSON.stringify(values));
}, [values]);
const initialValues = JSON.parse(localStorage.getItem('tuning-values') || '{}');
URL Sharing
const shareUrl = () => {
const params = new URLSearchParams();
Object.entries(values).forEach(([k, v]) => params.set(k, String(v)));
return `${location.origin}${location.pathname}?${params}`;
};
Presets
const PRESETS = {
snappy: { duration: 150, easing: 'easeOut', stiffness: 400 },
smooth: { duration: 400, easing: 'easeInOut', stiffness: 180 },
bouncy: { duration: 600, easing: 'easeOut', stiffness: 120, damping: 8 },
};
const { preset } = useControls({
preset: {
value: 'smooth',
options: Object.keys(PRESETS),
onChange: (p) => applyPreset(PRESETS[p]),
},
});