Mantine Picker

Logo

@gfazioli/mantine-picker

A Mantine component that allows you to create a picker effect with a list of elements.

Installation

yarn add @gfazioli/mantine-picker

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

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

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

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

Usage

The Picker component allows you to create a picker effect with any list of items. It's inspired by the iOS picker and allows you to create a similar effect with any list of items.

  • You can drag up and down to select the item you want
  • You may also use the mouse wheel to scroll through the items
  • Of course, you can also click on the item to select it
  • Finally, you can use the keyboard to navigate through the items
RomeMilanNaplesBerlinMadridBarcelona
Value: Rome
Rotate y
Wheel sensitivity
Momentum
Deceleration rate
Item height
Perspective
Max rotation
Cylinder radius
Visible items
Min item opacity
Min item scale
Max blur amount
Mask height
Mask intensity
import { useState } from 'react';
import { Picker, type PickerProps } from '@gfazioli/mantine-picker';
import { Code, Stack } from '@mantine/core';

function Demo(props: PickerProps) {
  const [value, setValue] = useState<string | number>('Rome');

  return (
    <Stack align="center" justify="space-between" h={300}>
      <Picker
        value={value}
        data={[
          'Rome',
          'Milan',
          'Naples',
          'Berlin',
          'Madrid',
          'Barcelona',
          'Paris',
          'London',
          'New York',
          'Los Angeles',
        ]}
        onChange={setValue}
      />
      <Code>Value: {value}</Code>
    </Stack>
  );
}

Controlled

Pass both value and onChange to use the Picker in controlled mode. The selected item is fully owned by your state, and the picker mirrors any external value change (e.g. from a Select or another input bound to the same state).

OctoberNovemberDecemberJanuaryFebruaryMarchAprilMayJuneJulyAugustSeptemberOctober
Value: April
import { useState } from 'react';
import { Picker } from '@gfazioli/mantine-picker';
import { Code, Select, Stack } from '@mantine/core';

function Demo() {
  const [value, setValue] = useState<string | number | null>(
    new Date().toLocaleString('en-US', { month: 'long' })
  );
  const data = [
    'January',
    'February',
    'March',
    'April',
    'May',
    'June',
    'July',
    'August',
    'September',
    'October',
    'November',
    'December',
  ];

  return (
    <Stack align="center" justify="space-between" h={400}>
      <Picker onChange={setValue} value={value ?? undefined} data={data} />
      <Code>Value: {value}</Code>
      <Select data={data} label="Select value" placeholder="Select value" onChange={setValue} />
    </Stack>
  );
}

By default the Picker is uncontrolled β€” omit value/onChange and optionally set defaultValue to choose the initial item. onChange is still called on every change if you provide it.

Scroll lifecycle

Pass onScrollStart and onScrollEnd to react to interaction boundaries β€” useful for analytics, haptic feedback, or coordinating multiple pickers in a group. A single continuous interaction (drag β†’ momentum, or wheel + animated snap) only fires the pair once: onScrollStart triggers when the picker leaves the idle state, onScrollEnd fires when every internal animation, momentum, drag and wheel debounce has settled.

Status:

idle

start: 0 Β· end: 0

DecemberJanuaryFebruaryMarchAprilMayJuneJulyAugustSeptemberOctoberNovemberDecember
import { Picker } from '@gfazioli/mantine-picker';
import { Badge, Group, Stack, Text } from '@mantine/core';
import { useState } from 'react';

function Demo() {
  const [scrolling, setScrolling] = useState(false);
  const [eventCount, setEventCount] = useState({ start: 0, end: 0 });

  return (
    <Stack align="center" justify="space-between" h={400}>
      <Group gap="xs">
        <Text size="sm">Status:</Text>
        <Badge color={scrolling ? 'orange' : 'gray'}>{scrolling ? 'scrolling' : 'idle'}</Badge>
        <Text size="sm" c="dimmed">
          start: {eventCount.start} Β· end: {eventCount.end}
        </Text>
      </Group>
      <Picker
        data={months}
        defaultValue="June"
        onScrollStart={() => {
          setScrolling(true);
          setEventCount((c) => ({ ...c, start: c.start + 1 }));
        }}
        onScrollEnd={() => {
          setScrolling(false);
          setEventCount((c) => ({ ...c, end: c.end + 1 }));
        }}
      />
    </Stack>
  );
}

