Magnify Your Choices: A Fisheye Select Component for Mantine

typescript dev.to

A select component where items magnify under your cursor -- macOS Dock-style -- built for Mantine 9.

The Problem With Flat Lists

You are building a settings panel where users pick a value from a range -- say, a volume level from 1 to 10, or a date from a multi-year timeline. A traditional <select> dropdown works, but it is buried behind a click. A slider works, but it carries no visual weight. What if the selection itself could respond to the user's intent -- items growing as the cursor approaches, then settling back as it moves away?

That is exactly what @gfazioli/mantine-lens-select does. It brings the macOS Dock's fisheye magnification effect to a Mantine select component, making selection both functional and delightful.

What Is LensSelect?

LensSelect is a React component built on top of Mantine 9 that displays a row (or column) of items with a cosine-based lens magnification effect on hover. Items near the cursor scale up smoothly while distant items remain at their base size, creating an interactive and visually engaging selection experience.

It ships as @gfazioli/mantine-lens-select v1.0.0, follows the full Mantine Factory pattern, and supports the Styles API out of the box.

Key Features

Pill Mode -- Zero Configuration

The simplest usage needs no data array at all. Just pass a count and you get styled pills with automatic numeric values:

import { LensSelect } from '@gfazioli/mantine-lens-select';

function Demo() {
  return <LensSelect count={20} />;
}
Enter fullscreen mode Exit fullscreen mode

Twenty rounded vertical bars appear, each responding to your cursor with the fisheye effect. The active pill gets the primary color, hovered pills get a lighter tint, and a dot indicator tracks the selection.

Count Mode with Ranges

For numeric selection, combine count with min/max to generate evenly distributed values, or use step for fixed increments -- an API that will feel familiar if you have used Mantine's Slider:

// 20 pills mapped linearly from 0 to 100
<LensSelect count={20} min={0} max={100} />

// Fixed steps: pills at 0, 10, 20, ..., 100
<LensSelect min={0} max={100} step={10} />

// Decimal precision: 0.00, 0.25, 0.50, 0.75, 1.00
<LensSelect min={0} max={1} step={0.25} precision={2} />
Enter fullscreen mode Exit fullscreen mode

The data prop always takes priority, so you can start with count mode during prototyping and switch to a custom data array later without changing anything else.

Custom Data with Rich Content

Pass an array of { value, view } objects for full control over what each item renders:

const apps = [
  { value: 'finder', view: <span>📁</span> },
  { value: 'safari', view: <span>🧭</span> },
  { value: 'mail', view: <span>📧</span> },
  { value: 'settings', view: <span>⚙️</span> },
];

<LensSelect data={apps} itemSize={48} magnification={2.5} expandOnHover />
Enter fullscreen mode Exit fullscreen mode

When view is omitted from all items, the component automatically enters pill mode -- no special flag needed.

Orientation: Horizontal and Vertical

Switch between horizontal and vertical layouts with a single prop. Vertical mode is perfect for timeline selectors:

<LensSelect
  data={events}
  orientation="vertical"
  itemSize={12}
  gap={6}
  pillWidth={2}
  activeColor="orange"
/>
Enter fullscreen mode Exit fullscreen mode

Visual Effects Stack

Three independent visual effects can be combined:

  • Scale (withScale) -- the core fisheye magnification, enabled by default
  • Opacity (withOpacity) -- distant items fade, focused items stay opaque
  • Blur (withBlur) -- distant items blur, focused items stay sharp

Each effect has configurable ranges (opacityRange, blurRange) and all respect the same cosine-based falloff curve controlled by magnification and lensRange.

Expand on Hover

Enable expandOnHover to make items push their neighbors apart during magnification -- the signature macOS Dock behavior:

<LensSelect
  count={15}
  itemSize={48}
  magnification={2.5}
  expandOnHover
  transitionDuration={0}
  easing="linear"
/>
Enter fullscreen mode Exit fullscreen mode

Selection Modes and Navigation

Two selection modes serve different use cases:

  • Click mode (default) -- user clicks to select
  • Hover mode -- selection follows the cursor automatically

On top of that, LensSelect supports:

  • Keyboard navigation -- Arrow keys, Home, End with proper orientation awareness
  • Mouse wheel -- scroll through items with withWheel
  • Loop -- wrap around from last to first (and vice versa) with loop
  • Touch -- swipe navigation on mobile devices

The Indicator: A Compound Component

LensSelect.Indicator is a compound sub-component that renders a tracking dot below (or beside, in vertical mode) the active item. It follows the item in real time using requestAnimationFrame, staying in sync even during magnification animations.

Use it automatically via the withIndicator prop, or mount it explicitly as a child for full control:

<LensSelect data={data} withIndicator={false}>
  <LensSelect.Indicator
    variant="outline"
    color="red"
    size={10}
    offset={20}
  />
</LensSelect>
Enter fullscreen mode Exit fullscreen mode

Both LensSelect and LensSelect.Indicator support default and outline variants independently, so you can mix filled pills with an outline indicator dot (or vice versa).

renderItem for Total Control

When pills and view are not enough, renderItem gives you a callback with the item, its active state, current scale factor, and hover status:

<LensSelect
  data={apps}
  renderItem={(item, { active, scale, hovered }) => (
    <Box
      style={{
        borderRadius: 10,
        background: active ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-gray-1)',
        fontSize: 24,
      }}
    >
      {item.view}
    </Box>
  )}
/>
Enter fullscreen mode Exit fullscreen mode

Getting Started

Install the package:

