Mantine Mask

Undolog

@gfazioli/mantine-mask

A Mantine extension spotlight mask wrapper for focusing any content with cursor-follow or static radial masks.

Installation

yarn add @gfazioli/mantine-mask

After installation import package styles at the root of your application:

import '@gfazioli/mantine-mask/styles.css';

You can import styles within a layer @layer mantine-mask by importing @gfazioli/mantine-mask/styles.layer.css file.

import '@gfazioli/mantine-mask/styles.layer.css';

Mask component

Mask wraps any content and applies a radial “spotlight” effect using CSS masking.

You can use it in two main ways:

  • Cursor spotlight: the mask follows the pointer (withCursorMask)
  • Static spotlight: the mask stays at a fixed position (maskX/maskY)

If you need the cursor spotlight to keep updating even when the pointer is outside the component, enable document-level tracking with trackPointerOnDocument.

Use maskRadius (or maskRadiusX/maskRadiusY) to control the spotlight size.

Quick mental model

  • The inside of the spotlight is visible
  • The outside fades to transparent (based on maskTransparencyStart/maskTransparencyEnd or maskFeather)
  • With invertMask, it is the opposite: the center becomes transparent and the outside stays visible

NOTE

  • animation controls how the mask follows the cursor when withCursorMask is enabled.
  • animation="lerp" uses easing
  • animation="none" follows instantly
  • activation="pointer" (or activation="hover") toggles the mask on pointer enter/leave.
Before
Animation
maskAngle (linear variant only)
Variant
Mask radius x
Mask radius y
Mask x
Mask y
Mask transparency start
Mask transparency end
Mask opacity
Easing
Cursor offset x
Cursor offset y
Clamp padding
Bg
import { Mask, type MaskProps } from '@gfazioli/mantine-mask';
import { Image } from '@mantine/core';

function Demo(props: MaskProps) {
  return (
    <Mask maskRadiusX={160} maskRadiusY={160}>
      <Image
        src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&auto=format&fit=crop"
        alt="Before"
        style={{ width: '100%', height: '100%', objectFit: 'cover' }}
      />
    </Mask>
  );
}

Key props (most common)

  • Position: withCursorMask, maskX, maskY, cursorOffsetX, cursorOffsetY
  • Pointer tracking: trackPointerOnDocument
  • Size: maskRadius, maskRadiusX, maskRadiusY
  • Feather: maskFeather (simple), or maskTransparencyStart + maskTransparencyEnd (advanced)
  • Visibility: maskOpacity, invertMask
  • Interaction: activation, active, onActiveChange
  • Motion: animation, easing
  • Bounds: clampToBounds, clampPadding

Convenience props

You can use convenience props to set common configurations quickly.

maskFeather

For example, maskFeather is a convenience prop that overrides maskTransparencyStart/maskTransparencyEnd.

  • maskFeather={0}: hard edge (start=end=100)
  • maskFeather={100}: full fade (start=0, end=100)

maskRadius

Another example is maskRadius which sets both maskRadiusX and maskRadiusY to the same value.

If you need more control, you can still use the lower-level props:

  • maskTransparencyStart and maskTransparencyEnd define where the fade starts/ends (0–100)
  • maskRadiusX and maskRadiusY create an elliptical spotlight
Before
Mask radius
Mask feather
import { Mask, type MaskProps } from '@gfazioli/mantine-mask';
import { Image } from '@mantine/core';

function Demo(props: MaskProps) {
  return (
    <Mask maskRadius={160} maskFeather={100}>
      <Image
        src="https://images.unsplash.com/photo-1542640244-7e672d6cef4e?w=800&auto=format&fit=crop"
        alt="Before"
        style={{ width: '100%', height: '100%', objectFit: 'cover' }}
      />
    </Mask>
  );
}

Basic usage

Wrap any content with Mask to apply the spotlight effect. In this example the spotlight follows the cursor (withCursorMask) and reveals the image under the mask.

Before
import { Mask } from '@gfazioli/mantine-mask';
import { Image } from '@mantine/core';

