Mantine Depth Select

Logo

@gfazioli/mantine-depth-select

A 3D stack select component inspired by macOS Time Machine for React applications built with Mantine. Navigate through stacked cards with perspective transforms and smooth transitions.

Installation

yarn add @gfazioli/mantine-depth-select

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

import '@gfazioli/mantine-depth-select/styles.css';

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

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

Usage

A 3D stack select component inspired by macOS Time Machine. Navigate through stacked cards with perspective transforms and smooth transitions. Use keyboard arrows, click the second card, mouse wheel, touch swipe, or use the built-in controls to navigate.

The controlsPosition prop controls where the navigation arrows are placed: right (default) or left. Set withControls={false} to hide the built-in controls and use your own.

You must set w and h props to define the area where cards live. Cards should use h="100%" to fill the available height.

Snapshot 1

Today

Controls position
Visible cards
Transition duration
Scale step
Translate ystep
Opacity step
Blur step
import { Card, Text, Title } from '@mantine/core';
import { DepthSelect, DepthSelectItem } from '@gfazioli/mantine-depth-select';

const data: DepthSelectItem[] = [
  { value: 'snap-1', view: <Card shadow="sm" p="lg" withBorder h="100%"><Title order={4}>Snapshot 1</Title><Text size="sm" c="dimmed">Today</Text></Card> },
  { value: 'snap-2', view: <Card shadow="sm" p="lg" withBorder h="100%"><Title order={4}>Snapshot 2</Title><Text size="sm" c="dimmed">Yesterday</Text></Card> },
  { value: 'snap-3', view: <Card shadow="sm" p="lg" withBorder h="100%"><Title order={4}>Snapshot 3</Title><Text size="sm" c="dimmed">2 days ago</Text></Card> },
  { value: 'snap-4', view: <Card shadow="sm" p="lg" withBorder h="100%"><Title order={4}>Snapshot 4</Title><Text size="sm" c="dimmed">3 days ago</Text></Card> },
  { value: 'snap-5', view: <Card shadow="sm" p="lg" withBorder h="100%"><Title order={4}>Snapshot 5</Title><Text size="sm" c="dimmed">Last week</Text></Card> },
];

function Demo() {
  return (
    <DepthSelect data={data} w={400} h={150} />
  );
}

Controlled

Use value and onChange for controlled mode. Navigation also works via keyboard (ArrowUp/Down, Home/End), mouse wheel, and touch swipe:

Snapshot 2

Yesterday

import { useState } from 'react';
import { Button, Card, Group, Stack, Text, Title } from '@mantine/core';
import { DepthSelect, DepthSelectItem } from '@gfazioli/mantine-depth-select';

const data: DepthSelectItem[] = [
  { value: 'snap-1', view: <Card shadow="sm" p="lg" withBorder><Title order={4}>Snapshot 1</Title></Card> },
  { value: 'snap-2', view: <Card shadow="sm" p="lg" withBorder><Title order={4}>Snapshot 2</Title></Card> },
  { value: 'snap-3', view: <Card shadow="sm" p="lg" withBorder><Title order={4}>Snapshot 3</Title></Card> },
];

function Demo() {
  const [value, setValue] = useState<string | number>('snap-2');

  return (
    <Stack>
      <DepthSelect data={data} value={value} onChange={setValue} withControls={false} w={400} h={200} />
      <Group justify="center">
        {data.map((item) => (
          <Button key={item.value} onClick={() => setValue(item.value)} variant="light" size="xs">
            {String(item.value)}
          </Button>
        ))}
      </Group>
    </Stack>
  );
}

Scroll navigation

By default, you can navigate the stack using the mouse wheel or trackpad gestures. The page scroll is automatically blocked while interacting with the component. Set withScrollNavigation={false} to disable this behavior.

Loop

Set loop to enable infinite navigation. When you reach the last item, the next navigation wraps back to the first item and vice versa:

Slide 1

First item — loops back from the last

slide-1
import { Card, Stack, Text, Title } from '@mantine/core';
import { DepthSelect, DepthSelectItem } from '@gfazioli/mantine-depth-select';

const data: DepthSelectItem[] = [
  { value: 'slide-1', view: <Card shadow="sm" p="lg" h="100%" bg="red.7"><Title order={4} c="white">Slide 1</Title><Text size="sm" c="gray.3">First item</Text></Card> },
  { value: 'slide-2', view: <Card shadow="sm" p="lg" h="100%" bg="violet.7"><Title order={4} c="white">Slide 2</Title><Text size="sm" c="gray.3">Middle item</Text></Card> },
  { value: 'slide-3', view: <Card shadow="sm" p="lg" h="100%" bg="blue.7"><Title order={4} c="white">Slide 3</Title><Text size="sm" c="gray.3">Last item</Text></Card> },
];

