
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:
<LensSelectdata={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 defaultOpacity (
withOpacity) -- distant items fade, focused items stay opaqueBlur (
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:
<LensSelectcount={15}itemSize={48}magnification={2.5}expandOnHovertransitionDuration={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
withWheelLoop -- wrap around from last to first (and vice versa) with
loopTouch -- 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:
<LensSelectdata={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 |
|---|---|---|---|
|
| -- | Array of |
|
| -- | Generate N pills with numeric values (no data needed) |
|
| -- | Range bounds for generated values |
|
| -- | Fixed increment between generated values |
|
|
| Decimal places for generated values |
| `string \ | number` | -- |
|
| -- | Selection change callback |
| `'horizontal' \ | 'vertical'` |
|
|
|
| Maximum scale factor (200%) |
|
|
| Number of adjacent items influenced |
|
|
| Push neighbors apart (Dock behavior) |
| `'click' \ | 'hover'` |
|
|
|
| Enable scroll wheel navigation |
|
|
| Wrap-around navigation |
| `'default' \ | 'outline'` |
|
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" withBorderstyle={{ 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}` })));
<LensSelectdata={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 itemaria-selectedreflects the current selectionaria-orientationmatches the layout directionFull keyboard navigation: Arrow keys (orientation-aware), Home, End
prefers-reduced-motionmedia query disables all CSS transitionsConfigurable
ariaLabelprop
Under the Hood
A few implementation details worth noting:
Cosine-based falloff: the lens effect uses
(1 + cos(pi * distance / maxRange)) / 2for a smooth bell-curve that feels natural rather than linearCSS-native responsive props:
itemSize,gap,pillWidth, andpillHeightall support Mantine'sStylePropresponsive syntax viaInlineStyles+ CSS media queries -- nouseMatchesre-rendersGPU-optimized:
will-changeis set only during active hover and removed on mouse leaveRAF-synced indicator: the dot position updates via
requestAnimationFrameto 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
Comments (0)
Login to post a comment.