Mantine Mask

Logo

@gfazioli/mantine-mask

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

What's new in v1.0

Breaking changes:

  • Internal utility functions (clampValue, getLinearCenterPercent, normalizeFeather, parseAngleDegrees) are no longer exported from the public API. If you were importing them, copy them into your project.

New features:

  • Responsive maskRadiusmaskRadius, maskRadiusX, and maskRadiusY now accept Mantine breakpoint objects ({ base: 120, sm: 200, md: 320 }). The spotlight size adapts to the viewport using CSS media queries with no JavaScript re-renders.
  • maskTransition — CSS transition for smooth fade-in/fade-out when active changes (e.g. maskTransition="opacity 400ms ease").
  • onPositionChange — callback that exposes the current spotlight position { x, y } as it moves.
  • Mask.Group — compound component that synchronizes the cursor position across multiple Mask children for coordinated spotlight effects.
  • maskSmoothing — eased multi-stop gradients that eliminate the hard edge / bright ring artifact in both radial and linear variants.
  • New type exportsMaskVariant, MaskActivation, and MaskAnimation types are now exported.
  • Bug fixes — fixed stale closures in document-level pointer tracking, fixed JSDoc default values for radius and clampToBounds.

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

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>
  );
}

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>
  );
}

💡 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>
  );
}

Gradient smoothing

By default, CSS gradients use linear interpolation between color stops, which can produce a visible "ring" or hard edge — especially when maskTransparencyStart is high (radial) or when the linear variant has a narrow band. Enable maskSmoothing to replace the 2-stop gradient with an eased multi-stop gradient that creates a much smoother fade.

Without smoothing

Without

With smoothing

With
import { Mask } from '@gfazioli/mantine-mask';
import { Image, SimpleGrid, Text } from '@mantine/core';

function Demo() {
  return (
    <SimpleGrid cols={2}>
      <div>
        <Text fw={500} mb="xs" ta="center">Without smoothing</Text>
        <Mask maskRadius={120} maskTransparencyStart={70}>
          <Image src="..." style={{ width: '100%', height: 300, objectFit: 'cover' }} />
        </Mask>
      </div>
      <div>
        <Text fw={500} mb="xs" ta="center">With smoothing</Text>
        <Mask maskSmoothing maskRadius={120} maskTransparencyStart={70}>
          <Image src="..." style={{ width: '100%', height: 300, objectFit: 'cover' }} />
        </Mask>
      </div>
    </SimpleGrid>
  );
}

Responsive maskRadius

maskRadius, maskRadiusX, and maskRadiusY accept responsive objects with breakpoint keys. The spotlight size adapts to the viewport using CSS media queries — no JavaScript re-renders.

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

function Demo() {
  return (
    <Mask withCursorMask maskRadius={{ base: 120, sm: 200, md: 320, lg: 480 }}>
      <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>
  );
}

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

    Implementation detail

    When activation is not set to 'always', the component maintains a Box wrapper even when the mask is inactive. This wrapper is necessary to handle activation events (hover, pointer, focus). When activation='always' but active={false} is set externally, only the children are rendered without any wrapper.

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

Mask transition

Use maskTransition to animate the mask appearance when active changes. Instead of appearing/disappearing instantly, the mask fades in and out using a CSS transition.

This works great with activation modes like hover or pointer.

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

function Demo() {
  return (
    <Mask
      withCursorMask
      activation="hover"
      maskRadius={320}
      maskTransition="opacity 400ms ease"
    >
      <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>
  );
}

Position tracking

Use onPositionChange to receive the current spotlight position as it moves. This is useful for synchronizing other UI elements (tooltips, labels, cursors) with the spotlight.

When withCursorMask is enabled, positions are in pixels. When using static coordinates, positions are in percentages.

Before
x: 0px, y: 0px
import { useState } from 'react';
import { Mask } from '@gfazioli/mantine-mask';
import { Code, Image, Stack } from '@mantine/core';

function Demo() {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  return (
    <Stack>
      <Mask withCursorMask maskRadius={200} onPositionChange={setPosition}>
        <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>
      <Code>
        x: {Math.round(position.x)}px, y: {Math.round(position.y)}px
      </Code>
    </Stack>
  );
}

Mask.Group

Mask.Group synchronizes the cursor position across multiple Mask components. Each Mask inside the group keeps its own independent configuration (radius, variant, inversion, feathering...), but they all follow the same pointer — something you cannot achieve with a single Mask wrapping everything.

Use cases:

  • A grid where each card has a different mask effect but all react to the same cursor
  • Dashboard layouts where multiple panels reveal content in sync
  • Landing pages with coordinated spotlight animations across sections

In the example below, each card uses a different mask configuration (radial, linear, inverted, elliptical), yet all spotlights follow the same cursor position:

Mountain
Forest

Inverted mask

The center is transparent, the outside is visible.

Elliptical mask

Wide horizontal spotlight with soft feathering.

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

function Demo() {
  return (
    <Mask.Group>
      <SimpleGrid cols={2}>
        {/* Radial spotlight */}
        <Mask withCursorMask maskRadius={120}>
          <Image src="..." alt="Mountain" style={{ width: '100%', height: 200, objectFit: 'cover' }} />
        </Mask>

        {/* Linear band */}
        <Mask withCursorMask variant="linear" maskAngle={0} maskRadius={80}>
          <Image src="..." alt="Forest" style={{ width: '100%', height: 200, objectFit: 'cover' }} />
        </Mask>

        {/* Inverted radial */}
        <Mask withCursorMask maskRadius={100} invertMask>
          <Box bg="blue.9" h={200} p="xl">
            <Text c="white" fw={700} fz="lg">Inverted mask</Text>
            <Text c="blue.2" fz="sm">The center is transparent, the outside is visible.</Text>
          </Box>
        </Mask>

        {/* Elliptical with feathering */}
        <Mask withCursorMask maskRadiusX={200} maskRadiusY={80} maskFeather={60}>
          <Paper bg="dark.6" h={200} p="xl" withBorder>
            <Text c="white" fw={700} fz="lg">Elliptical mask</Text>
            <Text c="dimmed" fz="sm">Wide horizontal spotlight with soft feathering.</Text>
          </Paper>
        </Mask>
      </SimpleGrid>
    </Mask.Group>
  );
}

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.

Height
maskAngle (linear variant only)
Mask radius
Mask y
Mask transparency start
Mask transparency end
import { Mask, type MaskProps } from '@gfazioli/mantine-mask';
import { Alert, Button, Paper, Stack, Text, Textarea, Title } from '@mantine/core';

function Demo(props: MaskProps) {
  return (
    <Stack>
      {props.active && (
        <Alert title="Your credits are running low" color="orange" variant="light">
          Update your payment method to continue creating videos without interruptions.
          <Button variant="outline" color="blue" size="xs" radius="sm" ml="md">
            Update Payment Method
          </Button>
        </Alert>
      )}
      <Mask h={340} maskRadius={160} variant="linear" recenterOnResize>
        <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={props.active} placeholder="Describe your video idea..." minRows={3} />
            <Button disabled={props.active}>Create Video</Button>
          </Stack>
        </Paper>
      </Mask>
    </Stack>
  );
}