Haptic feedback

Set hapticFeedback to trigger a short navigator.vibrate() pulse on supported devices whenever the selected item changes, giving the picker a native iOS/Android feel. Pass true to use the default 15ms duration, or a number for a custom duration in milliseconds. Desktop browsers and devices without vibration support silently no-op.

Open this demo on a phone or tablet to feel the haptic pulse on every selection change. Desktop browsers silently ignore navigator.vibrate.

Pulse duration: 15ms

JuneJanuaryFebruaryMarchAprilMayJune
import { Picker } from '@gfazioli/mantine-picker';

function Demo() {
  return (
    <Picker
      data={['January', 'February', 'March', 'April', 'May', 'June']}
      defaultValue="March"
      hapticFeedback   // boolean (default 15ms) or number for custom duration
    />
  );
}

renderItem

You can use the renderItem prop to customize the rendering of each item. This prop receives the item as arguments and should return the JSX to be rendered.

gray

violet

pink

brown

cyan

magenta

lime

olive

maroon

red

green

blue

yellow

orange

purple

black

white

gray

violet

gray
violet
pink
brown
cyan
magenta
lime
olive
maroon
red
green
blue
yellow
orange
purple
black
white
gray
violet
Value:
import { useState } from 'react';
import { Picker } from '@gfazioli/mantine-picker';
import { Badge, Code, ColorSwatch, Group, Stack, Text } from '@mantine/core';

function Demo(props: PickerProps) {
  const [value, setValue] = useState<string | number>('');

  const data = [
    'red',
    'green',
    'blue',
    'yellow',
    'orange',
    'purple',
    'black',
    'white',
    'gray',
    'violet',
    'pink',
    'brown',
    'cyan',
    'magenta',
    'lime',
    'olive',
    'maroon',
  ];

  function renderItem(item: string | number) {
    return (
      <Group>
        <ColorSwatch size={16} color={item as string} />
        <Text>{item}</Text>
      </Group>
    );
  }

  return (
    <Stack align="center" justify="space-between" h={400}>
      <Group>
        <Picker w={200} onChange={setValue} data={data} renderItem={renderItem} />
        <Picker
          w={200}
          onChange={setValue}
          data={data}
          renderItem={(item) => <Badge color={item as string}>{item}</Badge>}
        />
      </Group>
      <Code>Value: {value}</Code>
    </Stack>
  );
}

3D Effect

The Picker features a built-in 3D cylinder rotation effect inspired by the iOS picker. Configure it with:

  • enable3D β€” toggle the 3D effect (default: true)
  • perspective β€” depth perspective in pixels (default: 300)
  • maxRotation β€” maximum rotation angle in degrees (default: 60)
  • cylinderRadius β€” curvature factor (default: 4)
  • rotateY β€” rotate the entire picker along the Y-axis (default: 0)
JulyAugustSeptemberOctoberNovemberDecemberJanuaryFebruaryMarchAprilMayJuneJuly
JulyAugustSeptemberOctoberNovemberDecemberJanuaryFebruaryMarchAprilMayJuneJuly
JulyAugustSeptemberOctoberNovemberDecemberJanuaryFebruaryMarchAprilMayJuneJuly
import { Picker } from '@gfazioli/mantine-picker';
import { Group } from '@mantine/core';

function Demo() {
  return (
    <Group justify="center" gap={40}>
      <Picker data={months} w={120} rotateY={-15} perspective={200} cylinderRadius={3} />
      <Picker data={months} w={120} />
      <Picker data={months} w={120} rotateY={15} perspective={200} cylinderRadius={3} />
    </Group>
  );
}

Visual Effects

Non-selected items can be styled with gradual blur, opacity, and scale effects:

  • maxBlurAmount β€” maximum blur in pixels for non-selected items (default: 0)
  • minItemOpacity β€” minimum opacity at the edges (default: 0.3)
  • minItemScale β€” minimum scale at the edges (default: 0.85)