function Demo() {
  return (
    <Stack pt={60} pb={60}>
      <DepthSelect
        data={data}
        loop
        controlsProps={{ labelFormatter: (item) => String(item.value) }}
        w={400}
        h={120}
      />
    </Stack>
  );
}

Controls alignment

Use controlsProps to customize the built-in controls. The justify prop aligns them vertically: start (top), center (default), or end (bottom). You can also set w for a fixed width to prevent layout shifts when labels change:

Snapshot 1

Today

snap-1
import { useState } from 'react';
import { Card, SegmentedControl, Stack, Text, Title } from '@mantine/core';
import { DepthSelect, DepthSelectItem } from '@gfazioli/mantine-depth-select';

const data: DepthSelectItem[] = [
  { value: 'snap-1', view: <Card shadow="sm" p="lg" withBorder h="100%"><Title order={4}>Snapshot 1</Title></Card> },
  { value: 'snap-2', view: <Card shadow="sm" p="lg" withBorder h="100%"><Title order={4}>Snapshot 2</Title></Card> },
  { value: 'snap-3', view: <Card shadow="sm" p="lg" withBorder h="100%"><Title order={4}>Snapshot 3</Title></Card> },
];

function Demo() {
  const [justify, setJustify] = useState<'start' | 'center' | 'end'>('center');

  return (
    <Stack align="center" gap="md">
      <SegmentedControl
        value={justify}
        onChange={(val) => setJustify(val as 'start' | 'center' | 'end')}
        data={[
          { value: 'start', label: 'Start' },
          { value: 'center', label: 'Center' },
          { value: 'end', label: 'End' },
        ]}
        size="xs"
      />
      <DepthSelect
        data={data}
        w={350}
        h={200}
        controlsProps={{
          justify,
          w: 80,
          labelFormatter: (item) => String(item.value),
        }}
      />
    </Stack>
  );
}

Custom controls

Set withControls={false} to hide the built-in navigation and use your own controls. Use value and onChange to manage navigation externally:

Snapshot 2

Yesterday

snap-2 (2/3)

import { useState } from 'react';
import { ActionIcon, Card, Group, Stack, Text, Title } from '@mantine/core';
import { DepthSelect, DepthSelectItem } from '@gfazioli/mantine-depth-select';

const data: DepthSelectItem[] = [
  { value: 'snap-1', view: <Card shadow="sm" p="lg" withBorder h="100%"><Title order={4}>Snapshot 1</Title><Text size="sm" c="dimmed">Today</Text></Card> },
  { value: 'snap-2', view: <Card shadow="sm" p="lg" withBorder h="100%"><Title order={4}>Snapshot 2</Title><Text size="sm" c="dimmed">Yesterday</Text></Card> },
  { value: 'snap-3', view: <Card shadow="sm" p="lg" withBorder h="100%"><Title order={4}>Snapshot 3</Title><Text size="sm" c="dimmed">2 days ago</Text></Card> },
];

function Demo() {
  const [value, setValue] = useState<string | number>('snap-2');
  const currentIndex = data.findIndex((item) => item.value === value);

  const goNext = () => {
    if (currentIndex < data.length - 1) {
      setValue(data[currentIndex + 1].value);
    }
  };

  const goPrevious = () => {
    if (currentIndex > 0) {
      setValue(data[currentIndex - 1].value);
    }
  };

  return (
    <Stack align="center">
      <DepthSelect
        data={data}
        value={value}
        onChange={setValue}
        withControls={false}
        w={400}
        h={80}
      />
      <Group>
        <ActionIcon variant="default" onClick={goPrevious} disabled={currentIndex <= 0}>
          ←
        </ActionIcon>
        <Text size="sm" fw={500}>
          {String(value)} ({currentIndex + 1}/{data.length})
        </Text>
        <ActionIcon variant="default" onClick={goNext} disabled={currentIndex >= data.length - 1}>
          →
        </ActionIcon>
      </Group>
    </Stack>
  );
}

Controls as children

You can also use DepthSelect.Controls as a child component with withControls={false}. This gives you access to props like labelFormatter directly on the controls:

Snapshot 2

Yesterday

snap-2
import { Card, Stack, Text, Title } from '@mantine/core';
import { DepthSelect, DepthSelectItem } from '@gfazioli/mantine-depth-select';

