Skip to main content

Content & marketing

Parallax Scroll Sequence code

Copy the implementation or inspect every file that belongs to this component unit.

resources/views/components/ui/parallax-scroll-sequence.blade.php

@props([
    'images' => [],
    'frames' => null,
    'eyebrow' => null,
    'title' => null,
    'description' => null,
])

@php
    /*
    | Scroll-sequence parallax. It swaps between a small set of image frames and
    | moves a progress rail as the section passes through the viewport.
    */
    $defaultFrames = [
        ['label' => 'Wide establish', 'copy' => 'The first plate anchors the scene and gives the scroll a clear starting point.', 'meta' => 'Establish'],
        ['label' => 'Move through', 'copy' => 'The middle frame carries the eye forward while the background keeps drifting.', 'meta' => 'Advance'],
        ['label' => 'Final hold', 'copy' => 'The last frame lands the sequence with a stronger composed moment.', 'meta' => 'Hold'],
    ];

    $sequenceFrames = collect($frames ?? $images)
        ->map(function (array|string $frame, int $index) use ($defaultFrames): array {
            $fallback = $defaultFrames[$index] ?? [
                'label' => 'Frame '.str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT),
                'copy' => 'A sequenced parallax frame with its own timing and active state.',
                'meta' => 'Frame',
            ];

            if (is_string($frame)) {
                return [
                    'src' => $frame,
                    'label' => $fallback['label'],
                    'copy' => $fallback['copy'],
                    'meta' => $fallback['meta'],
                ];
            }

            return [
                'src' => $frame['src'] ?? $frame['image'] ?? '',
                'label' => $frame['label'] ?? $fallback['label'],
                'copy' => $frame['copy'] ?? $frame['description'] ?? $fallback['copy'],
                'meta' => $frame['meta'] ?? $fallback['meta'],
            ];
        })
        ->filter(fn (array $frame): bool => $frame['src'] !== '')
        ->values()
        ->all();
@endphp

<section
    x-data="{
        frameData: @js($sequenceFrames),
        progress: 0,
        frame: 0,
        reduced: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
        currentFrame() {
            return this.frameData[this.frame] ?? { label: 'Frame', copy: '', meta: 'Frame' };
        },
        update() {
            const rect = $el.getBoundingClientRect();
            const travel = rect.height + window.innerHeight;
            const raw = (window.innerHeight - rect.top) / travel;
            this.progress = Math.min(1, Math.max(0, raw));

            if (this.frameData.length === 0) {
                this.frame = 0;

                return;
            }

            this.frame = this.reduced ? 0 : Math.min(this.frameData.length - 1, Math.floor(this.progress * this.frameData.length));
        },
    }"
    x-init="update()"
    @scroll.window.passive="update()"
    {{ $attributes->merge(['class' => 'relative isolate min-h-[170vh] overflow-hidden bg-gray-950']) }}