Blur

SolidPreactLitQwikReactAngularVueSvelteSolid

Opacity

SolidPreactLitQwikReactAngularVueSvelteSolid

Scale

SolidPreactLitQwikReactAngularVueSvelteSolid

Combined

SolidPreactLitQwikReactAngularVueSvelteSolid
import { Picker } from '@gfazioli/mantine-picker';
import { Group } from '@mantine/core';

function Demo() {
  return (
    <Group justify="center" gap={40}>
      {/* Blur effect on non-selected items */}
      <Picker data={data} w={100} maxBlurAmount={4} />

      {/* Opacity gradient */}
      <Picker data={data} w={100} minItemOpacity={0.1} />

      {/* Scale gradient */}
      <Picker data={data} w={100} minItemScale={0.5} />

      {/* All combined */}
      <Picker data={data} w={100} maxBlurAmount={3} minItemOpacity={0.2} minItemScale={0.6} />
    </Group>
  );
}

Mask

Enable a gradient mask at the top and bottom edges with withMask. Control the gradient intensity with maskHeight (percentage of picker height) and maskIntensity (gradient opacity).

Default (no mask)

Item 11Item 12Item 13Item 14Item 15Item 16Item 17Item 18Item 19Item 20Item 1Item 2Item 3Item 4Item 5Item 6Item 7Item 8Item 9Item 10Item 11

With mask

Item 11Item 12Item 13Item 14Item 15Item 16Item 17Item 18Item 19Item 20Item 1Item 2Item 3Item 4Item 5Item 6Item 7Item 8Item 9Item 10Item 11

Intense mask

Item 11Item 12Item 13Item 14Item 15Item 16Item 17Item 18Item 19Item 20Item 1Item 2Item 3Item 4Item 5Item 6Item 7Item 8Item 9Item 10Item 11
import { Picker } from '@gfazioli/mantine-picker';
import { Group } from '@mantine/core';

function Demo() {
  return (
    <Group justify="center" gap={40}>
      {/* No mask (default) */}
      <Picker data={data} w={120} visibleItems={5} />

      {/* With gradient mask */}
      <Picker data={data} w={120} visibleItems={5} withMask maskHeight={45} maskIntensity={40} />

      {/* Intense mask */}
      <Picker data={data} w={120} visibleItems={5} withMask maskHeight={60} maskIntensity={70} />
    </Group>
  );
}

Left and Right Sections

You can use the leftSection and rightSection props to add custom content to the left and right sides of the picker. This is useful for adding icons, buttons, or any other content you want.

πŸ‘‰
56789100123456
πŸ‘ˆ
56789100123456
↑
↓
56789100123456
import { Picker } from '@gfazioli/mantine-picker';
import { Group, Stack } from '@mantine/core';

function Demo(props: PickerProps) {
  const data = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

  return (
    <Stack>
      <Group justify="space-between">
        <Picker w={200} data={data} leftSection="πŸ‘‰" />
        <Picker w={200} data={data} rightSection="πŸ‘ˆ" />
        <Picker w={200} data={data} leftSection="↑" rightSection="↓" />
      </Group>
    </Stack>
  );
}

As you can see, by using leftSection and rightSection props you can add any content you want to the left and right sides of the picker. In these cases the sections are fixed and do not scroll with the items. They will also inside the picker.

Alternatively, you can use a simple <Group/> component to add any left (center) and right section you want.

πŸ‘‰

56789100123456

πŸ‘ˆ

56789100123456

:

56789100123456
import { Picker } from '@gfazioli/mantine-picker';
import { Group, Stack, Text } from '@mantine/core';

function Demo(props: PickerProps) {
  const data = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

  return (
    <Stack>
      <Group justify="space-between">
        <Group>
          <Text>πŸ‘‰</Text>
          <Picker w={200} data={data} />
          <Text>πŸ‘ˆ</Text>
        </Group>
        <Group>
          <Picker w={100} data={data} />
          <Text>:</Text>
          <Picker w={100} data={data} />
        </Group>
      </Group>
    </Stack>
  );
}

Styles the Picker

You can change the default styles of the items by using the same props you use in the <Text/> component.