function Demo() {
  return (
    <Mask withCursorMask maskRadius={360}>
      <Image
        src="https://images.unsplash.com/photo-1519114056088-b877fe073a5e?w=800&auto=format&fit=crop"
        alt="Before"
        style={{ width: '100%', height: '100%', objectFit: 'cover' }}
      />
    </Mask>
  );
}

💡 Tip: keep the spotlight inside

When using withCursorMask, you can keep the spotlight inside the container with:

  • clampToBounds: prevents the center from going outside
  • clampPadding: adds extra padding from the edges

Document-level pointer tracking

By default, the cursor mask position is updated only while the pointer moves inside the component.

If you want the mask to follow the pointer across the entire document (the original behavior), enable:

  • withCursorMask
  • trackPointerOnDocument

NOTE

When trackPointerOnDocument is enabled, clampToBounds and clampPadding are ignored.

Before
import { Mask } from '@gfazioli/mantine-mask';
import { Image } from '@mantine/core';

function Demo() {
  return (
    <Mask withCursorMask trackPointerOnDocument activation="always" maskRadius={320}>
      <Image
        src="https://images.unsplash.com/photo-1542749191-320c458c8435?w=800&auto=format&fit=crop"
        alt="Before"
        style={{ width: '100%', height: '100%', objectFit: 'cover' }}
      />
    </Mask>
  );
}

Static mask origin

Before
import { Mask } from '@gfazioli/mantine-mask';
import { Image } from '@mantine/core';

function Demo() {
  return (
    <Mask withCursorMask={false} maskX={25} maskY={35}>
      <Image
        src="https://images.unsplash.com/photo-1542856391-010fb87dcfed?w=800&auto=format&fit=crop"
        alt="Before"
        style={{ width: '100%', height: '100%', objectFit: 'cover' }}
      />
    </Mask>
  );
}

Static mask with animation

You can animate the transition when changing maskX/maskY by setting the animation prop. In this example, the spotlight moves smoothly to the new position. The easing can be customized with the easing prop.

0.12

maskX: 25%, maskY: 35%

Before
import { useState } from 'react';
import { Mask } from '@gfazioli/mantine-mask';
import { Button, Group, Image, Stack, Text } from '@mantine/core';

function Demo() {
  const [position, setPosition] = useState({ x: 25, y: 35 });
  const [easing, setEasing] = useState(0.12);

  const randomize = () => {
    setPosition({
      x: Math.round(Math.random() * 100),
      y: Math.round(Math.random() * 100),
    });
  };

  return (
    <Stack>
      <Group gap="sm">
        <Button
          variant="default"
          onClick={() => {
            setPosition({ x: 25, y: 35 });
            setEasing(0.12);
          }}
        >
          Reset
        </Button>
        <Button onClick={randomize}>Random position</Button>
        <Slider labelAlwaysOn value={easing} onChange={setEasing} min={0.01} max={1} step={0.01} style={{ width: 200 }} />
        <Text size="sm" c="dimmed">
          maskX: {position.x}%, maskY: {position.y}%
        </Text>
      </Group>

      <Mask bg="black" withCursorMask={false} animation="lerp" easing={easing} maskX={position.x} maskY={position.y} maskRadius={320} radius={16}>
        <Image
          src="https://images.unsplash.com/photo-1571769267292-e24dfadebbdc?q=80&w=3580&auto=format&fit=crop"
          alt="Before"
          style={{ width: '100%', height: '100%', objectFit: 'cover' }}
        />
      </Mask>
    </Stack>
  );
}

Custom radius

Use maskRadius when you want a simple, circular spotlight. It sets both X and Y radii to the same value.

Before
import { Mask } from '@gfazioli/mantine-mask';
import { Image } from '@mantine/core';

function Demo() {
  return (
    <Mask withCursorMask maskRadius={180}>
      <Image
        src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&auto=format&fit=crop"
        alt="Before"
        style={{ width: '100%', height: '100%', objectFit: 'cover' }}
      />
    </Mask>
  );
}

Elliptical mask

