feat: add animation

This commit is contained in:
Alexander Daichendt 2025-08-24 12:17:30 +02:00
parent 5e09ae35f0
commit d154d2e937
2 changed files with 184 additions and 33 deletions

View file

@ -12,6 +12,7 @@ interface Props {
const { images } = Astro.props;
const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
const helpId = `${carouselId}-help`;
---
<section
@ -21,21 +22,22 @@ const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
aria-roledescription="carousel"
aria-label="Image carousel"
data-carousel-id={carouselId}
tabindex="0"
aria-describedby={helpId}
>
<!-- Slides -->
<div class="relative h-0 pb-[56.25%]">
<!-- 16:9 ratio placeholder -->
{
images.map((image, i) => (
<img
src={image.src.src}
alt={image.alt}
class={`
absolute inset-0 w-full h-full object-cover object-top
transition-opacity duration-500 ease-in-out
${i === 0 ? "opacity-100" : "opacity-0"}
carousel-slide
`}
absolute inset-0 w-full h-full object-cover object-top
transition-opacity duration-500 ease-in-out
${i === 0 ? "opacity-100" : "opacity-0"}
carousel-slide
`}
data-index={i}
/>
))
@ -73,6 +75,20 @@ const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
<span class="sr-only">Next</span>
</button>
<!-- Theater toggle button (bottom-right) -->
<button
type="button"
class="absolute bottom-4 right-4 flex h-10 w-10 items-center justify-center
rounded-full bg-white/30 backdrop-blur-sm text-gray-800
hover:bg-white focus:outline-none focus-visible:ring-2
focus-visible:ring-indigo-600 transition-colors"
aria-label="Enter wide mode"
aria-pressed="false"
data-theater-toggle
>
<Icon name="mdi:arrow-expand-horizontal" class="h-5 w-5" />
</button>
<!-- Live region for screenreader announcements -->
<div
class="sr-only"
@ -81,12 +97,15 @@ const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
data-live-announcer
>
</div>
<!-- Hidden helper text for screen readers -->
<p id={helpId} class="sr-only">
Carousel focused. Use Left and Right Arrow keys to change slides. Press T to
toggle wide mode. Press Escape to exit wide mode.
</p>
</section>
<script define:vars={{ carouselId }}>
// -----------------------------------------------------------------
// Carousel logic runs once the component is in the DOM.
// -----------------------------------------------------------------
document.addEventListener("DOMContentLoaded", () => {
const carousel = document.getElementById(carouselId);
if (!carousel) return;
@ -95,14 +114,49 @@ const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
const prevBtn = carousel.querySelector('[data-dir="prev"]');
const nextBtn = carousel.querySelector('[data-dir="next"]');
const liveAnnouncer = carousel.querySelector("[data-live-announcer]");
const theaterBtn = carousel.querySelector("[data-theater-toggle]");
let current = 0;
const total = slides.length;
let autoRotateTimer;
let theater = false;
const updateTheaterButton = () => {
if (!theaterBtn) return;
theaterBtn.setAttribute("aria-pressed", theater ? "true" : "false");
theaterBtn.setAttribute(
"aria-label",
theater ? "Exit wide mode" : "Enter wide mode",
);
const icon = theaterBtn.querySelector("svg");
if (icon) {
icon.setAttribute("data-icon-state", theater ? "collapse" : "expand");
}
};
const applyTheaterState = () => {
const section = carousel.closest("[data-threecol]");
if (!section) return;
section.classList.toggle("theater-mode", theater);
updateTheaterButton();
};
const toggleTheater = () => {
theater = !theater;
applyTheaterState();
};
const exitTheater = () => {
if (!theater) return;
theater = false;
applyTheaterState();
};
theaterBtn?.addEventListener("click", (e) => {
e.preventDefault();
toggleTheater();
});
// -----------------------------------------------------------------
// Helper show a slide by index (with fade transition)
// -----------------------------------------------------------------
const showSlide = (index) => {
slides[current].classList.remove("opacity-100");
slides[current].classList.add("opacity-0");
@ -112,15 +166,11 @@ const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
slides[current].classList.remove("opacity-0");
slides[current].classList.add("opacity-100");
// Update screenreader announcement
if (liveAnnouncer) {
liveAnnouncerContent = `Slide ${current + 1} of ${total}`;
liveAnnouncer.textContent = `Slide ${current + 1} of ${total}`;
}
};
// -----------------------------------------------------------------
// Navigation button handlers
// -----------------------------------------------------------------
const goPrev = () => {
const prev = (current - 1 + total) % total;
showSlide(prev);
@ -133,24 +183,37 @@ const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
prevBtn?.addEventListener("click", goPrev);
nextBtn?.addEventListener("click", goNext);
// -----------------------------------------------------------------
// Keyboard navigation (left / right arrows)
// -----------------------------------------------------------------
// Key handling (now works when the section itself is focused)
carousel.addEventListener("keydown", (e) => {
const target = e.target;
const isTyping =
target instanceof HTMLElement &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable);
if (isTyping) return;
if (e.key === "ArrowLeft") {
e.preventDefault();
goPrev();
} else if (e.key === "ArrowRight") {
e.preventDefault();
goNext();
} else if (
(e.key === "t" || e.key === "T") &&
!e.altKey &&
!e.metaKey &&
!e.ctrlKey
) {
e.preventDefault();
toggleTheater();
} else if (e.key === "Escape") {
exitTheater();
}
});
// -----------------------------------------------------------------
// Autorotate (pause on hover / focus)
// -----------------------------------------------------------------
const startAutoRotate = () => {
stopAutoRotate(); // safety
stopAutoRotate();
autoRotateTimer = window.setInterval(goNext, 5000);
};
const stopAutoRotate = () => {
@ -160,10 +223,8 @@ const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
}
};
// Start rotating when the component mounts
startAutoRotate();
// Pause when the user hovers or focuses inside the carousel
carousel.addEventListener("mouseenter", stopAutoRotate);
carousel.addEventListener("mouseleave", startAutoRotate);
carousel.addEventListener("focusin", stopAutoRotate);

View file

@ -27,23 +27,113 @@ const maxWidthClasses = {
};
---
<section class={`${bgClass} ${py}`}>
<section class={`${bgClass} ${py}`} data-threecol>
<div
class={`${maxWidthClasses[maxWidth]} mx-auto grid grid-cols-1 lg:grid-cols-[1fr_640px_1fr] items-center md:items-start gap-8 px-4 ${containerClass}`}
class={`threecol-row ${maxWidthClasses[maxWidth]} mx-auto flex flex-col lg:flex-row gap-8 px-4 ${containerClass}`}
>
<!-- Left Column (Hidden on mobile, visible on desktop) -->
<div class={`${reverseOnMobile ? "order-3 lg:order-1" : "order-1"}`}>
<!-- Left Column -->
<div
class={`${reverseOnMobile ? "order-3 lg:order-1" : "order-1"} flex-col`}
data-left
>
<slot name="left" />
</div>
<!-- Center Column (Main content) -->
<div class={`order-1 lg:order-2`}>
<!-- Center Column -->
<div class={`order-1 lg:order-2 flex-col`} data-center>
<slot name="center" />
</div>
<!-- Right Column -->
<div class={`${reverseOnMobile ? "order-1 lg:order-3" : "order-3"}`}>
<div
class={`${reverseOnMobile ? "order-1 lg:order-3" : "order-3"} flex-col`}
data-right
>
<slot name="right" />
</div>
</div>
</section>
<style>
/* Base layout variables */
[data-threecol] .threecol-row {
--center-initial: 640px; /* initial center width at lg+ */
--transition-ease: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Make sure columns can shrink properly */
[data-threecol] [data-left],
[data-threecol] [data-center],
[data-threecol] [data-right] {
min-width: 0;
}
/* Large-screen flex behavior (mobile stays stacked naturally) */
@media (min-width: 1024px) {
[data-threecol] .threecol-row {
align-items: stretch;
}
[data-threecol] [data-left] {
max-width: 33.33%;
flex: 1 1 33.33%;
/* optional: transition flex changes if you expect subtle movement */
transition: flex 0.55s var(--transition-ease);
}
[data-threecol] [data-center] {
flex: 0 0 var(--center-initial);
transition:
flex-basis 0.55s var(--transition-ease),
flex-grow 0.55s var(--transition-ease),
max-width 0.55s var(--transition-ease);
display: flex;
flex-direction: column;
}
[data-threecol] [data-right] {
flex: 1 1 0;
transition:
flex-basis 0.55s var(--transition-ease),
flex-grow 0.55s var(--transition-ease),
opacity 0.35s ease;
display: flex;
flex-direction: column;
}
/* THEATER MODE (expanded center) */
[data-threecol].theater-mode [data-center] {
flex: 1 1 0; /* allow it to fill remaining space */
max-width: 100%;
}
[data-threecol].theater-mode [data-right] {
flex: 0 0 0;
flex-grow: 0;
opacity: 0;
pointer-events: none;
}
/* Optional: subtle emphasis on the center content while expanding */
[data-threecol] .threecol-row :is([data-center]) .carousel-theater-target,
[data-threecol] .threecol-row :is([data-center]) > * {
transition:
box-shadow 0.55s var(--transition-ease),
transform 0.55s var(--transition-ease);
}
[data-threecol].theater-mode [data-center] .carousel-theater-target,
[data-threecol].theater-mode [data-center] > * {
/* Example visual polish (comment out if not desired) */
/* box-shadow: 0 6px 24px -4px rgba(0,0,0,.25); */
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
[data-threecol] [data-left],
[data-threecol] [data-center],
[data-threecol] [data-right] {
transition: none !important;
}
}
</style>