PhiladelphiaSan AntonioSan DiegoDallasSan JoseNew YorkLos AngelesChicagoHoustonPhoenixPhiladelphia
PhiladelphiaSan AntonioSan DiegoDallasSan JoseNew YorkLos AngelesChicagoHoustonPhoenixPhiladelphia
PhiladelphiaSan AntonioSan DiegoDallasSan JoseNew YorkLos AngelesChicagoHoustonPhoenixPhiladelphia
import { Picker } from '@gfazioli/mantine-picker';
import { Group, Stack } from '@mantine/core';

function Demo() {
  const data = [
    'New York',
    'Los Angeles',
    'Chicago',
    'Houston',
    'Phoenix',
    'Philadelphia',
    'San Antonio',
    'San Diego',
    'Dallas',
    'San Jose',
  ];

  return (
    <Stack>
      <Group justify="space-between">
        <Picker w={200} data={data} tt="uppercase" />
        <Picker w={200} data={data} size="xl" />
        <Picker
          w={200}
          data={data}
          variant="gradient"
          gradient={{ from: 'blue', to: 'red', deg: 90 }}
          style={{
            textShadow: '0 4px 2px rgba(0, 0, 0, 0.4)',
          }}
        />
      </Group>
    </Stack>
  );
}

readOnly

In addition to the disabled prop, you can use the readOnly prop to make the picker read-only. This will prevent the user from changing the selected item, but will still allow you to change it programmatically. It can be useful when you want to display the selected item without allowing the user to change it.

56789012345
012345
import { useState } from 'react';
import { Picker } from '@gfazioli/mantine-picker';
import { Button, Group, Stack } from '@mantine/core';
import { useInterval } from '@mantine/hooks';

function Demo() {
  const length = 10;
  const data = Array.from({ length }, (_, i) => i);

  const [seconds, setSeconds] = useState(0);
  const interval = useInterval(() => {
    setSeconds((s) => (s === length - 1 ? 0 : s + 1));
  }, 1000);

  const pickerProps: PickerProps = {
    w: 60,
    withDividers: false,
    withHighlight: false,
    withMask: false,
    itemHeight: 24,
    readOnly: true,
    data,
  };

  return (
    <Stack align="center" justify="space-between" h={200}>
      <Group>
        <Picker
          {...pickerProps}
          maxBlurAmount={1.5}
          minItemOpacity={0.5}
          visibleItems={3}
          value={seconds}
        />
        <Picker
          {...pickerProps}
          loop={false}
          c="red"
          minItemScale={0.1}
          visibleItems={1}
          animationDuration={700}
          value={seconds}
        />
      </Group>

      <Button onClick={interval.toggle}>{interval.active ? 'Stop' : 'Start'}</Button>
    </Stack>
  );
}

Accessibility

The Picker component provides built-in keyboard navigation and screen reader support:

  • Arrow Up/Down β€” move one item up/down
  • Home/End β€” jump to first/last item
  • Page Up/Down β€” move 5 items at a time
  • role="listbox" and role="option" for semantic structure
  • aria-selected on the active item
  • aria-disabled and aria-readonly when applicable

Use the id, label, description, and keyboardHint props to provide additional context for assistive technologies. Set focusable={false} to exclude the picker from the tab order.

Example: Time Picker

The Picker component can be used as a Time Picker. You can use the renderItem prop to customize the rendering of each item and the onChange prop to handle the selection logic. In this example, we will create a simple time picker that allows you to select the hour and minute.

πŸ•‘

06070809101112131415161718192021222300010203040506

:

17181920212223242526272829303132333435363738394041424344454647484950515253545556575859000102030405060708091011121314151617

min

Selected time: 18:47

00010203040506070809101100

:

17181920212223242526272829303132333435363738394041424344454647484950515253545556575859000102030405060708091011121314151617
ampm

Selected time: 06:47 pm

import { useState } from 'react';
import { Picker } from '@gfazioli/mantine-picker';
import { Group, Stack, Text } from '@mantine/core';

