Demo

Motivasi

Dalam salah satu proyek tempat saya bekerja, carousel digunakan secara luas. Hingga saat ini paket carousel npm eksternal digunakan. Meskipun saya tidak menentang paket seperti itu, kasus penggunaan kami sangat spesifik. Carousel yang disediakan dari paket eksternal mencoba menangani semua kasus sehingga bobotnya jauh lebih besar. Tujuan saya adalah membuat carousel yang sesuai dengan kebutuhan kita dan ukurannya sangat kecil di disk.

Apa yang akan kami bangun?

Kami akan membuat carousel yang menampilkan satu gambar dalam satu waktu. Di akhir slide, slide pertama akan ditampilkan seolah-olah itu adalah baris berikutnya.

Saya akan menjelaskan cara kerja carousel dengan menyorot bagian terpenting dari komponen carousel dan menghilangkan bagian yang tidak penting untuk pemahamannya. Untuk kode selengkapnya silahkan gulir ke akhir artikel ini.

Apa yang kami perlukan?

Kami akan menggunakan React (dengan TypeScript) dan komponen bergaya untuk mencapai tujuan kami.

Persiapan

Anda dapat menggunakan npm atau benang. Satu-satunya alasan menggunakan npm dalam tutorial ini adalah kesederhanaan.
Pertama kita membuat proyek React dan menginstal komponen-komponen yang ditata:

npx create-react-app carousels --template typescript
cd carousels
npm i styled-components
npm start

Korsel

Bergerak

Saat menghadapi carousel seperti ini kita perlu menyadari bahwa ia memiliki empat gerakan. Jika pengguna ingin melihat gambar berikutnya, carousel akan menampilkan gambar berikutnya (shift berikutnya) atau harus menampilkan gambar pertama ketika tidak ada lagi gambar (lompatan berikutnya). Situasi yang sama terjadi dalam arah yang berlawanan sehingga memberi kita dua gerakan lagi: pergeseran sebelumnya dan lompatan sebelumnya.

Untuk menentukan apakah shiftnya adalah shift berikutnya atau shift sebelumnya kita perlu mengurangi posisi saat ini dari posisi sebelumnya. Jika angkanya positif kita ada shift berikutnya. Jika angkanya negatif kita punya shift sebelumnya. Situasinya sebaliknya jika kita menemui lompatan. Oleh karena itu untuk mendapatkan semua informasi kita perlu mengetahui tiga hal: posisi sebelumnya, posisi saat ini dan arah.

Mekanik

Kami menumpuk semua gambar di atas gambar lainnya. Kemudian kami menggunakan animasi css untuk menangani perubahan status guna menciptakan efek geser sebagai respons terhadap tindakan pengguna. Di akhir animasi, hanya satu gambar yang ditampilkan kepada pengguna. Pada titik ini carousel siap untuk pergerakan lainnya.

Kita perlu mempertimbangkan gambar yang terlihat saat ini dan gambar berikutnya yang akan ditampilkan. Gambar yang sedang terlihat harus digeser menjauh (set) dari posisi saat ini. Gambar berikutnya harus digeser ke posisi saat ini (naik).

Gambar yang terlihat saat ini akan bergeser ke kiri atau ke kanan tergantung apakah pengguna ingin melihat gambar sebelumnya atau berikutnya. Ini memberi kita dua animasi: nextSet dan previousSet. Gambar berikutnya akan meluncur ke tampilan dari kanan atau kiri tergantung apakah pengguna ingin melihat gambar sebelumnya atau berikutnya. Ini memberi kita dua animasi lagi: nextRisedan previousRise.

Penjelasan dengan kode

Negara Bagian

Seperti yang dijelaskan sebelumnya di artikel ini, kita memerlukan status yang mempertahankan nilai sebelumnya, saat ini, dan arah. Kita akan menggunakan hook useState yang akan berisi array. Array terdiri dari tiga angka yaitu [saat ini, sebelumnya, arah]. Nilai saat ini dan sebelumnya dapat memiliki nilai numerik apa pun yang mewakili posisi dalam larik slide. Angka terakhir adalah nilai arah yang akan kita atur ke -1, 0 atau 1. -1 mewakili gerakan mundur, 1 maju dan 0 adalah keadaan awal.

const [shift, setShift] = useState([0, 0, 0]);

Ganti pengendali

