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} />;
}
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} />
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 />
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"
/>
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"
/>
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>
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>
)}
/>
Getting Started
Install the package:
npm install @gfazioli/mantine-lens-select
# or
yarn add @gfazioli/mantine-lens-select
Import styles at the root of your application:
import '@gfazioli/mantine-lens-select/styles.css';
Layer-aware import is also available:
import '@gfazioli/mantine-lens-select/styles.layer.css';
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}
/>
);
}
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>
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' }}
/>
);
}
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 }}
/>
);
}
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 }}
/>
Accessibility
LensSelect implements the WAI-ARIA listbox pattern:
-
role="listbox"on the root,role="option"on each item -
aria-selectedreflects the current selection -
aria-orientationmatches the layout direction - Full keyboard navigation: Arrow keys (orientation-aware), Home, End
-
prefers-reduced-motionmedia query disables all CSS transitions - Configurable
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 linear -
CSS-native responsive props:
itemSize,gap,pillWidth, andpillHeightall support Mantine'sStylePropresponsive syntax viaInlineStyles+ CSS media queries -- nouseMatchesre-renders -
GPU-optimized:
will-changeis set only during active hover and removed on mouse leave -
RAF-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