Creating StickerNest React Components
This skill covers creating React components that follow StickerNest's patterns, including Zustand store integration, theme tokens, and custom hooks.
Component Location
| Type |
Location |
| UI Components |
src/components/ |
| Shared UI |
src/shared-ui/ |
| Hooks |
src/hooks/ |
Basic Component Template
// src/components/MyComponent.tsx
import React, { useState, useCallback, useEffect } from 'react';
import { useCanvasStore } from '../state/useCanvasStore';
interface MyComponentProps {
/** Component property */
value?: string;
/** Callback when value changes */
onChange?: (value: string) => void;
/** Optional CSS class */
className?: string;
}
export const MyComponent: React.FC<MyComponentProps> = ({
value = '',
onChange,
className,
}) => {
// Local state
const [localValue, setLocalValue] = useState(value);
// Zustand store state (select only what you need)
const canvasId = useCanvasStore((s) => s.canvasId);
const addWidget = useCanvasStore((s) => s.addWidget);
// Handlers
const handleChange = useCallback((newValue: string) => {
setLocalValue(newValue);
onChange?.(newValue);
}, [onChange]);
// Effects
useEffect(() => {
setLocalValue(value);
}, [value]);
return (
<div
className={className}
style={{
backgroundColor: 'var(--sn-bg-secondary)',
borderRadius: 'var(--sn-radius-md)',
padding: 'var(--sn-space-4)',
color: 'var(--sn-text-primary)',
}}
>
{/* Component content */}
</div>
);
};
Zustand Store Integration
Selecting State (Optimized)
// GOOD: Select only what you need
const canvasId = useCanvasStore((s) => s.canvasId);
const widgets = useCanvasStore((s) => s.widgets);
// BAD: Selecting entire state causes unnecessary re-renders
const store = useCanvasStore(); // Avoid this
Multiple Selectors
// Option 1: Multiple hooks (recommended for unrelated data)
const canvasId = useCanvasStore((s) => s.canvasId);
const mode = useCanvasStore((s) => s.mode);
// Option 2: Single selector for related data
const { canvasId, mode } = useCanvasStore((s) => ({
canvasId: s.canvasId,
mode: s.mode,
}));
Accessing Actions
// Actions are stable, safe to access directly
const addWidget = useCanvasStore((s) => s.addWidget);
const removeWidget = useCanvasStore((s) => s.removeWidget);
// Or destructure multiple
const { addWidget, removeWidget, updateWidget } = useCanvasStore((s) => ({
addWidget: s.addWidget,
removeWidget: s.removeWidget,
updateWidget: s.updateWidget,
}));
Available Stores
| Store |
Purpose |
Key State |
useCanvasStore |
Canvas & widgets |
canvasId, widgets, mode, selection |
useLibraryStore |
Widget library |
searchQuery, selectedWidgets, filters |
usePanelsStore |
Panel states |
isPanelOpen, panelPositions |
useToolStore |
Active tools |
activeTool, shapeDefaults |
useThemeStore |
Theme settings |
theme, customColors |
useAssetStore |
Asset management |
assets, uploadProgress |
useApiSettingsStore |
API config |
apiKey, endpoint |
Theme Tokens
Color Tokens
/* Backgrounds */
--sn-bg-primary: #0f0f19;
--sn-bg-secondary: #1a1a2e;
--sn-bg-tertiary: #252538;
--sn-bg-elevated: #2a2a42;
/* Text */
--sn-text-primary: #e2e8f0;
--sn-text-secondary: #94a3b8;
--sn-text-muted: #64748b;
/* Accents */
--sn-accent-primary: #8b5cf6;
--sn-accent-secondary: #a78bfa;
--sn-success: #22c55e;
--sn-error: #ef4444;
--sn-warning: #f59e0b;
--sn-info: #3b82f6;
/* Borders */
--sn-border-primary: rgba(139, 92, 246, 0.2);
--sn-border-secondary: rgba(255, 255, 255, 0.1);
Spacing Tokens
--sn-space-1: 4px;
--sn-space-2: 8px;
--sn-space-3: 12px;
--sn-space-4: 16px;
--sn-space-5: 20px;
--sn-space-6: 24px;
--sn-space-8: 32px;
Border Radius
--sn-radius-sm: 4px;
--sn-radius-md: 6px;
--sn-radius-lg: 8px;
--sn-radius-xl: 12px;
--sn-radius-full: 9999px;
Elevation (Shadows)
--sn-elevation-1: 0 1px 2px rgba(0, 0, 0, 0.3);
--sn-elevation-2: 0 2px 4px rgba(0, 0, 0, 0.4);
--sn-elevation-3: 0 4px 8px rgba(0, 0, 0, 0.5);
--sn-elevation-panel: 0 4px 16px rgba(0, 0, 0, 0.5);
Transitions
--sn-transition-fast: 150ms ease;
--sn-transition-normal: 200ms ease;
--sn-transition-slow: 300ms ease;
--sn-ease-spring-snappy: cubic-bezier(0.34, 1.56, 0.64, 1);
Glass Effect
--sn-glass-bg: rgba(15, 15, 25, 0.85);
--sn-glass-blur: blur(12px);
--sn-glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
Common UI Patterns
Panel Component
interface PanelProps {
title: string;
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}
export const Panel: React.FC<PanelProps> = ({
title,
isOpen,
onClose,
children,
}) => {
if (!isOpen) return null;
return (
<div
style={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: 320,
background: 'var(--sn-glass-bg)',
backdropFilter: 'var(--sn-glass-blur)',
borderLeft: '1px solid var(--sn-border-primary)',
boxShadow: 'var(--sn-elevation-panel)',
display: 'flex',
flexDirection: 'column',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: 'var(--sn-space-4)',
borderBottom: '1px solid var(--sn-border-secondary)',
}}
>
<span style={{ fontWeight: 600, color: 'var(--sn-text-primary)' }}>
{title}
</span>
<button
onClick={onClose}
style={{
background: 'transparent',
border: 'none',
color: 'var(--sn-text-secondary)',
cursor: 'pointer',
padding: 'var(--sn-space-1)',
}}
>
×
</button>
</div>
{/* Content */}
<div style={{ flex: 1, overflow: 'auto', padding: 'var(--sn-space-4)' }}>
{children}
</div>
</div>
);
};
Button Component
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
onClick?: () => void;
children: React.ReactNode;
}
export const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'md',
disabled = false,
onClick,
children,
}) => {
const baseStyles: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 'var(--sn-radius-md)',
fontWeight: 500,
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
transition: 'var(--sn-transition-fast)',
border: 'none',
};
const variantStyles: Record<string, React.CSSProperties> = {
primary: {
background: 'var(--sn-accent-primary)',
color: 'white',
},
secondary: {
background: 'var(--sn-bg-tertiary)',
color: 'var(--sn-text-primary)',
border: '1px solid var(--sn-border-primary)',
},
ghost: {
background: 'transparent',
color: 'var(--sn-text-secondary)',
},
};
const sizeStyles: Record<string, React.CSSProperties> = {
sm: { padding: '6px 12px', fontSize: 12 },
md: { padding: '8px 16px', fontSize: 14 },
lg: { padding: '12px 24px', fontSize: 16 },
};
return (
<button
onClick={onClick}
disabled={disabled}
style={{
...baseStyles,
...variantStyles[variant],
...sizeStyles[size],
}}
>
{children}
</button>
);
};
Input Component
interface InputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
type?: 'text' | 'number' | 'password';
disabled?: boolean;
}
export const Input: React.FC<InputProps> = ({
value,
onChange,
placeholder,
type = 'text',
disabled = false,
}) => {
return (
<input
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
disabled={disabled}
style={{
width: '100%',
padding: 'var(--sn-space-2) var(--sn-space-3)',
background: 'var(--sn-bg-tertiary)',
border: '1px solid var(--sn-border-primary)',
borderRadius: 'var(--sn-radius-md)',
color: 'var(--sn-text-primary)',
fontSize: 14,
outline: 'none',
transition: 'var(--sn-transition-fast)',
}}
/>
);
};
List Item Component
interface ListItemProps {
selected?: boolean;
onClick?: () => void;
children: React.ReactNode;
}
export const ListItem: React.FC<ListItemProps> = ({
selected = false,
onClick,
children,
}) => {
return (
<div
onClick={onClick}
style={{
padding: 'var(--sn-space-3)',
borderRadius: 'var(--sn-radius-md)',
background: selected ? 'var(--sn-accent-primary)' : 'transparent',
color: selected ? 'white' : 'var(--sn-text-primary)',
cursor: 'pointer',
transition: 'var(--sn-transition-fast)',
}}
>
{children}
</div>
);
};
Custom Hooks
Creating a Custom Hook
// src/hooks/useMyFeature.ts
import { useState, useCallback, useEffect } from 'react';
import { useCanvasStore } from '../state/useCanvasStore';
interface UseMyFeatureOptions {
initialValue?: string;
autoSave?: boolean;
}
interface UseMyFeatureReturn {
value: string;
setValue: (value: string) => void;
isLoading: boolean;
error: string | null;
}
export function useMyFeature(
options: UseMyFeatureOptions = {}
): UseMyFeatureReturn {
const { initialValue = '', autoSave = false } = options;
// Local state
const [value, setValueState] = useState(initialValue);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Store integration
const canvasId = useCanvasStore((s) => s.canvasId);
// Handlers
const setValue = useCallback((newValue: string) => {
setValueState(newValue);
setError(null);
if (autoSave) {
// Auto-save logic
}
}, [autoSave]);
// Effects
useEffect(() => {
// Initialization or subscription logic
return () => {
// Cleanup
};
}, [canvasId]);
return {
value,
setValue,
isLoading,
error,
};
}
Existing Hooks Reference
| Hook |
Purpose |
File |
useCanvasController |
Canvas control logic |
src/hooks/useCanvasController.ts |
useCanvasGestures |
Pan, zoom, touch |
src/hooks/useCanvasGestures.ts |
useCanvasKeyboardShortcuts |
Keyboard bindings |
src/hooks/useCanvasKeyboardShortcuts.ts |
useResponsive |
Viewport detection |
src/hooks/useResponsive.ts |
useWidgetCapabilities |
Capability checking |
src/hooks/useWidgetCapabilities.ts |
useWidgetSync |
Widget synchronization |
src/hooks/useWidgetSync.ts |
usePermission |
Permission checking |
src/hooks/usePermission.ts |
Component Best Practices
1. Memoization
// Memoize expensive computations
const processedData = useMemo(() => {
return data.map(item => expensiveProcess(item));
}, [data]);
// Memoize callbacks passed to children
const handleClick = useCallback(() => {
doSomething(value);
}, [value]);
// Memoize components that receive stable props
const MemoizedChild = React.memo(ChildComponent);
2. Error Boundaries
// Wrap components that might throw
<ErrorBoundary fallback={<ErrorFallback />}>
<RiskyComponent />
</ErrorBoundary>
3. Loading States
if (isLoading) {
return (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--sn-text-secondary)',
}}>
Loading...
</div>
);
}
4. Conditional Rendering with Visibility
// For panels that need to maintain state, use CSS instead of unmounting
<div style={{ display: isVisible ? 'block' : 'none' }}>
<ExpensivePanel />
</div>
Reference Files
- Theme tokens:
src/styles/theme-tokens.css
- Global styles:
src/index.css
- Example components:
src/components/PropertiesPanel.tsx, src/components/LayerPanel.tsx
- Hooks:
src/hooks/
- Stores:
src/state/