Ketika pengguna ingin melihat slide lain

  • Kita harus menghitung posisi slide yang menjadi saat ini dan mengaturnya ke elemen pertama dalam array kita (shift[0])
  • Kita harus memindahkan slide saat ini ke posisi kedua seperti sebelumnya (shift[1])
  • Elemen yang dulu ada dibuang
  • Kita mencatat arah pergerakannya dan mengaturnya dalam array kita (shift[2])
  • Kita harus ingat bahwa setelah slide terakhir kita menampilkan slide pertama saat maju dan setelah slide pertama kita menampilkan slide terakhir saat mundur.

Semua manipulasi ini akan tercermin dalam animasi/gerakan kita.

type SlideShiftDirection = -1 | 0 | 1;

const shiftPosition = (direction: SlideShiftDirection) => {
  const nextShift = Math.abs(count + shift[0] + direction) % count;
  setShift([nextShift].concat(shift).slice(0, 2).concat([direction]));
};

Animasi

Nama animasi terdiri dari dua bagian:

  • arah — bergantung pada tindakan pengguna: pengguna ingin melihat slide berikutnya — berikutnya, pengguna ingin melihat slide sebelumnya sebelumnya — sebelumnya
  • perilaku — diatur saat slide keluar dari tampilan dan naik saat masuk ke tampilan

Oleh karena itu kita membutuhkan empat animasi. Kita juga memerlukan animasi void sebagai pengganti. Ini memberi kita total lima animasi: peningkatan sebelumnya, peningkatan berikutnya, set sebelumnya, set berikutnya, batalkan animasi

const voidAnimation = keyframes``;

const nextRise = keyframes`
  0% { transform: translateX(100%); }
  100% { transform: translateX(0); }
`;

const nextSet = keyframes`
  0% { transform: translateX(0); }
  100% { transform: translateX(-100%); }
`;

const previousRise = keyframes`
  0% { transform: translateX(-100%); }
  100% { transform: translateX(0); }
`;

const previousSet = keyframes`
  0% { transform: translateX(0); }
  100% { transform: translateX(100%); }
`;

nextRise — Pengguna ingin melihat slide berikutnya. Slide berikutnya berpindah dari kanan ke kiri dan berakhir di tampilan
nextSet — Pengguna ingin melihat slide berikutnya. Slide saat ini berpindah dari kanan ke kiri dan berakhir di luar tampilan
previousRise — Pengguna ingin melihat slide sebelumnya. Slide berikutnya berpindah dari kiri ke kanan dan berakhir di tampilan
previousSet — Pengguna ingin melihat slide sebelumnya. Slide saat ini berpindah dari kiri ke kanan dan berakhir di luar tampilan

Struktur

Korsel terdiri dari dua elemen utama. Komponen carousel dan komponen Slide.

<Carousel>
  <Slides shift={shift} slideTime={slideTime}>
    {children}
  </Slides>
  {/* controls */}
</Carousel>

Komponen Carousel adalah pembungkus utama untuk slide dan kontrol carousel. Itu juga dapat berisi elemen carousel lain seperti misalnya titik kemajuan.

Komponen Slides adalah pekerja keras utama carousel. Mari kita bahas yang satu ini secara detail.

const Slides = styled.section<{ shift: number[]; slideTime: number }>`
  overflow: hidden;
  display: grid;
  grid-template-columns: 1fr;
  grid-template-rows: 1fr;
  & > * {
    z-index: -1;
    grid-row: 1;
    grid-column: 1;
  }
  ${({ shift, slideTime }) => [
    css`
      & > *:not(:nth-child(${1 + shift[0]})):not(:nth-child(${1 + shift[1]})) {
        display: none;
      }
    `,
    css`
      & > *:nth-child(${1 + shift[0]}) {
        animation: ${slideTime}ms ease-out
          ${shift[2] === 0
            ? voidAnimation
            : shift[2] === 1
            ? nextRise
            : previousRise}
          forwards;
        z-index: 1;
      }
    `,
    css`
      & > *:nth-child(${1 + shift[1]}) {
        animation: ${slideTime}ms ease-out
          ${shift[2] === 0
            ? voidAnimation
            : shift[2] === 1
            ? nextSet
            : previousSet}
          forwards;
        z-index: 0;
      }
    `,
  ]}
`;

Meluap

overflow: tersembunyi memastikan bahwa hanya satu gambar yang terlihat setelah animasi selesai dan selama animasi kita dapat melihat keadaan peralihan tanpa dua gambar ditampilkan. Ini menciptakan efek jendela.

