R
Rishtaara
Next.js: Zero to Full-Stack
Lesson 37 of 42Article20 min

Practice: Carousel, Video, Forms, Skeleton, Popup, Spinner

Build a small client carousel with local state. Keep controls keyboard-friendly. Prefer next/image for slides.

Image carousel

Build a small client carousel with local state. Keep controls keyboard-friendly. Prefer next/image for slides.

Real-life example: A carousel is a photo album you flip — one picture visible, next/prev buttons on the sides.

Simple carousel
"use client";

import { useState } from "react";
import Image from "next/image";

const slides = [
  { src: "/slides/1.jpg", alt: "App Router diagram" },
  { src: "/slides/2.jpg", alt: "Auth flow" },
  { src: "/slides/3.jpg", alt: "Deploy checklist" },
];

export function LessonCarousel() {
  const [i, setI] = useState(0);
  const slide = slides[i];

  return (
    <div>
      <Image src={slide.src} alt={slide.alt} width={800} height={450} />
      <div>
        <button
          type="button"
          onClick={() => setI((n) => (n === 0 ? slides.length - 1 : n - 1))}
        >
          Prev
        </button>
        <span>
          {i + 1} / {slides.length}
        </span>
        <button
          type="button"
          onClick={() => setI((n) => (n + 1) % slides.length)}
        >
          Next
        </button>
      </div>
    </div>
  );
}

Video player & form validation

Use the HTML5 video element for self-hosted files. For YouTube, prefer a privacy-friendly embed (next lesson).

Validate forms on the client for UX and again on the server for safety.

Real-life example: Client validation is spell-check before you send an email; server validation is the post office rejecting a blank address.

Video element
export function LessonVideo() {
  return (
    <video controls preload="metadata" poster="/video/poster.jpg">
      <source src="/video/intro.mp4" type="video/mp4" />
      Your browser does not support video.
    </video>
  );
}
Form with basic validation
"use client";

import { useState } from "react";

export function ContactForm() {
  const [email, setEmail] = useState("");
  const [error, setError] = useState("");

  function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!email.includes("@")) {
      setError("Enter a valid email.");
      return;
    }
    setError("");
    // call Server Action or /api/contact
  }

  return (
    <form onSubmit={onSubmit}>
      <label>
        Email
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
        />
      </label>
      {error && <p role="alert">{error}</p>}
      <button type="submit">Send</button>
    </form>
  );
}

Skeleton, popup, spinner, slider, date picker

Skeletons reduce layout jump while data loads. Modals need focus trap basics and Escape to close. Spinners need aria-busy or aria-live for accessibility.

A native range input is enough for many sliders. Prefer <input type="date"> before pulling a heavy date library.

Real-life example: A skeleton screen is the chalk outline of furniture while the real sofa is still on the delivery truck.

Tip: Reach for native HTML controls first. Add a UI library only when you need complex a11y patterns you cannot maintain yourself.
Skeleton + spinner
export function CourseSkeleton() {
  return (
    <div aria-hidden className="animate-pulse">
      <div className="h-6 w-2/3 rounded bg-neutral-200" />
      <div className="mt-3 h-4 w-full rounded bg-neutral-200" />
      <div className="mt-2 h-4 w-5/6 rounded bg-neutral-200" />
    </div>
  );
}

export function Spinner() {
  return (
    <p role="status" aria-live="polite">
      Loading…
    </p>
  );
}
Minimal modal popup
"use client";

import { useEffect, useState } from "react";

export function ConfirmModal() {
  const [open, setOpen] = useState(false);

  useEffect(() => {
    if (!open) return;
    function onKey(e: KeyboardEvent) {
      if (e.key === "Escape") setOpen(false);
    }
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open]);

  return (
    <>
      <button type="button" onClick={() => setOpen(true)}>
        Delete lesson
      </button>
      {open && (
        <div
          role="dialog"
          aria-modal="true"
          aria-labelledby="confirm-title"
        >
          <h2 id="confirm-title">Delete this lesson?</h2>
          <button type="button" onClick={() => setOpen(false)}>
            Cancel
          </button>
          <button type="button">Confirm</button>
        </div>
      )}
    </>
  );
}
Slider + date input
"use client";

import { useState } from "react";

export function PaceControls() {
  const [speed, setSpeed] = useState(1);
  const [day, setDay] = useState("");

  return (
    <div>
      <label>
        Playback speed: {speed.toFixed(1)}x
        <input
          type="range"
          min={0.5}
          max={2}
          step={0.1}
          value={speed}
          onChange={(e) => setSpeed(Number(e.target.value))}
        />
      </label>
      <label>
        Study date
        <input
          type="date"
          value={day}
          onChange={(e) => setDay(e.target.value)}
        />
      </label>
    </div>
  );
}