function Demo() {
  const initialHour12 = +(new Date().getHours() as number) % 12;

  const [hours24, setHours24] = useState(new Date().getHours().toString());
  const [hours12, setHours12] = useState(
    initialHour12 < 10 ? `0${initialHour12}` : `${initialHour12}`
  );
  const [minutes24, setMinutes24] = useState(new Date().getMinutes());
  const [minutes12, setMinutes12] = useState(new Date().getMinutes());
  const [amPm, setAmPm] = useState(new Date().getHours() >= 12 ? 'pm' : 'am');

  const hours24Data = Array.from({ length: 24 }, (_, i) => (i < 10 ? `0${i}` : `${i}`));
  const hours12Data = Array.from({ length: 12 }, (_, i) => (i < 10 ? `0${i}` : `${i}`));
  const minutesData = Array.from({ length: 60 }, (_, i) => (i < 10 ? `0${i}` : i));

  const pickerProps: Omit<PickerProps, 'data'> = {
    w: 50,
    withDividers: false,
    withHighlight: false,
    loop: true,
    maxRotation: 90,
    itemHeight: 30,
    visibleItems: 5,
    withMask: false,
  };

  return (
    <Group justify="center" grow>
      <Stack align="center" justify="space-between" h={200}>
        <Group gap={0}>
          <Text>πŸ•‘</Text>
          <Picker
            {...pickerProps}
            rotateY={-10}
            data={hours24Data}
            value={hours24}
            onChange={setHours24}
          />
          <Text>:</Text>
          <Picker
            {...pickerProps}
            rotateY={10}
            data={minutesData}
            value={minutes24}
            onChange={setMinutes24}
          />
          <Text>min</Text>
        </Group>
        <Text>Selected time: {`${hours24}:${minutes24}`}</Text>
      </Stack>

      <Stack align="center" justify="space-between" h={200}>
        <Group gap={0}>
          <Picker
            {...pickerProps}
            rotateY={-10}
            data={hours12Data}
            value={hours12}
            onChange={setHours12}
          />
          <Text>:</Text>
          <Picker
            {...pickerProps}
            rotateY={10}
            data={minutesData}
            value={minutes12}
            onChange={setMinutes12}
          />
          <Picker
            {...pickerProps}
            rotateY={10}
            data={['am', 'pm']}
            loop={false}
            value={amPm}
            onChange={setAmPm}
          />
        </Group>
        <Text>Selected time: {`${hours12}:${minutes12} ${amPm}`}</Text>
      </Stack>
    </Group>
  );
}

Example: Date Picker

The Picker component can be used as a Date Picker.

101112131415161718192021222324252627282930310102030405060708091011
OctoberNovemberDecemberJanuaryFebruaryMarchAprilMayJuneJulyAugustSeptemberOctober
199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996

Selected date: 26 April 2026

import { useState } from 'react';
import { Picker, PickerProps } from '@gfazioli/mantine-picker';
import { Group, Stack, Text } from '@mantine/core';

function DatePicker() {
  const days = Array.from({ length: 31 }, (_, i) => (i < 9 ? `0${i + 1}` : `${i + 1}`));
  const months = [
    'January',
    'February',
    'March',
    'April',
    'May',
    'June',
    'July',
    'August',
    'September',
    'October',
    'November',
    'December',
  ];
  // years start from 1970 to 2030
  const years = Array.from({ length: 61 }, (_, i) => (i + 1970).toString());

  const today =
    (new Date().getDate() as number) < 10 ? `0${new Date().getDate()}` : new Date().getDate();

  const [day, setDay] = useState<string | number>(today.toString());
  const [month, setMonth] = useState<string | number>(months[new Date().getMonth()]);
  const [year, setYear] = useState<string | number>(new Date().getFullYear().toString());

  const pickerProps: Omit<PickerProps, 'data'> = {
    withDividers: false,
    withHighlight: false,
    loop: true,
    maxRotation: 90,
    itemHeight: 30,
    visibleItems: 5,
    withMask: false,
    cylinderRadius: 3,
  };

  return (
    <Stack align="center" justify="space-between" h={200}>
      <Group gap={0}>
        <Picker {...pickerProps} w={60} value={day} data={days} rotateY={-10} onChange={setDay} />
        <Picker {...pickerProps} w={80} value={month} data={months} onChange={setMonth} />
        <Picker {...pickerProps} w={60} value={year} data={years} rotateY={10} onChange={setYear} />
      </Group>
      <Text>Selected date: {`${day} ${month} ${year}`}</Text>
    </Stack>
  );
}

