Summary
Add a useClipMask React hook to provide declarative clipping mask functionality using Two.js's mask property. This will enable developers to create masked effects, reveal animations, and viewport clipping without manually managing mask relationships.
Motivation
Two.js supports clipping masks through the shape.mask = clipPath property, where any shape or group can be masked by a Two.Polygon (or other shapes). However, there's currently no declarative React way to:
- Apply masks to shapes/groups
- Update masks when shapes change
- Clean up mask references on unmount
- Handle mask relationships across components
A React hook would make mask management declarative, type-safe, and integrated with React's lifecycle.
Two.js Clipping Mask Background
How Two.js masks work:
- Any Two.js object can have a
mask property: shape.mask = maskShape
- The mask's matrix is multiplied by the matrix of the shape it's masifying
- Common pattern:
group.mask = polygon to clip a group's contents
- Browser limitations: Currently most flexible with Two.Polygon as mask
- Affects all renderers: SVG (clipPath), Canvas (clip), WebGL (stencil buffer)
Example usage in vanilla Two.js:
const circle = two.makeCircle(0, 0, 50);
const mask = two.makePolygon(0, 0, 30);
circle.mask = mask;
Proposed API
Hook Signature
interface UseClipMaskOptions {
enabled?: boolean; // Toggle mask on/off
updateOnChange?: boolean; // Re-apply mask when deps change
}
interface ClipMaskControls {
applyMask: (target: RefShape | RefGroup, mask: RefShape | RefGroup) => void;
clearMask: (target: RefShape | RefGroup) => void;
hasMask: (target: RefShape | RefGroup) => boolean;
currentMask: RefShape | RefGroup | null;
}
function useClipMask(
targetRef?: RefObject<RefShape | RefGroup>,
maskRef?: RefObject<RefShape | RefGroup>,
options?: UseClipMaskOptions
): ClipMaskControls
Usage Examples
Basic - Imperative Style
import { Canvas, Circle, Rectangle, useClipMask } from 'react-two.js';
function ClippedShape() {
const shapeRef = useRef<RefCircle>(null);
const maskRef = useRef<RefRectangle>(null);
const { applyMask } = useClipMask();
useEffect(() => {
if (shapeRef.current && maskRef.current) {
applyMask(shapeRef.current, maskRef.current);
}
}, [applyMask]);
return (
<>
{/* The visible shape (will be clipped) */}
<Circle ref={shapeRef} radius={100} fill="blue" />
{/* The mask (defines visible area) */}
<Rectangle
ref={maskRef}
width={50}
height={150}
fill="transparent"
/>
</>
);
}
Declarative Style (Auto-apply)
function AutoClippedShape() {
const shapeRef = useRef<RefCircle>(null);
const maskRef = useRef<RefPolygon>(null);
// Automatically applies mask when both refs are available
useClipMask(shapeRef, maskRef);
return (
<>
<Circle ref={shapeRef} radius={100} fill="red" x={200} />
<Polygon ref={maskRef} sides={6} radius={60} x={200} />
</>
);
}
Animated Reveal Effect
function RevealAnimation() {
const imageRef = useRef<RefImage>(null);
const maskRef = useRef<RefCircle>(null);
const [radius, setRadius] = useState(0);
useClipMask(imageRef, maskRef);
useEffect(() => {
// Animate reveal
const interval = setInterval(() => {
setRadius(r => Math.min(r + 2, 200));
}, 16);
return () => clearInterval(interval);
}, []);
return (
<>
<Image ref={imageRef} src="/photo.jpg" />
<Circle ref={maskRef} radius={radius} />
</>
);
}
Dynamic Mask Switching
function DynamicMask() {
const shapeRef = useRef<RefRectangle>(null);
const circleMaskRef = useRef<RefCircle>(null);
const starMaskRef = useRef<RefStar>(null);
const [maskType, setMaskType] = useState<'circle' | 'star'>('circle');
const { applyMask, clearMask } = useClipMask();
useEffect(() => {
if (!shapeRef.current) return;
clearMask(shapeRef.current);
const mask = maskType === 'circle'
? circleMaskRef.current
: starMaskRef.current;
if (mask) {
applyMask(shapeRef.current, mask);
}
}, [maskType, applyMask, clearMask]);
return (
<>
<button onClick={() => setMaskType('circle')}>Circle Mask</button>
<button onClick={() => setMaskType('star')}>Star Mask</button>
<Rectangle ref={shapeRef} width={200} height={200} fill="blue" />
<Circle ref={circleMaskRef} radius={80} />
<Star ref={starMaskRef} outerRadius={80} innerRadius={40} />
</>
);
}
Group Masking (Advanced)
function MaskedGroup() {
const groupRef = useRef<RefGroup>(null);
const maskRef = useRef<RefPolygon>(null);
useClipMask(groupRef, maskRef);
return (
<>
<Group ref={groupRef}>
{/* Everything in this group will be clipped */}
<Circle radius={50} fill="red" x={-30} />
<Circle radius={50} fill="blue" x={30} />
<Rectangle width={100} height={20} fill="green" y={60} />
</Group>
<Polygon ref={maskRef} sides={6} radius={100} />
</>
);
}
Implementation Phases
Phase 1: Core Hook Structure
Files: lib/hooks/useClipMask.ts (new file)
Deliverable: Hook skeleton with TypeScript types
Phase 2: Apply/Clear Mask Logic
Files: lib/hooks/useClipMask.ts
Deliverable: Working imperative mask application
Phase 3: Declarative Auto-Apply
Files: lib/hooks/useClipMask.ts
Deliverable: Declarative mask application with auto-cleanup
Phase 4: Advanced Features & Edge Cases
Files: lib/hooks/useClipMask.ts
Deliverable: Robust mask handling with edge case coverage
Phase 5: Integration & Export
Files: lib/main.ts, lib/hooks/index.ts
Deliverable: Hook available in public API
Phase 6: Testing
Files: lib/hooks/__tests__/useClipMask.test.tsx (new file)
Deliverable: Comprehensive test coverage
Phase 7: Documentation & Examples
Files: README.md, src/App.tsx, documentation site
Deliverable: Complete documentation with examples
Technical Considerations
Two.js Mask Implementation
Core mechanism:
// Two.js internal behavior
shape.mask = maskShape;
// Renderer-specific implementation:
// SVG: Uses <clipPath> element
// Canvas: Uses ctx.clip()
// WebGL: Uses stencil buffer
Matrix transformations:
- Mask's transformation matrix is multiplied by target's matrix
- Both mask and target positions/rotations affect final clipping region
- Masks inherit parent group transformations
Design Decisions
- Hook-based approach: Follows React patterns, composable, reusable
- Dual API: Both imperative (
applyMask) and declarative (auto-apply with refs)
- Type-safe: Full TypeScript support for all shapes/groups
- Lifecycle-aware: Automatic cleanup on unmount
- Flexible: Works with any Two.js shape or group
- Ref-based: Integrates with existing component ref patterns
- Optional automation: Can be fully manual or fully automatic
Browser & Renderer Limitations
SVG Renderer:
- Most mature clipping support
- Supports nested clipPaths (some browser limitations)
- Complex shapes work well
Canvas Renderer:
- Uses
ctx.clip() for clipping
- Some limitations with complex paths
- Performance considerations for frequent updates
WebGL Renderer:
- Uses stencil buffer for masking
- Limited to simpler mask shapes
- Performance varies by GPU
Performance Considerations
- Masks add rendering overhead (especially Canvas/WebGL)
- Frequent mask changes can be expensive
- Consider reusing mask shapes when possible
- Complex masks (many vertices) impact performance
- Group masking affects all children
Alternative Approaches Considered
1. Mask as Component Prop:
<Circle radius={50} mask={maskRef} />
Pros: Declarative, concise
Cons: Breaks existing component API, requires ref before render
Decision: Hook provides more flexibility without breaking changes
2. MaskProvider Context:
<MaskProvider mask={maskShape}>
<Circle radius={50} />
</MaskProvider>
Pros: Applies mask to multiple children easily
Cons: Less explicit, harder to control individual masks
Decision: Hook is more explicit and flexible
3. Component Wrapper:
<Masked mask={maskShape}>
<Circle radius={50} />
</Masked>
Pros: Very declarative, clear hierarchy
Cons: Adds extra component layer, complex implementation
Decision: Hook is simpler and more performant
Resources
Success Criteria
Labels: enhancement, feature request, hooks
Milestone: v0.3.0
Summary
Add a
useClipMaskReact hook to provide declarative clipping mask functionality using Two.js's mask property. This will enable developers to create masked effects, reveal animations, and viewport clipping without manually managing mask relationships.Motivation
Two.js supports clipping masks through the
shape.mask = clipPathproperty, where any shape or group can be masked by a Two.Polygon (or other shapes). However, there's currently no declarative React way to:A React hook would make mask management declarative, type-safe, and integrated with React's lifecycle.
Two.js Clipping Mask Background
How Two.js masks work:
maskproperty:shape.mask = maskShapegroup.mask = polygonto clip a group's contentsExample usage in vanilla Two.js:
Proposed API
Hook Signature
Usage Examples
Basic - Imperative Style
Declarative Style (Auto-apply)
Animated Reveal Effect
Dynamic Mask Switching
Group Masking (Advanced)
Implementation Phases
Phase 1: Core Hook Structure
Files:
lib/hooks/useClipMask.ts(new file)lib/hooks/directory for utility hooksUseClipMaskOptionsClipMaskControlsuseRefto track current mask relationshipuseTwo()context integrationDeliverable: Hook skeleton with TypeScript types
Phase 2: Apply/Clear Mask Logic
Files:
lib/hooks/useClipMask.tsapplyMask(target, mask)method:target.mask = maskin Two.jsclearMask(target)method:target.mask = nullhasMask(target)utility:currentMaskgetter:Deliverable: Working imperative mask application
Phase 3: Declarative Auto-Apply
Files:
lib/hooks/useClipMask.tstargetRefandmaskRefparameters to hookuseEffectfor auto-apply:applyMaskwhen both availableenabledoption:Deliverable: Declarative mask application with auto-cleanup
Phase 4: Advanced Features & Edge Cases
Files:
lib/hooks/useClipMask.tsupdateOnChangeoption:Deliverable: Robust mask handling with edge case coverage
Phase 5: Integration & Export
Files:
lib/main.ts,lib/hooks/index.tslib/hooks/index.tsfor hook exportsuseClipMaskfromlib/main.tsDeliverable: Hook available in public API
Phase 6: Testing
Files:
lib/hooks/__tests__/useClipMask.test.tsx(new file)applyMask()sets mask propertyclearMask()removes maskhasMask()returns correct booleanDeliverable: Comprehensive test coverage
Phase 7: Documentation & Examples
Files:
README.md,src/App.tsx, documentation siteuseClipMasksection to main READMEDeliverable: Complete documentation with examples
Technical Considerations
Two.js Mask Implementation
Core mechanism:
Matrix transformations:
Design Decisions
applyMask) and declarative (auto-apply with refs)Browser & Renderer Limitations
SVG Renderer:
Canvas Renderer:
ctx.clip()for clippingWebGL Renderer:
Performance Considerations
Alternative Approaches Considered
1. Mask as Component Prop:
Pros: Declarative, concise
Cons: Breaks existing component API, requires ref before render
Decision: Hook provides more flexibility without breaking changes
2. MaskProvider Context:
Pros: Applies mask to multiple children easily
Cons: Less explicit, harder to control individual masks
Decision: Hook is more explicit and flexible
3. Component Wrapper:
Pros: Very declarative, clear hierarchy
Cons: Adds extra component layer, complex implementation
Decision: Hook is simpler and more performant
Resources
Success Criteria
useClipMaskhook successfully applies masks to shapes/groupsLabels: enhancement, feature request, hooks
Milestone: v0.3.0