>
    <div class="sticky top-0 flex min-h-screen items-center overflow-hidden">
        @foreach ($sequenceFrames as $index => $sequenceFrame)
            <img
                src="{{ $sequenceFrame['src'] }}"
                alt=""
                aria-hidden="true"
                class="pointer-events-none absolute inset-0 -z-20 h-full w-full scale-[1.1] object-cover transition-opacity duration-700 will-change-transform motion-reduce:scale-100 motion-reduce:transform-none"
                :class="frame === {{ $index }} ? 'opacity-100' : 'opacity-0'"
                style="transform: translate3d(0, 0, 0) scale(1.1);"
                :style="reduced ? 'transform: translate3d(0, 0, 0) scale(1);' : `transform: translate3d(${(progress - 0.5) * -26}px, ${(progress - 0.5) * -64}px, 0) scale(${1.1 + (progress * 0.04)});`"
            >
        @endforeach

        <div class="absolute inset-0 -z-10 bg-gradient-to-r from-gray-950/90 via-gray-950/28 to-gray-950/82" aria-hidden="true"></div>
        <div class="absolute inset-x-0 top-0 -z-10 h-36 bg-gradient-to-b from-gray-950/80 to-transparent" aria-hidden="true"></div>
        <div class="absolute inset-x-0 bottom-0 -z-10 h-44 bg-gradient-to-t from-gray-950 to-transparent" aria-hidden="true"></div>

        <div class="mx-auto grid w-full max-w-7xl items-center gap-10 px-6 py-16 sm:px-8 lg:grid-cols-[0.76fr_1.24fr] lg:px-10">
            <div>
                @if ($eyebrow)
                    <x-ui.badge variant="brand" icon="film-strip" class="bg-white/12 text-white ring-1 ring-white/20 backdrop-blur dark:bg-white/12 dark:text-white">
                        {{ $eyebrow }}
                    </x-ui.badge>
                @endif

                @if ($title)
                    <h2 class="mt-5 text-4xl font-semibold tracking-tight text-white text-balance sm:text-6xl">{{ $title }}</h2>
                @endif

                @if ($description)
                    <p class="mt-5 max-w-2xl text-base leading-7 text-white/78 text-pretty sm:text-lg">{{ $description }}</p>
                @endif

                @isset($actions)
                    <div class="mt-8 flex flex-col gap-3 sm:flex-row">{{ $actions }}</div>
                @endisset
            </div>

            <div class="relative overflow-hidden rounded-3xl border border-white/15 bg-white/10 p-4 shadow-2xl shadow-gray-950/50 backdrop-blur-md">
                <div class="relative aspect-[16/9] overflow-hidden rounded-2xl border border-white/10 bg-gray-950/70">
                    @foreach ($sequenceFrames as $index => $sequenceFrame)
                        <img
                            src="{{ $sequenceFrame['src'] }}"
                            alt=""
                            aria-hidden="true"
                            class="absolute inset-0 h-full w-full object-cover transition-opacity duration-700"
                            :class="frame === {{ $index }} ? 'opacity-100' : 'opacity-0'"
                        >
                    @endforeach

                    <div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-gray-950/92 via-gray-950/20 to-transparent p-4">
                        <div class="flex items-end justify-between gap-4">
                            <div>
                                <p class="text-xs font-medium text-brand-200">Active frame</p>
                                <p class="mt-1 text-lg font-semibold text-white" x-text="currentFrame().label"></p>
                            </div>
                            <p class="rounded-full border border-white/15 bg-white/10 px-3 py-1 text-sm font-medium text-white" x-text="String(frame + 1).padStart(2, '0') + ' / ' + String(frameData.length).padStart(2, '0')"></p>
                        </div>
                    </div>
                </div>

                <div class="mt-5 flex items-center justify-between gap-4 text-sm text-white">
                    <span x-text="currentFrame().meta"></span>
                    <span x-text="Math.round(progress * 100) + '%'"></span>
                </div>
                <div class="mt-3 h-2 overflow-hidden rounded-full bg-white/15">
                    <div class="h-full rounded-full bg-brand-300 transition-[width] duration-200" :style="`width: ${Math.round(progress * 100)}%;`"></div>
                </div>

                <p class="mt-4 text-sm leading-6 text-white/70" x-text="currentFrame().copy"></p>

                <div class="mt-5 grid gap-3">
                    @foreach ($sequenceFrames as $index => $sequenceFrame)
                        <div
                            class="grid grid-cols-[4.5rem_1fr] gap-3 rounded-2xl border p-2 transition"
                            :class="frame === {{ $index }} ? 'border-brand-300/60 bg-brand-300/12' : 'border-white/10 bg-white/5'"
                        >
                            <img src="{{ $sequenceFrame['src'] }}" alt="" aria-hidden="true" class="h-16 w-full rounded-xl object-cover">
                            <div>
                                <p class="text-sm font-semibold text-white">{{ $sequenceFrame['label'] }}</p>
                                <p class="mt-1 text-sm leading-5 text-white/62">{{ $sequenceFrame['copy'] }}</p>
                            </div>
                        </div>
                    @endforeach
                </div>
            </div>
        </div>
    </div>
</section>