Search code examples
cssreactjstypescriptstyled-components

Radial animated focus effect with mask-image in React TS


I am recreating this Radial animated focus effect with mask-image: Codepen I know I can just copy&paste the CSS into a .css file but I want to achieve the same result with a styled component. For that, I declared the CSS in my styled component and apply it. But I am not sure why nothing happens at all and what should I use instead of getElementById as manual DOM manipulation is bad practice?

App.tsx

import React from "react";
import styled from "styled-components";

const Property = styled.div`
  @property --focal-size {
    syntax: "<length-percentage>";
    initial-value: 100%;
    inherits: false;
  }
`;

const FocusZoom = styled.div`
--mouse-x: center;
  --mouse-y: center;
  --backdrop-color: hsl(200 50% 0% / 50%); /* can't be opaque */
  --backdrop-blur-strength: 10px;
  
  position: fixed;
  touch-action: none;
  inset: 0;
  background-color: var(--backdrop-color);
  backdrop-filter: blur(var(--backdrop-blur-strength));
  
  mask-image: radial-gradient(
    circle at var(--mouse-x) var(--mouse-y), 
    transparent var(--focal-size), 
    black 0%
  );
  
  transition: --focal-size .3s ease;
  
  /*  debug/grok the gradient mask image here   */
/*   background-image: radial-gradient(
    circle, 
    transparent 100px, 
    black 0%
  ); */
}
`;

function App(bool: boolean) {
  const zoom: Element = document.querySelector("focus-zoom");

  const toggleSpotlight = (bool) =>
    zoom.style.setProperty("--focal-size", bool ? "15vmax" : "100%");

  window.addEventListener("pointermove", (e) => {
    zoom.style.setProperty("--mouse-x", e.clientX + "px");
    zoom.style.setProperty("--mouse-y", e.clientY + "px");
  });

  window.addEventListener("keydown", (e) => toggleSpotlight(e.altKey));
  window.addEventListener("keyup", (e) => toggleSpotlight(e.altKey));
  window.addEventListener("touchstart", (e) => toggleSpotlight(true));
  window.addEventListener("touchend", (e) => toggleSpotlight(false));

  return (
    <>
      <h1>
        Press <kbd>Opt/Alt</kbd> or touch for a spotlight effect
      </h1>
      <FocusZoom></FocusZoom>
    </>
  );
}

export default App;

Solution

  • As mentioned by others, we can simply refer to a DOM element in the React component template by using a useRef hook:

    function App() {
      // Get an imperative reference to a DOM element
      const zoomRef = useRef<HTMLDivElement>(null);
    
      const toggleSpotlight = (bool: boolean) =>
        // To get the DOM element, use the .current property of the ref
        zoomRef.current?.style.setProperty(
          "--focal-size",
          bool ? "15vmax" : "100%"
        );
    
      // Etc. including event listeners
    
      return (
        <>
          <h1>
            Press <kbd>Opt/Alt</kbd> or touch for a spotlight effect
          </h1>
          <FocusZoom ref={zoomRef} /> {/* Pass the reference to the special ref prop */}
        </>
      );
    }
    

    Demo: https://codesandbox.io/s/exciting-flower-349b48?file=/src/App.tsx


    A more intensive solution could leverage styled-components props adaptation to replace the calls to zoom.style.setProperty(), as described in Jumping Text in React with styled component

    In particular, this can help replace the use of CSS variables.

    Except for --focal-size unfortunately, which is configured with a transition.

    const FocusZoom = styled.div<{
      focalSize: string; // Specify the extra styling props for adaptation
      pointerPos: { x: string; y: string };
    }>`
      --focal-size: ${(props) => props.focalSize};
    
      position: fixed;
      touch-action: none;
      inset: 0;
      background-color: hsl(200 50% 0% / 50%);
      backdrop-filter: blur(10px);
    
      mask-image: radial-gradient(
        circle at ${(props) => props.pointerPos.x + " " + props.pointerPos.y},
        transparent var(--focal-size),
        black 0%
      );
    
      transition: --focal-size 0.3s ease;
    `;
    
    function App() {
      // Store all dynamic values into state
      const [focalSize, setFocalSize] = useState("100%");
      const [pointerPosition, setPointerPosition] = useState({
        x: "center",
        y: "center"
      });
    
      const toggleSpotlight = (bool: boolean) =>
        // Change the state instead of messing directly with the DOM element
        setFocalSize(bool ? "15vmax" : "100%");
    
      // Etc. including event listeners
    
      return (
        <>
          <h1>
            Press <kbd>Opt/Alt</kbd> or touch for a spotlight effect
          </h1>
          {/* Pass the states to the styled component */}
          <FocusZoom focalSize={focalSize} pointerPos={pointerPosition} />
        </>
      );
    }
    

    Demo: https://codesandbox.io/s/frosty-swirles-jdbcte?file=/src/App.tsx

    This solution might be overkill for such case where the values change all the time (especially the mouse position), but it decouples the logic from the style implementation (the component does not know whether CSS variables are used or not).


    Side note: for the event listeners, make sure to attach them only once (typically with a useEffect(cb, []) with an empty dependency array), and to remove them when the component is unmounted (typically by returning a clean up function from the useEffect callback).

    You could also use useEvent from react-use for example, which hendles all that directly:

    React sensor hook that subscribes a handler to events.

    import { useEvent } from "react-use";
    
    function App() {
      // Attaches to window and takes care of removing on unmount
      useEvent("pointermove", (e: PointerEvent) =>
        setPointerPosition({ x: e.clientX + "px", y: e.clientY + "px" })
      );
    
      // Etc.
    }