Skip to content

Feature Request: Add <SVG> component for loading and interpreting SVG files #8

@jonobr1

Description

@jonobr1

Summary

Add a declarative <SVG> React component to load and interpret SVG files using Two.js's load() method. This will enable developers to easily import SVG artwork created in tools like Adobe Illustrator directly into their Two.js scenes using familiar React patterns.

Motivation

Two.js provides powerful SVG interpretation capabilities via the two.load() method, which can:

  • Load external SVG files from URLs
  • Parse inline SVG markup strings
  • Convert SVG elements into native Two.js shapes
  • Enable designers to create artwork in vector editors and import directly

However, the current imperative API requires manual XHR handling and callback management. A React component would make this functionality declarative, easy to use, and consistent with the library's existing component patterns.

Proposed API

Component Signature

interface SVGProps {
  // Source (one required)
  src?: string;           // URL to .svg file
  content?: string;       // Inline SVG markup string
  
  // Positioning & Transform
  x?: number;
  y?: number;
  scale?: number;
  rotation?: number;
  
  // Callbacks
  onLoad?: (group: RefGroup, svg: SVGElement | SVGElement[]) => void;
  onError?: (error: Error) => void;
  
  // Loading behavior
  shallow?: boolean;      // Flatten groups when interpreting
  
  // Group properties (fill, stroke, opacity, etc.)
  fill?: string;
  stroke?: string;
  opacity?: number;
  visible?: boolean;
  linewidth?: number;
  // ... all Two.Group properties
}

export type RefSVG = RefGroup;

export const SVG: React.ForwardRefExoticComponent<
  SVGProps & React.RefAttributes<RefSVG>
>;

Basic Usage Example

import { Canvas, SVG } from 'react-two.js';

function App() {
  return (
    <Canvas fullscreen>
      {/* Load from external URL */}
      <SVG src="/assets/logo.svg" x={100} y={100} />
      
      {/* Load inline SVG markup */}
      <SVG 
        content={`
          <svg viewBox="0 0 100 100">
            <circle cx="50" cy="50" r="40" fill="red" />
          </svg>
        `}
        x={300} 
        y={100}
      />
    </Canvas>
  );
}

With Loading State

function Logo() {
  const [loaded, setLoaded] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  
  if (error) {
    return <Text value={`Error: ${error.message}`} />;
  }
  
  return (
    <>
      {!loaded && <Text value="Loading..." />}
      <SVG 
        src="/logo.svg"
        x={200}
        y={200}
        onLoad={(group, svg) => {
          console.log('Loaded:', group.children.length, 'objects');
          setLoaded(true);
        }}
        onError={setError}
      />
    </>
  );
}

Advanced - Ref Access & Animation

function AnimatedSVG() {
  const svgRef = useRef<RefSVG>(null);
  
  useFrame((elapsed) => {
    if (svgRef.current) {
      svgRef.current.rotation = Math.sin(elapsed) * 0.5;
      svgRef.current.scale = 1 + Math.sin(elapsed * 2) * 0.1;
    }
  });
  
  const handleLoad = (group, svg) => {
    // Manipulate loaded SVG objects
    group.children.forEach((child, i) => {
      child.opacity = 0;
      // Fade in with delay
      setTimeout(() => {
        child.opacity = 1;
      }, i * 100);
    });
  };
  
  return (
    <SVG 
      ref={svgRef}
      src="/animated-icon.svg"
      x={400}
      y={300}
      onLoad={handleLoad}
    />
  );
}

With Fallback Content

function SVGWithFallback() {
  return (
    <SVG src="/icon.svg" x={100} y={100}>
      {/* Children shown while loading (optional pattern) */}
      <Circle radius={20} fill="gray" opacity={0.5} />
    </SVG>
  );
}

Implementation Phases

Phase 1: Core Component Structure

Files: lib/SVG.tsx (new file)

  • Create SVG.tsx with React component boilerplate
  • Define SVGProps and RefSVG TypeScript interfaces
  • Implement basic component structure with forwardRef
  • Add props validation (require either src or content)
  • Set up state management for:
    • ref - The loaded Group instance
    • loading - Loading state (boolean)
    • error - Error state
  • Integrate with useTwo() context