Example: Color Palette

renderItem can return any JSX, so a colour swatch next to the label is a one-liner. The selected colour is mirrored back to a preview block to make the picker feel like a proper colour control.

Slate

Crimson

Amber

Emerald

Sky

Indigo

Fuchsia

Rose

Slate

import { Picker } from '@gfazioli/mantine-picker';
import { Box, Group, Stack, Text } from '@mantine/core';
import { useState } from 'react';

const COLORS = [
  { name: 'Slate', value: '#64748b' },
  { name: 'Crimson', value: '#dc2626' },
  { name: 'Amber', value: '#f59e0b' },
  { name: 'Emerald', value: '#10b981' },
  { name: 'Sky', value: '#0ea5e9' },
  { name: 'Indigo', value: '#6366f1' },
  { name: 'Fuchsia', value: '#d946ef' },
  { name: 'Rose', value: '#f43f5e' },
];

function Demo() {
  const [name, setName] = useState('Sky');
  const selected = COLORS.find((c) => c.name === name) ?? COLORS[0];

  return (
    <Stack align="center">
      <Box w={180} h={80} bg={selected.value} style={{ borderRadius: 12 }} />
      <Picker
        w={220}
        data={COLORS.map((c) => c.name)}
        value={name}
        onChange={setName}
        renderItem={(item) => {
          const color = COLORS.find((c) => c.name === item);
          return (
            <Group gap="sm" justify="center">
              <Box w={16} h={16} bg={color?.value} style={{ borderRadius: '50%' }} />
              <Text>{item}</Text>
            </Group>
          );
        }}
      />
    </Stack>
  );
}

Example: Alarm clock

A small iOS-style alarm clock combining three pickers (hours, minutes, AM/PM), hapticFeedback on every wheel tick, and a final vibration burst when the alarm is armed.

01020304050607080910111201

:

00010203040506070809101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585900
PMAMPMAMPMAMPM

No alarm set

import { Picker } from '@gfazioli/mantine-picker';
import { Button, Group, Stack, Text } from '@mantine/core';
import { useState } from 'react';

const HOURS = Array.from({ length: 12 }, (_, i) => (i + 1).toString().padStart(2, '0'));
const MINUTES = Array.from({ length: 60 }, (_, i) => i.toString().padStart(2, '0'));
const PERIODS = ['AM', 'PM'];

function Demo() {
  const [hour, setHour] = useState('07');
  const [minute, setMinute] = useState('30');
  const [period, setPeriod] = useState('AM');
  const [armed, setArmed] = useState(null);

  const pickerProps = {
    w: 64,
    withDividers: false,
    withHighlight: false,
    itemHeight: 36,
    visibleItems: 5,
    withMask: true,
    maskHeight: 30,
    cylinderRadius: 3,
  };

  const setAlarm = () => {
    setArmed(`${hour}:${minute} ${period}`);
    if (typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function') {
      navigator.vibrate([0, 30, 50, 30]);
    }
  };

  return (
    <Stack align="center">
      <Group gap={4}>
        <Picker {...pickerProps} loop data={HOURS} value={hour} onChange={setHour} hapticFeedback />
        <Text size="lg" fw={700}>:</Text>
        <Picker {...pickerProps} loop data={MINUTES} value={minute} onChange={setMinute} hapticFeedback />
        <Picker {...pickerProps} data={PERIODS} value={period} onChange={setPeriod} hapticFeedback />
      </Group>
      <Button onClick={setAlarm}>Set alarm</Button>
      <Text>{armed ? `⏰ Alarm armed for ${armed}` : 'No alarm set'}</Text>
    </Stack>
  );
}

Example: Slot Machine

As funny as it may sound, the Picker component can be used to create a slot machine effect β€” three reels spin at different speeds, stop sequentially, and emit a tactile pulse on every reel landing (and a longer pattern on a jackpot).

