Radial animated focus effect with mask-image in React TS

Viewed 145

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;
3 Answers

Check out solution with styled components Code sandbox

import React, { useEffect } from "react";
import styled, { createGlobalStyle } from "styled-components";

export const GlobalStyle = createGlobalStyle`
  body {
    display: flex;
    align-items: center;
    justify-content: center;
  }

/* custom properties */
  :root {  
    --focal-size: { 
    syntax: "<length-percentage>";
    initial-value: 100%;
    inherits: false;
  }
  --mouse-x: center;
  --mouse-y: center;
  --backdrop-color: hsl(200 50% 0% / 50%);
  --backdrop-blur-strength: 10px;
}
`;

const Wrapper = styled.div`
  height: 400px;
  width: 400px;
  background: conic-gradient(
    from -0.5turn at bottom right,
    deeppink,
    cyan,
    rebeccapurple
  );
`;
const FocusZoom = styled.div`
  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 0.3s ease;
`;

function App(bool) {
  useEffect(() => {
    const zoom = document.getElementById("zoomId");

    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));
    toggleSpotlight();
  }, []);

  return (
    <Wrapper>
      <h1>
        Press <kbd>Opt/Alt</kbd> or touch for a spotlight effect
      </h1>

      <FocusZoom id="zoomId"></FocusZoom>
    </Wrapper>
  );
}

export default App;


Also, ensure you have global styles & component imported in app file.

import Test, { GlobalStyle } from "./test";

export default function App() {
  return (
    <div className="App">
      <GlobalStyle />
      <Test />
    </div>
  );
}

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.
}

Instead of getElementById you should use the useRef hook

Related