const data: DepthSelectItem[] = [
  { value: 'snap-1', view: <Card shadow="sm" p="lg" withBorder h="100%"><Title order={4}>Snapshot 1</Title><Text size="sm" c="dimmed">Today</Text></Card> },
  { value: 'snap-2', view: <Card shadow="sm" p="lg" withBorder h="100%"><Title order={4}>Snapshot 2</Title><Text size="sm" c="dimmed">Yesterday</Text></Card> },
  { value: 'snap-3', view: <Card shadow="sm" p="lg" withBorder h="100%"><Title order={4}>Snapshot 3</Title><Text size="sm" c="dimmed">2 days ago</Text></Card> },
  { value: 'snap-4', view: <Card shadow="sm" p="lg" withBorder h="100%"><Title order={4}>Snapshot 4</Title><Text size="sm" c="dimmed">3 days ago</Text></Card> },
];

function Demo() {
  return (
    <Stack pt={60}>
      <DepthSelect data={data} defaultValue="snap-2" withControls={false} w={400} h={150}>
        <DepthSelect.Controls labelFormatter={(item) => String(item.value)} />
      </DepthSelect>
    </Stack>
  );
}

Rich cards

You can use any React content inside items. Here is an example with decorated cards featuring images, badges and buttons:

Hawaii

Hawaii Beach Retreat

Popular

Relax on pristine beaches with crystal clear waters and enjoy tropical island activities

hawaii
import { useState } from 'react';
import { Badge, Button, Card, Group, Image, Stack, Text } from '@mantine/core';
import { DepthSelect, DepthSelectItem } from '@gfazioli/mantine-depth-select';

const data: DepthSelectItem[] = [
  {
    value: 'norway',
    view: (
      <Card shadow="sm" padding="lg" radius="md" withBorder h="100%">
        <Card.Section>
          <Image src="..." height={160} alt="Norway" />
        </Card.Section>
        <Group justify="space-between" mt="md" mb="xs">
          <Text fw={500}>Norway Fjord Adventures</Text>
          <Badge color="pink">On Sale</Badge>
        </Group>
        <Text size="sm" c="dimmed">Explore magical fjord landscapes...</Text>
        <Button color="blue" fullWidth mt="md" radius="md">Book classic tour now</Button>
      </Card>
    ),
  },
  // ... more items
];

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

  return (
    <Stack pt={80} pb={80}>
      <DepthSelect
        data={data}
        value={value}
        onChange={setValue}
        controlsProps={{ labelFormatter: (item) => String(item.value) }}
        w={400}
        h={340}
      />
    </Stack>
  );
}

Use cases

Pricing plan selector

The depth stack creates a natural visual hierarchy, making the front card feel "recommended." Navigate between plans to compare features:

Pro

Popular

$19/month

  • Unlimited projects
  • 10 GB storage
  • Priority support
  • Custom domain
pro
import { Badge, Button, Card, Group, List, Text, Title } from '@mantine/core';
import { DepthSelect, DepthSelectItem } from '@gfazioli/mantine-depth-select';

const plans: DepthSelectItem[] = [
  {
    value: 'free',
    view: (
      <Card shadow="sm" padding="lg" radius="md" withBorder h="100%">
        <Group justify="space-between" mb="xs">
          <Title order={3}>Free</Title>
          <Badge variant="light">Current</Badge>
        </Group>
        <Title order={2} mb="md">$0<Text span size="sm" c="dimmed">/month</Text></Title>
        <List size="sm" spacing="xs" mb="md">
          <List.Item>1 project</List.Item>
          <List.Item>100 MB storage</List.Item>
          <List.Item>Community support</List.Item>
        </List>
        <Button variant="default" fullWidth radius="md">Current plan</Button>
      </Card>
    ),
  },
  {
    value: 'pro',
    view: (
      <Card shadow="sm" padding="lg" radius="md" withBorder h="100%">
        <Group justify="space-between" mb="xs">
          <Title order={3}>Pro</Title>
          <Badge color="blue">Popular</Badge>
        </Group>
        <Title order={2} mb="md">$19<Text span size="sm" c="dimmed">/month</Text></Title>
        <List size="sm" spacing="xs" mb="md">
          <List.Item>Unlimited projects</List.Item>
          <List.Item>10 GB storage</List.Item>
          <List.Item>Priority support</List.Item>
        </List>
        <Button color="blue" fullWidth radius="md">Upgrade to Pro</Button>
      </Card>
    ),
  },
  // ... Enterprise plan
];

function Demo() {
  return (
    <DepthSelect
      data={plans}
      defaultValue="pro"
      controlsLabelFormatter={(item) => String(item.value)}
      w={350}
      h={340}
    />
  );
}

Document version history

Browse through document versions with author info and change summaries. The depth effect naturally communicates "older = further back":