If you need an oval shape, use maskRadiusX and maskRadiusY. This is useful for wide headers, cards, or panoramic images.

Before
import { Mask } from '@gfazioli/mantine-mask';
import { Image } from '@mantine/core';

function Demo() {
  return (
    <Mask withCursorMask maskRadiusX={420} maskRadiusY={180}>
      <Image
        src="https://plus.unsplash.com/premium_photo-1661306437817-8ab34be91e0c?w=800&auto=format&fit=crop"
        alt="Before"
        style={{ width: '100%', height: '100%', objectFit: 'cover' }}
      />
    </Mask>
  );
}

Variants

Mask supports two variants:

  • variant="radial" (default): a classic circular/elliptical spotlight
  • variant="linear": a linear “band” (useful for reveal stripes and scanner effects)

When you use variant="linear":

  • maskRadius controls the band thickness
  • maskTransparencyStart / maskTransparencyEnd (or maskFeather) control how soft the band edges are
  • maskAngle controls the band angle (in degrees)

Linear variant

Use variant="linear" to create a linear band instead of a radial spotlight. The band follows the cursor the same way as the radial variant.

Use maskAngle to set the direction (0–360).

Before
import { Mask } from '@gfazioli/mantine-mask';
import { Image } from '@mantine/core';

function Demo() {
  return (
    <Mask variant="linear" withCursorMask maskAngle={35} maskRadius={180} maskFeather={35}>
      <Image
        src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&auto=format&fit=crop"
        alt="Before"
        style={{ width: '100%', height: '100%', objectFit: 'cover' }}
      />
    </Mask>
  );
}

Inverted mask

invertMask flips what is visible: the center becomes transparent and the outside stays visible. It works well for “hole” effects or to reveal a background layer.

Before

Try to change the dark mode

import { useState } from 'react';
import { Mask } from '@gfazioli/mantine-mask';
import { Image } from '@mantine/core';

function Demo() {
  const [bg, setBg] = useState(false);
  return (
    <Stack>
      <Mask withCursorMask invertMask maskRadius={240} bg={bg ? 'white' : undefined}>
        <Image
          src="https://images.unsplash.com/photo-1542875272-2037d53b5e4d?w=800&auto=format&fit=crop"
          alt="Before"
          style={{ width: '100%', height: '100%', objectFit: 'cover' }}
        />
      </Mask>
      <Text>Try to change the dark mode</Text>
      <Switch
        label="Use background"
        checked={bg}
        onChange={(event) => setBg(event.currentTarget.checked)}
      />
    </Stack>
  );
}

Linear + inverted

You can combine variant="linear" with invertMask to cut a “hole band” through the content.

Before
import { Mask } from '@gfazioli/mantine-mask';
import { Image } from '@mantine/core';

function Demo() {
  return (
    <Mask
      variant="linear"
      invertMask
      withCursorMask
      maskAngle={90}
      maskRadius={180}
      maskFeather={30}
    >
      <Image
        src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&auto=format&fit=crop"
        alt="Before"
        style={{ width: '100%', height: '100%', objectFit: 'cover' }}
      />
    </Mask>
  );
}

NOTE

invertMask makes the center transparent. Transparent areas show whatever is behind the component. That is why the “glow” can look light in light mode and dark in dark mode.

Tip: set a background on the Mask container (for example with Mantine bg, or style={{ backgroundColor: ... }}) if you want a consistent look across themes.

Activation

activation controls when the cursor mask is considered “active”.

  • always: the mask is always enabled
  • hover: enabled while the pointer is over the component (mouseenter/leave)
  • pointer: same idea as hover, but based on pointer events (useful for touch/stylus)
  • focus: enabled while the component has keyboard focus

If you do not want any automatic behavior, you can control it yourself with the active prop.

Before
import { Mask } from '@gfazioli/mantine-mask';
import { Image } from '@mantine/core';

function Demo() {
  return (
    <Mask withCursorMask activation="hover" maskRadius={280}>
      <Image
        src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&auto=format&fit=crop"
        alt="Before"
        style={{ width: '100%', height: '100%', objectFit: 'cover' }}
      />
    </Mask>
  );
}

