react-carousel-latest
Version:
A headless, accessible, tree-shakeable React carousel with a compound-component API (and a backwards-compatible CardSlider preset).
788 lines (712 loc) • 19.2 kB
CSS
/*
* react-carousel-latest — base styles.
*
* Import once at your app root: import "react-carousel-latest/styles.css";
*
* Everything is namespaced with `rc-` and driven by CSS custom properties, so
* you can theme without overriding rules. There is NO Tailwind dependency.
*/
.rc-root {
/* Theming knobs — override on .rc-root or any ancestor. */
--rc-slide-size: 100%;
--rc-slide-gap: 1rem;
--rc-duration: 350ms;
--rc-easing: cubic-bezier(0.22, 1, 0.36, 1);
--rc-button-bg: rgba(17, 24, 39, 0.65);
--rc-button-bg-hover: rgba(17, 24, 39, 0.85);
--rc-button-color: #fff;
--rc-button-size: 2.5rem;
--rc-accent: #111827;
--rc-dot-color: rgba(17, 24, 39, 0.22);
--rc-dot-active-color: var(--rc-accent);
position: relative;
outline: none;
}
.rc-root:focus-visible {
outline: 2px solid var(--rc-accent);
outline-offset: 2px;
}
/* Clipping window. */
.rc-viewport {
overflow: hidden;
width: 100%;
height: 100%;
}
/*
* Vertical carousels need a bounded height so the stacked slides can be
* clipped and translated. Override --rc-viewport-height to taste.
*/
.rc-viewport[data-orientation="vertical"] {
height: var(--rc-viewport-height, 360px);
}
/*
* The moving strip. The track box is viewport-wide, so a translate of one
* slide-size (+ gap) advances exactly one slide — whether --rc-slide-size is a
* percentage (multiple cards per view) or a fixed width.
*/
.rc-track {
display: flex;
gap: var(--rc-slide-gap);
will-change: transform;
transition: transform var(--rc-duration) var(--rc-easing);
transform: translate3d(
calc(
var(--rc-active-index, 0) * -1 *
(var(--rc-slide-size, 100%) + var(--rc-slide-gap)) + var(--rc-drag, 0px)
),
0,
0
);
}
.rc-track[data-orientation="vertical"] {
flex-direction: column;
/* Fill the bounded viewport so a translate of one slide-size advances one
slide on the vertical axis, mirroring the horizontal case. */
height: 100%;
transform: translate3d(
0,
calc(
var(--rc-active-index, 0) * -1 *
(var(--rc-slide-size, 100%) + var(--rc-slide-gap)) + var(--rc-drag, 0px)
),
0
);
}
/* No animation while the finger is down — the content must track the pointer. */
.rc-track[data-dragging] {
transition: none;
cursor: grabbing;
}
.rc-slide {
flex: 0 0 var(--rc-slide-size);
min-width: 0;
min-height: 0;
user-select: none;
}
/* ----------------------------- Buttons ----------------------------- */
/* Base pill — used by every control (prev/next/first/last/play-pause). */
.rc-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--rc-button-size);
height: var(--rc-button-size);
padding: 0;
font-size: calc(var(--rc-button-size) * 0.5);
color: var(--rc-button-color);
background: var(--rc-button-bg);
border: none;
border-radius: 9999px;
cursor: pointer;
transition: background-color 150ms ease, opacity 150ms ease, transform 150ms ease;
}
.rc-button:hover:not(:disabled) {
background: var(--rc-button-bg-hover);
}
.rc-button:active:not(:disabled) {
transform: scale(0.92);
}
.rc-button:disabled {
opacity: 0.35;
cursor: default;
}
.rc-button:focus-visible {
outline: 2px solid var(--rc-accent);
outline-offset: 2px;
}
/* Prev/next overlay the track edges. */
.rc-button--prev,
.rc-button--next {
position: absolute;
top: 50%;
z-index: 2;
transform: translateY(-50%);
}
.rc-button--prev:active:not(:disabled),
.rc-button--next:active:not(:disabled) {
transform: translateY(-50%) scale(0.92);
}
.rc-button--prev {
left: 0.5rem;
}
.rc-button--next {
right: 0.5rem;
}
.rc-button__icon {
display: block;
}
/* Optional controls row for Start / Play-Pause / End etc. */
.rc-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-top: 0.75rem;
}
/* ----------------------------- Dots ----------------------------- */
.rc-dots {
display: flex;
gap: 0.5rem;
justify-content: center;
margin-top: 0.75rem;
}
.rc-dot {
width: 0.625rem;
height: 0.625rem;
padding: 0;
background: var(--rc-dot-color);
border: none;
border-radius: 9999px;
cursor: pointer;
transition: background-color 150ms ease, transform 150ms ease;
}
.rc-dot:focus-visible {
outline: 2px solid var(--rc-accent);
outline-offset: 2px;
}
.rc-dot--active {
background: var(--rc-dot-active-color);
transform: scale(1.25);
}
/* Respect users who prefer reduced motion. */
@media (prefers-reduced-motion: reduce) {
.rc-track {
transition-duration: 1ms;
}
}
/*
* Preset slider styles (CardSlider + Card + Blobs, ImageSlider, SlicerSlider).
*
* Import alongside the base stylesheet:
* import "react-carousel-latest/styles.css";
* import "react-carousel-latest/presets.css";
*
* Or pull both in with a single import:
* import "react-carousel-latest/bundle.css";
*
* Framework-free CSS — no Tailwind. Cards read their palette from the
* --rc-from / --rc-to custom properties set inline by <Card>, so a single set
* of colours drives every design variant.
*/
.rc-cardslider {
/* Card row height — arrows are centred on THIS, not on the container's full
height, so host layouts that stretch the slider can't push them off. */
--rc-cs-card-h: 180px;
--rc-cs-pad-top: 1.5rem;
position: relative;
width: 100%;
overflow: hidden;
box-sizing: border-box;
}
.rc-cardslider__overlay {
pointer-events: none;
position: absolute;
top: 0;
z-index: 10;
height: 100%;
width: 4rem;
}
.rc-cardslider__overlay--left {
left: 0;
background: linear-gradient(to right, rgba(255, 255, 255, 0.9), transparent);
}
.rc-cardslider__overlay--right {
right: 0;
background: linear-gradient(to left, rgba(255, 255, 255, 0.9), transparent);
}
.rc-cardslider__arrow {
position: absolute;
/* Anchor to the card row's vertical centre (top padding + half a card),
not 50% of the container — robust no matter how tall the host makes it. */
top: calc(var(--rc-cs-pad-top, 1.5rem) + var(--rc-cs-card-h, 180px) / 2);
z-index: 15;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 2.75rem;
height: 2.75rem;
color: #111827;
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 9999px;
opacity: 0.5;
cursor: pointer;
backdrop-filter: blur(8px);
box-shadow: 0 10px 20px -8px rgba(0, 0, 0, 0.4);
transform: translateY(-50%);
transition: opacity 150ms ease;
}
.rc-cardslider__arrow:hover {
opacity: 1;
}
.rc-cardslider__arrow--left {
left: 0.75rem;
}
.rc-cardslider__arrow--right {
right: 0.75rem;
}
.rc-cardslider__track {
display: flex;
gap: 1.5rem;
box-sizing: border-box;
/* No height:100% — the row is sized by the cards, so a host-imposed height
on the slider can never stretch the track and shift the cards. */
padding: var(--rc-cs-pad-top, 1.5rem) 3.5rem 2rem;
overflow-x: auto;
scroll-behavior: smooth;
scrollbar-width: none;
}
.rc-cardslider__track::-webkit-scrollbar {
display: none;
}
.rc-cardslider__item {
/* Don't let flex shrink the cards — keep them full size and let the row scroll. */
flex: 0 0 auto;
}
.rc-cardslider__item a {
display: block;
text-decoration: none;
}
/* ============================ Card ============================ */
.rc-card {
/* Light text + accents by default (suited to the colourful variants). */
--rc-blob-fill: rgba(255, 255, 255, 0.9);
--rc-card-fg: #ffffff;
--rc-card-muted: rgba(255, 255, 255, 0.85);
--rc-card-tag-bg: rgba(0, 0, 0, 0.2);
--rc-card-tag-fg: #ffffff;
--rc-card-track: rgba(255, 255, 255, 0.3);
--rc-card-fill: #ffffff;
position: relative;
box-sizing: border-box;
height: var(--rc-cs-card-h, 180px);
width: 350px;
padding: 1.25rem;
overflow: hidden;
border-radius: 1rem;
box-shadow: 0 10px 20px -8px rgba(0, 0, 0, 0.25);
cursor: pointer;
/* Self-contained typography + alignment so the card looks identical in any
app, regardless of the host's global font or text-align (e.g. CRA/Vite
templates default to text-align:center). The named fonts are used when
present (e.g. the docs site) and fall back to system fonts everywhere else. */
text-align: left;
font-family: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.4;
}
.rc-card__blob {
position: absolute;
height: 10rem;
width: 12.5rem;
pointer-events: none;
}
.rc-card__tag {
position: relative;
z-index: 1;
display: inline-block;
padding: 0.25rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--rc-card-tag-fg);
background: var(--rc-card-tag-bg);
border-radius: 0.5rem;
}
.rc-card__title {
position: relative;
z-index: 1;
margin: 0.75rem 0 0;
/* Serif display face; falls back to a system serif when Fraunces isn't loaded. */
font-family: "Fraunces", ui-serif, Georgia, "Times New Roman", serif;
font-size: 1.25rem;
font-weight: 700;
color: var(--rc-card-fg);
}
.rc-card__progress {
position: relative;
z-index: 1;
margin-top: 1rem;
height: 0.25rem;
width: 100%;
background: var(--rc-card-track);
border-radius: 9999px;
overflow: hidden;
}
.rc-card__progress-fill {
height: 100%;
width: 90%;
background: var(--rc-card-fill);
border-radius: 9999px;
}
.rc-card__desc {
position: relative;
z-index: 1;
margin: 0.75rem 0 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--rc-card-muted);
}
.rc-card__button {
position: absolute;
top: 1rem;
right: 1rem;
z-index: 1;
display: inline-flex;
padding: 0.5rem;
color: var(--rc-card-fg);
background: rgba(255, 255, 255, 0.25);
border: none;
border-radius: 9999px;
cursor: pointer;
}
/* ------------------------- Variants ------------------------- */
/* Gradient (default) — the original look. */
.rc-card--gradient {
background: linear-gradient(135deg, var(--rc-from), var(--rc-to));
}
/* Glass — frosted film layered over the gradient. */
.rc-card--glass {
background:
linear-gradient(160deg, rgba(255, 255, 255, 0.28), rgba(255, 255, 255, 0.04)),
linear-gradient(135deg, var(--rc-from), var(--rc-to));
border: 1px solid rgba(255, 255, 255, 0.35);
backdrop-filter: blur(3px);
}
.rc-card--glass .rc-card__tag {
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(6px);
}
/* Solid — flat single colour. */
.rc-card--solid {
background: var(--rc-from);
}
/* Outline — light card, coloured border + accents, dark text. */
.rc-card--outline {
--rc-blob-fill: rgba(17, 24, 39, 0.05);
--rc-card-fg: #111827;
--rc-card-muted: #4b5563;
--rc-card-tag-bg: var(--rc-from);
--rc-card-tag-fg: #ffffff;
--rc-card-track: rgba(17, 24, 39, 0.1);
--rc-card-fill: linear-gradient(90deg, var(--rc-from), var(--rc-to));
background: #ffffff;
border: 2px solid var(--rc-from);
}
.rc-card--outline .rc-card__title {
color: var(--rc-from);
}
.rc-card--outline .rc-card__button {
color: var(--rc-from);
background: rgba(17, 24, 39, 0.06);
}
/* Dark — deep background, neon accent from the palette. */
.rc-card--dark {
--rc-blob-fill: rgba(255, 255, 255, 0.06);
--rc-card-fg: #f8fafc;
--rc-card-muted: rgba(248, 250, 252, 0.7);
--rc-card-tag-bg: rgba(255, 255, 255, 0.08);
--rc-card-tag-fg: var(--rc-to);
--rc-card-track: rgba(255, 255, 255, 0.12);
--rc-card-fill: linear-gradient(90deg, var(--rc-from), var(--rc-to));
background: #0f172a;
border: 1px solid var(--rc-from);
box-shadow: 0 0 28px -8px var(--rc-from);
}
.rc-card--dark .rc-card__title {
color: var(--rc-to);
}
.rc-card--dark .rc-card__button {
background: rgba(255, 255, 255, 0.1);
}
/* Minimal — clean white card, gradient only as a subtle accent. */
.rc-card--minimal {
--rc-blob-fill: rgba(17, 24, 39, 0.04);
--rc-card-fg: #111827;
--rc-card-muted: #6b7280;
--rc-card-tag-bg: #f3f4f6;
--rc-card-tag-fg: #374151;
--rc-card-track: #f3f4f6;
--rc-card-fill: linear-gradient(90deg, var(--rc-from), var(--rc-to));
background: #ffffff;
border: 1px solid #e5e7eb;
box-shadow: 0 8px 20px -10px rgba(0, 0, 0, 0.2);
}
.rc-card--minimal .rc-card__button {
color: var(--rc-from);
background: #f3f4f6;
}
/* ===================== ImageSlider preset ===================== */
.rc-image {
position: relative;
border-radius: 16px;
overflow: hidden;
background: #000;
/* thin, chrome-free arrows over the photo */
--rc-button-bg: transparent;
--rc-button-bg-hover: rgba(255, 255, 255, 0.18);
--rc-button-color: #fff;
--rc-button-size: 3rem;
}
.rc-image .rc-button {
font-size: 1.6rem;
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.55));
}
.rc-image__link {
display: block;
width: 100%;
}
.rc-image__slide {
position: relative;
width: 100%;
height: var(--rc-image-height, 460px);
display: flex;
align-items: center;
justify-content: center;
background-size: cover;
background-position: center;
}
.rc-image__caption {
padding: 0 1rem;
color: #fff;
font-size: clamp(1.5rem, 4vw, 2.5rem);
font-weight: 800;
text-align: center;
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.55);
}
.rc-image .rc-dots {
position: absolute;
left: 0;
right: 0;
bottom: 0.9rem;
margin: 0;
z-index: 3;
}
.rc-image .rc-dot {
background: rgba(255, 255, 255, 0.45);
}
.rc-image .rc-dot--active {
background: #fff;
}
/* ===================== SlicerSlider preset ===================== */
.rc-slicer {
position: relative;
height: var(--rc-image-height, 460px);
border-radius: 16px;
overflow: hidden;
background: #000;
outline: none;
--rc-button-bg: transparent;
--rc-button-bg-hover: rgba(255, 255, 255, 0.18);
--rc-button-color: #fff;
--rc-button-size: 3rem;
}
.rc-slicer:focus-visible {
outline: 2px solid var(--rc-accent, #fff);
outline-offset: 2px;
}
.rc-slicer .rc-button {
font-size: 1.6rem;
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.55));
}
.rc-slicer__viewport {
position: absolute;
inset: 0;
touch-action: pan-y;
}
.rc-slicer__slide {
position: absolute;
inset: 0;
z-index: 0;
}
.rc-slicer__slide[data-active] {
z-index: 1;
}
/*
* Each strip is one horizontal band of the image. The image is sized to the
* full slide box so every strip shares the same coordinates and the bands line
* up; background-position-y selects the band for strip k.
*/
.rc-slicer__strip {
position: absolute;
left: 0;
width: 100%;
height: calc(100% / var(--rc-slices, 6));
top: calc(var(--k) * 100% / var(--rc-slices, 6));
background-repeat: no-repeat;
background-size: 100% var(--rc-image-height, 460px);
background-position: 0 calc(var(--k) / (var(--rc-slices, 6) - 1) * 100%);
transform: translateX(-115%);
transition: transform 600ms cubic-bezier(0.7, 0, 0.3, 1);
transition-delay: calc(var(--k) * 70ms);
will-change: transform;
}
/* alternate strips enter from the opposite side for a woven wipe */
.rc-slicer__strip:nth-child(even) {
transform: translateX(115%);
}
.rc-slicer__slide[data-active] .rc-slicer__strip {
transform: translateX(0);
}
.rc-slicer__caption {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
padding: 0 1rem;
color: #fff;
font-size: clamp(1.5rem, 4vw, 2.5rem);
font-weight: 800;
text-align: center;
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.55);
pointer-events: none;
transition: opacity 250ms ease;
}
.rc-slicer__slide:not([data-active]) .rc-slicer__caption {
opacity: 0;
}
.rc-slicer .rc-dots {
position: absolute;
left: 0;
right: 0;
bottom: 0.9rem;
margin: 0;
z-index: 3;
}
.rc-slicer .rc-dot {
background: rgba(255, 255, 255, 0.45);
}
.rc-slicer .rc-dot--active {
background: #fff;
}
@media (prefers-reduced-motion: reduce) {
.rc-slicer__strip {
transition-duration: 1ms;
transition-delay: 0ms;
}
}
/* ===================== CubeSlider preset ===================== */
.rc-cube {
position: relative;
height: var(--rc-image-height, 460px);
border-radius: 16px;
background: #000;
outline: none;
--rc-button-bg: transparent;
--rc-button-bg-hover: rgba(255, 255, 255, 0.18);
--rc-button-color: #fff;
--rc-button-size: 3rem;
--rc-cube-depth: 0px;
--rc-cube-duration: 600ms;
}
.rc-cube:focus-visible {
outline: 2px solid var(--rc-accent, #fff);
outline-offset: 2px;
}
.rc-cube .rc-button {
z-index: 3;
font-size: 1.6rem;
filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.55));
}
/* Perspective host. touch-action frees the page to scroll on the cross axis. */
.rc-cube__viewport {
position: absolute;
inset: 0;
perspective: 1200px;
touch-action: pan-y;
}
.rc-cube[data-orientation="vertical"] .rc-cube__viewport {
touch-action: pan-x;
}
/*
* The 3D context. Pulled back by one depth so the active face (which is pushed
* forward by the same depth) lands flush in the viewport plane.
*/
.rc-cube__cage {
position: absolute;
inset: 0;
transform-style: preserve-3d;
transform: translateZ(calc(var(--rc-cube-depth) * -1));
}
/*
* Each face sits at `rotate(offset · 90°) translateZ(depth)`. All faces share
* one transition, so when the active index changes every offset shifts by one
* and the faces orbit together — a rigid 90° cube turn. Direction falls out of
* the offset's sign, so no prev-index bookkeeping is needed.
*/
.rc-cube__face {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
border-radius: 14px;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
transform: rotateY(calc(var(--off) * 90deg)) translateZ(var(--rc-cube-depth));
transition: transform var(--rc-cube-duration) cubic-bezier(0.66, 0, 0.34, 1);
}
.rc-cube[data-orientation="vertical"] .rc-cube__face {
transform: rotateX(calc(var(--off) * -90deg)) translateZ(var(--rc-cube-depth));
}
/* Subtle depth: darken the side faces, fading to clear on the active one. */
.rc-cube[data-shadow] .rc-cube__face::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: #000;
opacity: 0.5;
transition: opacity var(--rc-cube-duration) ease;
pointer-events: none;
}
.rc-cube[data-shadow] .rc-cube__face[data-active]::after {
opacity: 0;
}
/* Soft ground shadow under the cube. */
.rc-cube__shadow {
position: absolute;
left: 8%;
right: 8%;
bottom: -7%;
height: 16%;
background: rgba(0, 0, 0, 0.45);
filter: blur(22px);
border-radius: 50%;
z-index: 0;
pointer-events: none;
}
.rc-cube__caption {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
padding: 0 1rem;
color: #fff;
font-size: clamp(1.5rem, 4vw, 2.5rem);
font-weight: 800;
text-align: center;
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.55);
pointer-events: none;
}
.rc-cube .rc-dots {
position: absolute;
left: 0;
right: 0;
bottom: 0.9rem;
margin: 0;
z-index: 3;
}
.rc-cube .rc-dot {
background: rgba(255, 255, 255, 0.45);
}
.rc-cube .rc-dot--active {
background: #fff;
}
@media (prefers-reduced-motion: reduce) {
.rc-cube__face {
transition-duration: 1ms;
}
}