npm install @gfazioli/mantine-lens-select
# or
yarn add @gfazioli/mantine-lens-select
Enter fullscreen mode Exit fullscreen mode

Import styles at the root of your application:

import '@gfazioli/mantine-lens-select/styles.css';
Enter fullscreen mode Exit fullscreen mode

Layer-aware import is also available:

import '@gfazioli/mantine-lens-select/styles.layer.css';
Enter fullscreen mode Exit fullscreen mode

Then use it:

import { LensSelect } from '@gfazioli/mantine-lens-select';

function App() {
  const [value, setValue] = useState(5);

  return (
    <LensSelect
      count={10}
      value={value}
      onChange={setValue}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Props and API at a Glance

Core props:

Prop Type Default Description
data LensSelectItem[] -- Array of { value, view? } items
count number -- Generate N pills with numeric values (no data needed)
min / max number -- Range bounds for generated values
step number -- Fixed increment between generated values
precision number 0 Decimal places for generated values
value / defaultValue `string \ number` --
onChange (value) => void -- Selection change callback
orientation `'horizontal' \ 'vertical'` 'horizontal'
magnification number 2 Maximum scale factor (200%)
lensRange number 3 Number of adjacent items influenced
expandOnHover boolean false Push neighbors apart (Dock behavior)
selectionMode `'click' \ 'hover'` 'click'
withWheel boolean false Enable scroll wheel navigation
loop boolean false Wrap-around navigation
variant `'default' \ 'outline'` 'default'

Visual effects: withScale, withOpacity, withBlur, opacityRange, blurRange

Pill styling: itemSize, gap, pillWidth, pillHeight, pillRadius, pillColor, hoverColor, activeColor

Transitions: transitionDuration, easing (linear, ease-out, ease-in-out, spring, or custom cubic-bezier)

Styles API: 6 selectors (root, track, item, itemContent, itemPill, indicator) and 12 CSS custom properties for full visual customization.

Advanced Usage

macOS Dock Recreation

Combine renderItem, expandOnHover, and a Paper container for a faithful Dock clone:

<Paper px="lg" pt="md" pb="sm" radius="lg" withBorder
  style={{ backdropFilter: 'blur(20px)' }}>
  <LensSelect
    data={apps}
    itemSize={48}
    gap={4}
    magnification={2.5}
    lensRange={3}
    expandOnHover
    transitionDuration={0}
    easing="linear"
    renderItem={(item, { active }) => (
      <Box style={{
        borderRadius: 10,
        background: active
          ? 'var(--mantine-color-blue-6)'
          : 'var(--mantine-color-gray-1)',
        fontSize: 24,
      }}>
        {item.view}
      </Box>
    )}
    indicatorProps={{ size: 4, offset: 8 }}
  />
</Paper>
Enter fullscreen mode Exit fullscreen mode

Weekday Picker with Dynamic Colors

Leverage controlled state to change colors based on the selected value:

function WeekdayPicker() {
  const [day, setDay] = useState('Mon');
  const isWeekend = day === 'Sat' || day === 'Sun';

  return (
    <LensSelect
      data={DAYS}
      value={day}
      onChange={setDay}
      itemSize={36}
      magnification={1.8}
      lensRange={2}
      activeColor={isWeekend ? 'red' : 'blue'}
      indicatorProps={{ color: isWeekend ? 'red' : 'blue' }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Volume / Rating Selector

A compact numeric selector with count mode -- no data array required:

function VolumeControl() {
  const [volume, setVolume] = useState(5);

  return (
    <LensSelect
      count={10}
      value={volume}
      onChange={setVolume}
      itemSize={32}
      gap={3}
      pillWidth={6}
      magnification={1.5}
      lensRange={2}
      activeColor="teal"
      hoverColor="teal"
      indicatorProps={{ color: 'teal', size: 4 }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Vertical Timeline

Use vertical orientation for a time-machine-style date navigator:

const events = [2022, 2023, 2024, 2025].flatMap((year) =>
  months.map((month) => ({ value: `${month}${year}` }))
);

<LensSelect
  data={events}
  orientation="vertical"
  itemSize={12}
  gap={6}
  pillWidth={2}
  magnification={1.5}
  lensRange={2}
  activeColor="orange"
  indicatorProps={{ color: 'orange', size: 5, offset: 12 }}
/>
Enter fullscreen mode Exit fullscreen mode

Accessibility

LensSelect implements the WAI-ARIA listbox pattern:

  • role="listbox" on the root, role="option" on each item
  • aria-selected reflects the current selection
  • aria-orientation matches the layout direction
  • Full keyboard navigation: Arrow keys (orientation-aware), Home, End
  • prefers-reduced-motion media query disables all CSS transitions
  • Configurable ariaLabel prop

Under the Hood

A few implementation details worth noting:

  • Cosine-based falloff: the lens effect uses (1 + cos(pi * distance / maxRange)) / 2 for a smooth bell-curve that feels natural rather than linear
  • CSS-native responsive props: itemSize, gap, pillWidth, and pillHeight all support Mantine's StyleProp responsive syntax via InlineStyles + CSS media queries -- no useMatches re-renders
  • GPU-optimized: will-change is set only during active hover and removed on mouse leave
  • RAF-synced indicator: the dot position updates via requestAnimationFrame to stay in sync with CSS transitions

Live Demo

Try the interactive configurator and all use-case demos on the documentation site:

gfazioli.github.io/mantine-lens-select

Links

Read Full Tutorial open_in_new
arrow_back Back to Tutorials