JD

John Doe

Latest

2 hours ago

+ Added error handling for API calls
v3
import { Avatar, Badge, Card, Code, Group, Text } from '@mantine/core';
import { DepthSelect, DepthSelectItem } from '@gfazioli/mantine-depth-select';

const versions: DepthSelectItem[] = [
  {
    value: 'v3',
    view: (
      <Card shadow="sm" p="lg" withBorder h="100%">
        <Group justify="space-between" mb="xs">
          <Group gap="sm">
            <Avatar color="blue" radius="xl" size="sm">JD</Avatar>
            <Text size="sm" fw={500}>John Doe</Text>
          </Group>
          <Badge color="green" size="sm">Latest</Badge>
        </Group>
        <Text size="xs" c="dimmed" mb="xs">2 hours ago</Text>
        <Code block>+ Added error handling for API calls</Code>
      </Card>
    ),
  },
  // ... more versions
];

function Demo() {
  return (
    <DepthSelect
      data={versions}
      controlsLabelFormatter={(item) => String(item.value)}
      w={400}
      h={180}
    />
  );
}

Onboarding wizard

A step-by-step onboarding flow where completed steps move behind the current one. More engaging than a traditional linear stepper:

Welcome!

Let us show you around. This quick tour will help you get started with the most important features.

Step 1 of 4

import { useState } from 'react';
import { Button, Card, Group, Stack, Text, Title } from '@mantine/core';
import { DepthSelect, DepthSelectItem } from '@gfazioli/mantine-depth-select';

const steps: DepthSelectItem[] = [
  { value: 'welcome', view: <Card shadow="sm" p="lg" withBorder h="100%"><Title order={3} mb="xs">Welcome!</Title><Text size="sm" c="dimmed">Let us show you around...</Text></Card> },
  { value: 'profile', view: <Card shadow="sm" p="lg" withBorder h="100%"><Title order={3} mb="xs">Set up your profile</Title><Text size="sm" c="dimmed">Add a photo and bio...</Text></Card> },
  { value: 'project', view: <Card shadow="sm" p="lg" withBorder h="100%"><Title order={3} mb="xs">Create your first project</Title><Text size="sm" c="dimmed">Projects organize your work...</Text></Card> },
  { value: 'done', view: <Card shadow="sm" p="lg" withBorder h="100%"><Title order={3} mb="xs">You are all set!</Title><Text size="sm" c="dimmed">Explore the dashboard...</Text></Card> },
];

function Demo() {
  const [value, setValue] = useState<string | number>('welcome');
  const currentIndex = steps.findIndex((s) => s.value === value);
  const isLast = currentIndex === steps.length - 1;

  return (
    <Stack align="center">
      <DepthSelect
        data={steps}
        value={value}
        onChange={setValue}
        withControls={false}
        w={400}
        h={140}
      />
      <Group>
        <Button
          variant="default"
          size="xs"
          onClick={() => setValue(steps[currentIndex - 1].value)}
          disabled={currentIndex <= 0}
        >
          Back
        </Button>
        <Text size="xs" c="dimmed">
          Step {currentIndex + 1} of {steps.length}
        </Text>
        <Button
          size="xs"
          onClick={() => !isLast && setValue(steps[currentIndex + 1].value)}
          disabled={isLast}
        >
          {isLast ? 'Done' : 'Continue'}
        </Button>
      </Group>
    </Stack>
  );
}

Emoji stack

Items don't need to be cards — any React content works. This example uses emoji with varying widths to test how different-sized content behaves:

🎉🎈

2 emoji
import { Center, Text } from '@mantine/core';
import { DepthSelect, DepthSelectItem } from '@gfazioli/mantine-depth-select';

const data: DepthSelectItem[] = [
  { value: '1', view: <Center h="100%"><Text fz={64}>🎉</Text></Center> },
  { value: '2', view: <Center h="100%"><Text fz={64}>🎉🎈</Text></Center> },
  { value: '3', view: <Center h="100%"><Text fz={64}>🎉🎈🎁</Text></Center> },
  { value: '4', view: <Center h="100%"><Text fz={64}>🎉🎈🎁🎊</Text></Center> },
];

function Demo() {
  return (
    <DepthSelect
      data={data}
      defaultValue="2"
      loop
      controlsProps={{ labelFormatter: (item) => `${item.value} emoji` }}
      w={350}
      h={120}
    />
  );
}

Styles API

DepthSelect supports Styles API, you can add styles to any inner element of the component with classNames prop.

Snapshot 1

Today

Component Styles API

Hover over selectors to highlight corresponding elements

/*
 * Hover over selectors to apply outline styles
 *
 */