Activation can be handled automatically (with activation) or controlled manually:

  • Use activation for common interactions (always, hover, pointer, focus)
  • Use the active prop to fully control visibility (it overrides activation)
  • Use onActiveChange to react to internal activation events

Focus activation (accessibility)

If you use activation="focus", the component becomes keyboard-friendly. In that case Mask will apply a default tabIndex={0} (unless you provide a different tabIndex).

Any content

Mask does not care what you render inside. It can wrap images, cards, text blocks, or any custom React content.

Any content

Mask can wrap any React node, not just images.

import { Mask } from '@gfazioli/mantine-mask';
import { Box, Paper, Text } from '@mantine/core';

function Demo() {
  return (
    <Mask withCursorMask maskRadius={240}>
      <Paper p="lg" withBorder shadow="md" bg="violet.2">
        <Text fw={700} fz="lg">
          Any content
        </Text>
        <Text c="dimmed" mt="xs">
          Mask can wrap any React node, not just images.
        </Text>
        <Box mt="md" h={6} w="60%" bg="orange.4" />
      </Paper>
    </Mask>
  );
}

Use cases

Below are a couple of common patterns you can build by combining Mask with background images and a hard edge (maskFeather={0}).

Reveal

Use a different background on the container and “reveal” it through the spotlight.

Before
import { Mask } from '@gfazioli/mantine-mask';
import { Image } from '@mantine/core';

function Demo() {
  return (
    <Mask
      withCursorMask
      maskRadius={120}
      maskFeather={0}
      bg="url('https://images.unsplash.com/photo-1542749191-320c458c8435?w=800&auto=format&fit=crop') center/cover no-repeat"
    >
      <Image
        src="https://images.unsplash.com/photo-1476673160081-cf065607f449?w=800&auto=format&fit=crop"
        alt="Before"
        style={{ width: '100%', height: '100%', objectFit: 'cover' }}
      />
    </Mask>
  );
}

Zoom

Put a zoomed background image on the container and show it through the spotlight. This creates a simple “magnifier” effect.

Before
import { Mask } from '@gfazioli/mantine-mask';
import { Image } from '@mantine/core';

function Demo() {
  return (
    <Mask
      withCursorMask
      maskRadius={120}
      maskFeather={0}
      bg="url('https://images.unsplash.com/photo-1542749191-320c458c8435?w=800&auto=format&fit=cover') center no-repeat"
    >
      <Image
        src="https://images.unsplash.com/photo-1542749191-320c458c8435?w=800&auto=format&fit=crop"
        alt="Before"
        style={{ width: '100%', height: '100%', objectFit: 'cover' }}
      />
    </Mask>
  );
}

Disable mask

Another example is when you want to make part of your UI inaccessible while still showing a preview of what it could be.

Create Image to Video

Unlock the power of AI-driven video creation. Transform your images into captivating videos with just a few clicks. Perfect for marketers, content creators, and social media enthusiasts looking to elevate their visual storytelling.

import { Mask } from '@gfazioli/mantine-mask';
import { Image } from '@mantine/core';

function Demo() {
  return (
    <Stack>
      <Alert title="Your credits are running low" color="yellow" variant="light">
        Update your payment method to continue creating videos without interruptions.
        <Button variant="outline" size="xs" ml="md">
          Update Payment Method
        </Button>
      </Alert>
      <Mask h={400} variant="linear" maskAngle={0} maskY={40} maskTransparencyStart={0} maskOpacity={1}>
        <Paper shadow="md" withBorder p="md" radius="lg">
          <Stack>
            <Title>Create Image to Video</Title>
            <Text>
              Unlock the power of AI-driven video creation. Transform your images into captivating videos with just a few clicks. Perfect for marketers, content
              creators, and social media enthusiasts looking to elevate their visual storytelling.
            </Text>
            <Textarea disabled placeholder="Describe your video idea..." minRows={3} />
            <Button disabled>Create Video</Button>
          </Stack>
        </Paper>
      </Mask>
    </Stack>
  );
}