Deliverable: Basic component shell with TypeScript types


Phase 2: SVG Loading Logic

Files: lib/SVG.tsx

  • Implement useEffect for loading SVG when src or content changes
  • Call two.load(pathOrSVGContent, callback) with appropriate parameter
  • Handle loading from URL (when src prop provided)
  • Handle inline SVG (when content prop provided)
  • Store returned Group in component state
  • Implement callback handling:
    • Invoke onLoad prop with (group, svg) when successful
    • Invoke onError prop when loading fails
  • Handle cleanup on unmount

Implementation pattern:

useEffect(() => {
  if (!two) return;
  
  const source = src || content;
  if (!source) return;
  
  try {
    const group = two.load(source, (loadedGroup, svg) => {
      set(loadedGroup);
      onLoad?.(loadedGroup, svg);
    });
    
    // Store group reference immediately (even though empty)
    set(group);
  } catch (err) {
    onError?.(err);
  }
  
  return () => {
    // Cleanup
  };
}, [two, src, content]);

Deliverable: Working SVG loading with async handling


Phase 3: Group Integration & Positioning

Files: lib/SVG.tsx

  • Add loaded Group to parent using existing patterns (see Group.tsx)
  • Implement position props (x, y)
  • Implement transform props (scale, rotation)
  • Apply Group properties (fill, stroke, opacity, etc.)
  • Handle property updates when props change
  • Ensure loaded Group is added to scene correctly
  • Implement proper removal on unmount

Pattern to follow:

useEffect(() => {
  if (parent && ref) {
    parent.add(ref);
    return () => {
      parent.remove(ref);
    };
  }
}, [parent, ref]);

useEffect(() => {
  if (ref) {
    if (typeof x === 'number') ref.translation.x = x;
    if (typeof y === 'number') ref.translation.y = y;
    if (typeof scale === 'number') ref.scale = scale;
    // ... other properties
  }
}, [ref, x, y, scale, ...]);

Deliverable: Positioned and styled SVG groups in the scene


Phase 4: Error Handling & Edge Cases

Files: lib/SVG.tsx

  • Add error handling for:
    • Network errors (404, timeout, etc.)
    • Invalid SVG markup
    • Missing two instance
    • Both src and content provided (warn or prefer one)
    • Neither src nor content provided
  • Implement error state management
  • Add TypeScript error types
  • Handle XHR failures gracefully
  • Add console warnings for common mistakes
  • Test with various edge cases

Deliverable: Robust error handling and validation


Phase 5: Ref Forwarding & Advanced Features

Files: lib/SVG.tsx

  • Implement useImperativeHandle for ref forwarding
  • Expose loaded Group via ref
  • Add support for shallow prop (pass to interpret)
  • Handle loading state transitions properly
  • Ensure ref updates when SVG loads
  • Support changing src/content dynamically (reload)
  • Add optional children support (fallback content while loading)

Deliverable: Full ref support and advanced features


Phase 6: Integration & Export

Files: lib/main.ts, lib/SVG.tsx

  • Export SVG component from lib/main.ts
  • Export RefSVG type from lib/main.ts
  • Export SVGProps interface (optional, for advanced users)
  • Verify tree-shaking works correctly
  • Update package exports if needed

Deliverable: Component available in public API


Phase 7: Testing

Files: lib/__tests__/SVG.test.tsx (new file)

  • Test component rendering with src prop
  • Test component rendering with content prop
  • Test loading state transitions
  • Test onLoad callback invocation
  • Test onError callback invocation
  • Test positioning (x, y, scale, rotation)
  • Test property updates (fill, stroke, etc.)
  • Test ref forwarding
  • Test dynamic src changes (reload behavior)
  • Test cleanup on unmount
  • Mock two.load() for isolated testing
  • Test error scenarios (404, invalid SVG, etc.)

Deliverable: Comprehensive test coverage


Phase 8: Documentation & Examples