🍌🍍πŸ₯πŸπŸŽπŸπŸ‘πŸ’πŸ‹πŸŠπŸ‰πŸ‡πŸ“πŸˆπŸŒ
🍍πŸ₯πŸπŸŽπŸπŸ‘πŸ’πŸ‹πŸŠπŸ‰πŸ‡πŸ“πŸˆπŸŒπŸ
πŸ₯πŸπŸŽπŸπŸ‘πŸ’πŸ‹πŸŠπŸ‰πŸ‡πŸ“πŸˆπŸŒπŸπŸ₯

Place your bet!

import { Picker } from '@gfazioli/mantine-picker';
import { Button, Group, Paper, Stack, Text } from '@mantine/core';
import { useEffect, useRef, useState } from 'react';

const SLOT_DATA = ['πŸ’','πŸ‹','🍊','πŸ‰','πŸ‡','πŸ“','🍈','🍌','🍍','πŸ₯','🍏','🍎','🍐','πŸ‘'];
const TICK_MS = 120;
const STOP_TIMES = [1500, 2200, 3000];

function vibrate(duration) {
  if (typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function') {
    navigator.vibrate(duration);
  }
}

function Demo() {
  const [values, setValues] = useState([SLOT_DATA[0], SLOT_DATA[1], SLOT_DATA[2]]);
  const [spinning, setSpinning] = useState(false);
  const [result, setResult] = useState('idle');
  const intervalsRef = useRef([]);
  const timersRef = useRef([]);
  const valuesRef = useRef(values);
  valuesRef.current = values;

  const clearAll = () => {
    intervalsRef.current.forEach((id) => clearInterval(id));
    timersRef.current.forEach((id) => clearTimeout(id));
    intervalsRef.current = [];
    timersRef.current = [];
  };

  useEffect(() => clearAll, []);

  const spin = () => {
    clearAll();
    setSpinning(true);
    setResult('spinning');

    const indices = values.map((v) => SLOT_DATA.indexOf(v));

    indices.forEach((_, slotIndex) => {
      intervalsRef.current[slotIndex] = setInterval(() => {
        indices[slotIndex] = (indices[slotIndex] + 1) % SLOT_DATA.length;
        setValues((prev) => {
          const next = [...prev];
          next[slotIndex] = SLOT_DATA[indices[slotIndex]];
          return next;
        });
      }, TICK_MS);
    });

    STOP_TIMES.forEach((stopAt, slotIndex) => {
      timersRef.current[slotIndex] = setTimeout(() => {
        clearInterval(intervalsRef.current[slotIndex]);
        const finalIdx = Math.floor(Math.random() * SLOT_DATA.length);
        setValues((prev) => {
          const next = [...prev];
          next[slotIndex] = SLOT_DATA[finalIdx];
          return next;
        });
        vibrate(30);

        if (slotIndex === STOP_TIMES.length - 1) {
          setTimeout(() => {
            setSpinning(false);
            const final = valuesRef.current;
            const isWin = final.every((v) => v === final[0]);
            setResult(isWin ? 'win' : 'lose');
            if (isWin) vibrate([0, 60, 80, 60]);
          }, 350);
        }
      }, stopAt);
    });
  };

  return (
    <Stack align="center" h={400}>
      <Paper radius={16} withBorder p={4}>
        <Group gap={4}>
          {values.map((value, index) => (
            <Picker
              key={index}
              w={80}
              loop
              withMask
              data={SLOT_DATA}
              value={value}
              readOnly
              itemHeight={50}
              visibleItems={5}
              withHighlight={false}
              maxBlurAmount={spinning ? 8 : 0}
              maskHeight={20}
              size="32px"
              animationDuration={spinning ? 100 : 300}
            />
          ))}
        </Group>
      </Paper>
      <Button onClick={spin} disabled={spinning}>
        {spinning ? 'Spinning…' : 'Spin'}
      </Button>
      <Text fw={700}>
        {result === 'win' ? 'πŸŽ‰ Jackpot! πŸŽ‰' : result === 'lose' ? 'Try Again!' : 'Place your bet!'}
      </Text>
    </Stack>
  );
}