Menumpuk elemen

display: grid;
grid-template-columns: 1fr;
grid-template-rows: 1fr;
& > * {
  z-index: -1;
  grid-row: 1;
  grid-column: 1;
 }

Potongan kode ini membuat kisi dengan satu sel. Semua elemen dipaksa untuk menempati sel khusus ini.

Pendekatan penumpukan ini lebih mudah dikelola dan lebih intuitif untuk digunakan dibandingkan dengan menggunakan position: absolute. Slide yang memiliki dimensi mampu meregangkan wadahnya dibandingkan hanya bertumpuk ketika wadahnya tidak diberi dimensi apa pun.

Animasi — sintaksis

Mari kita uraikan sintaksis animasinya.

Kami membuka ekspresi. Di dalamnya kita menempatkan fungsi kita yang akan mengambil props dan menangani gaya. Kami akan mengembalikan array dari fungsi ini. Array akan berisi aturan css`` untuk diterapkan berdasarkan props. komponen bergaya dapat menerapkan semua gaya dari daftar.

${}
${(p) => { /* do something with props */ }}
${({ shift, slideTime }) => { /* do something with props */ } }
${({ shift, slideTime }) => []}
${({ shift, slideTime }) => [css``, css``, css``]

Di carousel kita memiliki tiga aturan css`` jadi mari kita bahas fungsinya.

css`
  & > *:not(:nth-child(${1 + shift[0]})):not(:nth-child(${1 + shift[1]})) {
    display: none;
  }
`

1. Potongan kode ini mengatakan bahwa semua gambar di tumpukan disembunyikan kecuali yang terlihat saat ini dan gambar sebelumnya. Gambar sebelumnya juga tidak terlihat oleh pengguna karena grid kita memiliki properti overflow: tersembunyi dan gambar sebelumnya digeser dengan translateX. Pendekatan ini memungkinkan penggunaan pemuatan lambat dalam kerangka kerja seperti NextJS. Misalnya kerangka NextJS tidak memuat gambar yang tidak terlihat (display: none) dengan bantuan komponen Gambar bawaannya. Ini secara efektif membuat gambar carousel kami dimuat dengan lambat. Penting juga untuk dicatat bahwa hanya slide yang tidak akan ditampilkan yang ditargetkan. Kami tidak mengatur atau mengubah properti display pada slide mana pun yang terlihat. Bagian 1 + in 1 + shift[0] berasal dari fakta bahwa array dihitung dari 0 tetapi node DOM dihitung dari 1.

css`
  & > *:nth-child(${1 + shift[0]}) {
    animation: ${slideTime}ms ease-out
      ${shift[2] === 0
        ? voidAnimation
        : shift[2] === 1
        ? rightRise
        : leftRise}
      forwards;
    z-index: 1;
  }
`,

ke-2. shift[2]mewakili arah di negara bagian kita. Bagian ini menargetkan slide yang akan menjadi terkini. Ia memutar animasi dengan durasi slideTime dan memilih salah satu naikyang berarti slide akan muncul dari salah satu sisi dan di akhir animasi akan tetap terlihat. Jika kita dalam keadaan awal, animasi void diputar yang berarti tidak ada animasi. Ini juga mengatur z-index: 1 yang memastikan bahwa slide yang menjadi saat ini tidak tercakup oleh slide yang menjadi sebelumnya dan tidak terlihat.

ke-3. Aturan css ketiga secara konseptual memiliki tugas yang sama dengan aturan kedua. Perbedaannya adalah z-index yang disetel ke 0 dan yang ketiga memutar setanimasi (slide out). Ini menargetkan slide yang akan menjadi sebelumnya.

Menambahkan fitur dan memperbaiki masalah

Masalah — Klik cepat

Karena carousel didasarkan pada animasi, maka ada satu kelemahan: ketika pengguna mengeklik lebih cepat daripada animasi selesai, animasi akan rusak. Untuk memperbaikinya kita perlu membuat fungsi throttle yang akan mengabaikan klik pengguna hingga animasi selesai. Kemudian kita perlu menerapkan fungsi ini ke shift handler kita.

const throttle = (fn: () => void, cooldown: number) => {
  let time = Date.now();
  return () =>
    time + cooldown < Date.now() ? void (fn(), (time = Date.now())) : undefined;
};

// ...

const shiftRight = throttle(() => shiftPosition(-1), slideTime);
const shiftLeft = throttle(() => shiftPosition(1), slideTime);

Fitur — titik kemajuan

Kita mungkin ingin melihat di slide mana kita berada. Untuk melakukan hal ini kita dapat menggunakan titik-titik yang semi transparan ketika tidak aktif dan aktif ketika posisi slide sesuai dengan posisi titik. Kita memerlukan daftar properti yang akan mewakili posisi titik dan apakah terisi atau tidak. Lalu kita bisa memetakan array ini ke titik-titik.

const count = Children.count(children)
const progressArray = new Array(count ?? 0).fill(null).map((_, i) => ({ filled: i === shift[0], position: i }))

Fitur — membuat titik kemajuan dapat diklik

Kami mungkin ingin titik-titik dapat diklik. Mengklik titik tersebut akan memindahkan kita ke bagian slidenya — slide yang memiliki nomor posisi yang sama dengan titik tersebut. Untuk memfasilitasi hal ini kita akan membuat fungsi lain yang mampu mengatur slide ke yang spesifik. Perlu diperhatikan bahwa dengan cara ini carousel tidak dapat membuat lompatan bergerak. Itu hanya bisa membuat perubahan itu terjadi. Fungsi setPosition juga harus dibatasi untuk menghindari lompatan animasi.

const setPosition = (position: number) => {
  const diff = position - shift[0]
  const direction = diff / Math.abs(diff)
  const nextShift = [position].concat(shift).slice(0, 2).concat([direction])

  setShift(nextShift)
}

const setPositionHandler = (position: number) =>
  throttle(() => setPosition(position), slideTime);

// ...

<Dot
  filled={filled}
  key={position}
  onClick={setPositionHandler(position)}
/>

Fitur — putar otomatis

Hal ini dapat dicapai dengan menambahkan interval yang secara otomatis menggeser posisi ke slide berikutnya secara berkala. Interval waktu tidak boleh lebih kecil dari waktu animasi slide individual. Kita menambahkan prop interval dan jika benar kita setInterval di hook useEffect.

useEffect(() => {
  if (typeof interval !== "number" || count < 2) return;
  const intervalID = setInterval(
    () => shiftPosition(1),
    Math.max(interval, slideTime)
  );

  return () => clearInterval(intervalID);
}, [shift[0]]);

Penggunaan

Korsel digunakan dengan merender berbagai elemen yang menjadi slide.

<StackCarousel>
  {[0, 1, 2, 3].map((n) => (
    <img key={n} src={`/images/${n + 1}.jpg`} />
  ))}
</StackCarousel>

Penting untuk dicatat bahwa komponen Carousel itu sendiri tidak memiliki lebar tertentu yang akan mengakibatkan carousel meregang sesuai lebar induknya. Panah akan ditempatkan dekat ke kiri dan ke kanan masing-masing induknya. Dengan cara ini Anda dapat dengan mudah menempatkan carousel pada wadah yang memiliki dimensi sesuai kebutuhan Anda. Anda dapat mengubah perilaku ini dengan menyetel width: fit-content ke komponen Carousel. Dengan cara ini panah akan berada di dalam carousel dan hanya akan menggunakan ruang sebanyak yang diperlukan.

Jika Anda memiliki elemen yang tidak meregang ke dimensi apa pun, mis. elemen dengan gambar latar belakang lebar slide tersebut akan menjadi 0. Anda perlu menatanya secara manual jika tidak, Anda tidak akan melihat slidenya.

Jika Anda memiliki gambar atau elemen lain yang memiliki dimensi berbeda, Anda perlu membungkus masing-masing elemen tersebut dengan elemen lain yang memiliki dimensi sama untuk setiap slide. Misalnya:

const SlideWrapper = styled.div`
  display: flex;
  align-items: center;
  justify-content: center;
  width: // specify your width;
  height: // specify your height;
`;

<StackCarousel>
  {[0, 1, 2, 3, 4].map((n) => (
    <SlideWrapper>
      <img src={`/images/${n + 1}.jpg`} />
    </SlideWrapper>
  ))}
</StackCarousel>

Mengubah display: none; menjadi visibility: hidden; pada aturan css``pertama tanpa menentukan width dan height akan meregangkan carousel ke slide terbesar namun mungkin tidak terkena pemuatan lambat yang disediakan oleh kerangka kerja seperti NextJS.

Kiat profesional

Anda dapat memberi nama Fragmendengan menugaskannya ke variabel. Anda kemudian dapat menggunakannya untuk membuat struktur Anda lebih mudah dibaca tanpa mengacaukannya dengan node DOM tambahan.

import { Fragment } from 'react';

// ...

const Actions = Fragment;

// ...

<Actions>
  <Action onClick={shiftRight} position="left">
    {"<"}
  </Action>
  <Action onClick={shiftLeft} position="right">
    {">"}
  </Action>
</Actions>

Kode lengkap — minimal

import { Children, Fragment, PropsWithChildren, useState } from "react";
import styled, { css, keyframes } from "styled-components";

const voidAnimation = keyframes``;

const nextRise = keyframes`
  0% { transform: translateX(100%); }
  100% { transform: translateX(0); }
`;

const nextSet = keyframes`
  0% { transform: translateX(0); }
  100% { transform: translateX(-100%); }
`;

const previousRise = keyframes`
  0% { transform: translateX(-100%); }
  100% { transform: translateX(0); }
`;

const previousSet = keyframes`
  0% { transform: translateX(0); }
  100% { transform: translateX(100%); }
`;

type SlideShiftDirection = -1 | 0 | 1;

type Props = {
  slideTime?: number;
};

export const StackCarousel: React.FC<
  PropsWithChildren<Props & { className?: string }>
> = ({ className, children, slideTime = 200 }) => {
  const [shift, setShift] = useState([0, 0, 0]); // [current, previous, direction]
  const count = Children.count(children);

  const shiftPosition = (direction: SlideShiftDirection) => {
    const nextShift = Math.abs(count + shift[0] + direction) % count;
    setShift([nextShift].concat(shift).slice(0, 2).concat([direction]));
  };

  const shiftRight = () => shiftPosition(-1);
  const shiftLeft = () => shiftPosition(1);

  return (
    <Carousel className={className}>
      <Slides shift={shift} slideTime={slideTime}>
        {children}
      </Slides>
      {count > 1 && (
        <Actions>
          <Action onClick={shiftRight} position="left">
            {"<"}
          </Action>
          <Action onClick={shiftLeft} position="right">
            {">"}
          </Action>
        </Actions>
      )}
    </Carousel>
  );
};

const Actions = Fragment;

const Slides = styled.section<{ shift: number[]; slideTime: number }>`
  overflow: hidden;
  display: grid;
  grid-template-columns: 1fr;
  grid-template-rows: 1fr;
  & > * {
    z-index: -1;
    grid-row: 1;
    grid-column: 1;
  }
  ${({ shift, slideTime }) => [
    css`
      & > *:not(:nth-child(${1 + shift[0]})):not(:nth-child(${1 + shift[1]})) {
        display: none;
      }
    `,
    css`
      & > *:nth-child(${1 + shift[0]}) {
        animation: ${slideTime}ms ease-out
          ${shift[2] === 0
            ? voidAnimation
            : shift[2] === 1
            ? nextRise
            : previousRise}
          forwards;
        z-index: 1;
      }
    `,
    css`
      & > *:nth-child(${1 + shift[1]}) {
        animation: ${slideTime}ms ease-out
          ${shift[2] === 0
            ? voidAnimation
            : shift[2] === 1
            ? nextSet
            : previousSet}
          forwards;
        z-index: 0;
      }
    `,
  ]}
`;

const Carousel = styled.div`
  position: relative;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
`;

const Action = styled.button<{ position: "left" | "right" }>`
  background-color: #eee;
  border-radius: 4px;
  border: none;
  cursor: pointer;
  height: 32px;
  position: absolute;
  top: 50%;
  width: 32px;
  z-index: 5;
  ${(p) => p.position === "left" && "left: 0"};
  ${(p) => p.position === "right" && "right: 0"};
`;

Kode lengkap — lengkap

import {
  Children,
  Fragment,
  PropsWithChildren,
  useEffect,
  useState,
} from "react";
import styled, { css, keyframes } from "styled-components";

const voidAnimation = keyframes``;

const nextRise = keyframes`
  0% { transform: translateX(100%); }
  100% { transform: translateX(0); }
`;

const nextSet = keyframes`
  0% { transform: translateX(0); }
  100% { transform: translateX(-100%); }
`;

const previousRise = keyframes`
  0% { transform: translateX(-100%); }
  100% { transform: translateX(0); }
`;

const previousSet = keyframes`
  0% { transform: translateX(0); }
  100% { transform: translateX(100%); }
`;

type SlideShiftDirection = -1 | 0 | 1;

type Props = {
  slideTime?: number;
  interval?: number;
};

const throttle = (fn: () => void, cooldown: number) => {
  let time = Date.now();
  return () =>
    time + cooldown < Date.now() ? void (fn(), (time = Date.now())) : undefined;
};

export const StackCarousel: React.FC<
  PropsWithChildren<Props & { className?: string }>
> = ({ className, children, interval = 1000, slideTime = 400 }) => {
  const [shift, setShift] = useState([0, 0, 0]); // [current, previous, direction]
  const count = Children.count(children);
  const progressArray = new Array(count ?? 0)
    .fill(null)
    .map((_, i) => ({ filled: i === shift[0], position: i }));

  const shiftPosition = (direction: SlideShiftDirection) => {
    const nextShift = Math.abs(count + shift[0] + direction) % count;
    setShift([nextShift].concat(shift).slice(0, 2).concat([direction]));
  };

  const setPosition = (position: number) => {
    const diff = position - shift[0];
    const direction = diff / Math.abs(diff);
    const nextShift = [position].concat(shift).slice(0, 2).concat([direction]);

    setShift(nextShift);
  };

  const shiftRight = throttle(() => shiftPosition(-1), slideTime);
  const shiftLeft = throttle(() => shiftPosition(1), slideTime);
  const setPositionHandler = (position: number) =>
    throttle(() => setPosition(position), slideTime);

  useEffect(() => {
    if (typeof interval !== "number" || count < 2) return;
    const intervalID = setInterval(
      () => shiftPosition(1),
      Math.max(interval, slideTime)
    );

    return () => clearInterval(intervalID);
  }, [shift[0]]);

  return (
    <Carousel className={className}>
      <Slides shift={shift} slideTime={slideTime}>
        {children}
      </Slides>
      {count > 1 && (
        <Actions>
          <Action onClick={shiftRight} position="left">
            {"<"}
          </Action>
          <Action onClick={shiftLeft} position="right">
            {">"}
          </Action>
        </Actions>
      )}
      {count > 1 && (
        <Dots>
          {progressArray.map(({ filled, position }) => (
            <Dot
              filled={filled}
              key={position}
              onClick={setPositionHandler(position)}
            />
          ))}
        </Dots>
      )}
    </Carousel>
  );
};

const Actions = Fragment;

const Slides = styled.section<{ shift: number[]; slideTime: number }>`
  overflow: hidden;
  display: grid;
  grid-template-columns: 1fr;
  grid-template-rows: 1fr;
  & > * {
    z-index: -1;
    grid-row: 1;
    grid-column: 1;
  }
  ${({ shift, slideTime }) => [
    css`
      & > *:not(:nth-child(${1 + shift[0]})):not(:nth-child(${1 + shift[1]})) {
        display: none;
      }
    `,
    css`
      & > *:nth-child(${1 + shift[0]}) {
        animation: ${slideTime}ms ease-out
          ${shift[2] === 0
            ? voidAnimation
            : shift[2] === 1
            ? nextRise
            : previousRise}
          forwards;
        z-index: 1;
      }
    `,
    css`
      & > *:nth-child(${1 + shift[1]}) {
        animation: ${slideTime}ms ease-out
          ${shift[2] === 0
            ? voidAnimation
            : shift[2] === 1
            ? nextSet
            : previousSet}
          forwards;
        z-index: 0;
      }
    `,
  ]}
`;

const Carousel = styled.div`
  position: relative;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
`;

const Action = styled.button<{ position: "left" | "right" }>`
  background-color: #eee;
  border-radius: 4px;
  border: none;
  cursor: pointer;
  height: 32px;
  position: absolute;
  top: 50%;
  width: 32px;
  z-index: 5;
  ${(p) => p.position === "left" && "left: 0"};
  ${(p) => p.position === "right" && "right: 0"};
`;

const Dots = styled.aside`
  bottom: 8px;
  display: flex;
  gap: 8px;
  justify-content: space-between;
  left: 50%;
  position: absolute;
  transform: translateX(-50%);
  z-index: 5;
  cursor: pointer;
`;

const Dot = styled.div<{ filled?: boolean }>`
  background-color: white;
  opacity: ${(p) => (p.filled ? 1 : 0.5)};
  border-radius: 100%;
  height: 8px;
  width: 8px;
  transition: opacity 300ms ease-out;
  &:hover {
    transition: opacity 300ms ease-out;
    opacity: 1;
  }
`;