Files: README.md, src/App.tsx, documentation site

  • Add <SVG> section to main README
  • Document all props and their types
  • Create interactive examples in dev app:
    • Basic SVG loading from URL
    • Inline SVG content
    • Loading states and error handling
    • Animated SVG with ref
    • Dynamic SVG switching
  • Add usage patterns and best practices
  • Document limitations (e.g., SVG features not supported by Two.js)
  • Include troubleshooting section
  • Add TypeScript usage examples
  • Link to Two.js SVG interpretation docs

Deliverable: Complete documentation and working examples


Technical Considerations

How Two.js load() Works

Two.js provides two.load(pathOrSVGContent, callback) which:

Parameters:

  • pathOrSVGContent: String - URL path ending in .svg OR inline SVG markup
  • callback: Optional (group, svg) => void - Called after loading completes

Behavior:

  • Returns a Two.Group immediately (empty initially)
  • If path ends with .svg, fetches file via XHR
  • Otherwise, treats string as inline SVG markup
  • Parses SVG into DOM elements using temporary container
  • Calls two.interpret() on each SVG child element
  • Populates returned Group with interpreted Two.js objects
  • Invokes callback with (group, svgElements) when complete

Related Methods:

  • two.interpret(svgElement, shallow, add) - Convert SVG DOM to Two.js shapes
  • two.xhr(path, callback) - Internal XHR utility

Design Decisions

  1. Component-based: Matches existing library patterns (Circle, Rectangle, etc.)
  2. Declarative: SVG loading feels like any other React component
  3. Async-friendly: Manages loading state internally, exposes via callbacks
  4. Dual source: Supports both external URLs (src) and inline markup (content)
  5. Group wrapper: Returns Group (like Two.js), inherits all Group properties
  6. Ref forwarding: Provides imperative access for animations
  7. Error handling: Graceful degradation with onError callback
  8. TypeScript first: Full type safety for SVG properties
  9. Reload support: Changing src/content triggers new load

Alternative Approaches Considered

1. Hook-based (useSVG)

const { group, loading, error } = useSVG('/logo.svg');

Pros: More flexible, separates loading from rendering
Cons: Less declarative, doesn't match component patterns in library
Decision: Component approach is more consistent with existing API

2. Canvas-level loading

<Canvas svgs={{ logo: '/logo.svg' }}>
  <SVGInstance name="logo" x={100} y={100} />
</Canvas>

Pros: Centralized loading, could implement resource management
Cons: Complex, breaks component composition, adds global state
Decision: Component-level loading is simpler and more flexible

3. Children as SVG content

<SVG>
  <svg>
    <circle cx="50" cy="50" r="40" />
  </svg>
</SVG>

Pros: JSX-native SVG syntax
Cons: Requires parsing React children as SVG, complex, non-standard
Decision: String-based content prop is simpler and clearer

Performance Considerations

  • SVG loading is async and non-blocking
  • Group returned immediately (can be positioned before content loads)
  • XHR requests are not cancelled on unmount (Two.js limitation)
    • Component should track mounted state to avoid setState on unmounted component
  • Large SVGs may cause parsing delays (Two.js limitation)
  • Consider memoization for content prop to avoid re-parsing

Browser Compatibility

  • Requires XHR support (all modern browsers)
  • Relies on Two.js SVG interpretation (SVG 1.1 subset)
  • Not all SVG features supported by Two.js (filters, animations, etc.)

Known Limitations

Two.js SVG interpretation has limitations:

  • Not all SVG elements supported (see Two.js docs)
  • SVG filters and effects not interpreted
  • SMIL animations ignored (use Two.js animation instead)
  • Text rendering may differ from original SVG
  • Embedded images may need special handling

Resources

Success Criteria

  • <SVG> component successfully loads external SVG files
  • Component handles inline SVG markup strings
  • Loading and error states are managed properly
  • onLoad and onError callbacks work correctly
  • Positioning and transforms apply to loaded SVG groups
  • Group properties (fill, stroke, etc.) cascade to loaded content
  • Ref forwarding provides access to the loaded Group
  • Dynamic src/content changes trigger reload
  • Full TypeScript support with proper types
  • Comprehensive tests with good coverage
  • Documentation includes examples and API reference
  • Works with all existing components and hooks
  • No breaking changes to existing API
  • Performance equivalent to direct two.load() usage

Labels: enhancement, feature request, svg, component
Milestone: v0.3.0

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions