Search code examples
reactjssvgtailwind-cssmask

Next.js SVG Image Mask (Reusable component)


I've been trying all day to create a reusable Next.js SVG Image mask component, using only tailwind / inline styles for locally stored images. I want to be able to pass an svg into it and an image and output the masked image and be able to define the width, height via classNames... This is what I have so far, but I haven't had any luck...

My svg shape is just this trapazoidal shape:

<svg
    width="auto"
    height="100%"
    viewBox="0 0 313 709"
    fill="none"
>
   <path d="M0 210.338L314 0V507.419L0 709V210.338Z" fill="#404040"/>
</svg>

I'm hoping to invoke the component like this:

import svgShape from "@/assets/svg's/AuthImageShape.svg"
import SignInImage from "@/assets/images/SignInImage.png"

<MaskedImage svgPath={svgShape} imageSrc={SignInImage} /> 
// process(strip) svg of everything but it's path
export const extractPath = (svgString: string): string | null => {
    try {
        const parser = new DOMParser()
        const svgDoc = parser.parseFromString(svgString, "image/svg+xml")
        const pathElement = svgDoc.querySelector("path")
        if (pathElement) {
            return pathElement.outerHTML
        } else {
            console.error("Invalid SVG shape: No path element found")
            return null
        }
    } catch (error) {
        console.error("Error parsing SVG shape:", error)
        return null
    }
}

// Reusable component for masking images.
"use client"
import React, { useEffect, useState } from "react"
import { StaticImageData } from "next/image"

interface MaskedImageProps {
  svgPath: string
  imageSrc: any
  maskId?: string
  className?: string
}



const MaskedImage: React.FC<MaskedImageProps> = ({ svgPath, imageSrc, maskId = 'mask', className }) => {

const [pathString, setPathString] = useState<string | null>(null)

          useEffect(() => {
                if (!svgShape) {
                    console.error("No SVG shape provided")
                    return
                }

                const path = extractPath(svgShape)
                if (path) {
                    setPathString(path)
                } else {
                    console.error("Failed to extract path from SVG shape")
                }
            }, [svgShape])

        if (!pathString) {
            return null
        }

  return (
    <div className={`maskedImageContainer ${className}`}>
      <svg width="0" height="0">
        <defs>
          <clipPath id={maskId} clipPathUnits="objectBoundingBox">
            <path d={svgPath} fill="transparent" />
          </clipPath>
        </defs>
      </svg>
      <div style={{ clipPath: `url(#${maskId})` }}>
        <img src={imageSrc} alt="Masked" />
      </div>
    </div>
  );
};

export default MaskedImage;


Solution

  • The code uses the <svg> string as-is as the d attribute value for the <clipPath> element's <path>. We'd want to use the extracted string path from extractPath():

    <path d={pathString} fill="transparent" />
    

    Furthermore, extractPath() returns the <path> element XML string, but we'd actually want its d attribute value if we are to pass it into the d attribute:

    return pathElement.getAttribute('d');
    

    The <clipPath> has zero size because of the clipPathUnits="objectBoundingBox" set on it. this is because the parent <svg> has 0 width and height. We'd want to remove this attribute:

    <clipPath id={maskId}>
    

    We could use useMemo for better performance. We'd want to use the prop value, svgPath as a dependency, not the out-of-scope variable svgShape:

    const pathString = useMemo(() => {
      …
    }, [svgPath])
    

    // process(strip) svg of everything but it's path
    const extractPath = (svgString) => {
      try {
        const parser = new DOMParser();
        const svgDoc = parser.parseFromString(svgString, 'image/svg+xml');
        const pathElement = svgDoc.querySelector('path');
        if (pathElement) {
          return pathElement.getAttribute('d');
        } else {
          console.error('Invalid SVG shape: No path element found');
          return null;
        }
      } catch (error) {
        console.error('Error parsing SVG shape:', error);
        return null;
      }
    };
    
    const { useMemo } = React;
    
    const MaskedImage = ({ svgPath, imageSrc, maskId = 'mask', className }) => {
      const pathString = useMemo(() => {
        if (!svgShape) {
          console.error('No SVG shape provided');
          return;
        }
    
        const path = extractPath(svgShape);
        if (path) {
          return path;
        } else {
          console.error('Failed to extract path from SVG shape');
        }
      }, [svgPath]);
    
      if (!pathString) {
        return null;
      }
    
      return (
        <div className={`maskedImageContainer ${className}`}>
          <svg width="0" height="0">
            <defs>
              <clipPath id={maskId}>
                <path d={pathString} fill="transparent" />
              </clipPath>
            </defs>
          </svg>
          <div style={{ clipPath: `url(#${maskId})` }}>
            <img src={imageSrc} alt="Masked" />
          </div>
        </div>
      );
    };
    
    const svgShape = `<svg
        width="auto"
        height="100%"
        viewBox="0 0 313 709"
        fill="none"
    >
       <path d="M0 210.338L314 0V507.419L0 709V210.338Z" fill="#404040"/>
    </svg>`;
    
    const SignInImage = 'https://picsum.photos/1000/1000';
    
    ReactDOM.createRoot(document.getElementById('app')).render(
      <MaskedImage svgPath={svgShape} imageSrc={SignInImage} />
    );
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.3.1/umd/react.production.min.js" integrity="sha512-QVs8Lo43F9lSuBykadDb0oSXDL/BbZ588urWVCRwSIoewQv/Ewg1f84mK3U790bZ0FfhFa1YSQUmIhG+pIRKeg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.3.1/umd/react-dom.production.min.js" integrity="sha512-6a1107rTlA4gYpgHAqbwLAtxmWipBdJFcq8y5S/aTge3Bp+VAklABm2LO+Kg51vOWR9JMZq1Ovjl5tpluNpTeQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    
    <div id="app"></div>