Carousel

Auto Reviews

Reviews advance on their own but freeze at any sign of interest: hover, focus inside the block, or the pause button — and with prefers-reduced-motion they never start.

  • carousel
  • reviews
  • autoplay
  • pause

Preview

1440pxHost theme

Use it with AI

  1. 1. Copy the link.
  2. 2. Write to your agent in your own words and drop the link into the sentence.
  3. 3. The agent opens the link and installs the component from the registry.

put this in the header: https://vibeui.ru/c/carousel-011?lang=en

Поставили за вечер, на следующий день собрали первый экран. Ничего не пришлось переписывать под наш проект.

Дмитрий Л.фронтенд-разработчик
1 / 3
Show the full instructions

What the link says, spelled out. Use it when your agent cannot open links — paste this text instead.

# Install and place "carousel-011" (Auto Reviews) from VibeUI

## 1. Install first — do not skip, do not recreate
Run this exact command before writing any code:
npx shadcn@latest add https://vibeui.ru/r/carousel-011.json

Registry item: https://vibeui.ru/r/carousel-011.json
Installs to: components/vibeui/carousel-011.tsx (the exact path follows this project's components.json aliases).
npm dependencies: none.
Registry dependencies: none.

Install it from the registry. Do not recreate it from the description,
do not substitute a similar component from another library, and do not
rewrite it to match the project's existing style.

## 2. What it is
Reviews advance on their own but freeze at any sign of interest: hover, focus inside the block, or the pause button — and with prefers-reduced-motion they never start.

Auto-advancing reviews with pause on hover, focus and a button, progress bars, and a full stop under prefers-reduced-motion. Zero dependencies, one file.

## 3. How to use it
import { Carousel011 } from "@/components/vibeui/carousel-011"

<Carousel011
  interval={6}
  reviews={[
    { text: "Installed in an evening, shipped the first screen the next day.", author: "Dmitry L.", role: "front-end developer" },
    { text: "The components drag in no foreign theme.", author: "Marina K.", role: "designer" },
  ]}
/>

Read the installed file for the full prop list.

## 4. Where to place it
This is a small inline component. Put it exactly where the user asked,
inside the existing markup. Do not create a new page, section or
wrapper for it. If a similar control already sits in that spot,
replace it instead of adding a second one.

Placement: ___
(The user fills this line in. If it is still blank, ask where to put it
instead of guessing.)

## 5. Keep exactly as installed
- the local --vibeui-carousel-011-* palette — do not swap it for your theme tokens
- the pause on hover and on focus: without it a long review cannot be finished
- the pause button with a changing label — hover is unavailable from a keyboard and on a phone
- dropping the timer entirely under prefers-reduced-motion: muting one animation is not enough
- the fixed min-height of the quote, otherwise the page jumps on every change
- aria-live="polite" while paused and "off" while running: otherwise the screen reader never stops

## 6. You may change
- the reviews through reviews: text, author and role
- the delay between reviews through interval, in seconds
- the accent colour through accent
- the quote height in the min-height rule of [data-part="quote"]

## 7. Rules
- An interval under four seconds is unreadable: the review cannot be finished in time.
- The bars track only the current review and are frozen by the browser while paused.
- After a manual stop the show does not resume by itself — that is deliberate.
- Do not lift the CSS variables into globals.css: the component has to stay a single file.

## 8. Verify
- it renders with no console errors;
- it looks like the preview on the VibeUI page you copied this from;
- if it does not, you changed something listed in section 5 — put it back.

For developers

npx shadcn@latest add https://vibeui.ru/r/carousel-011.json
https://vibeui.ru/r/carousel-011.json

Компонент самодостаточен: один файл, без зависимостей, палитра в локальных переменных --vibeui-carousel-011-*. Клиентский ("use client") — таймер живёт в useEffect и пересоздаётся на каждой смене отзыва. Пауза складывается из трёх источников: наведение и фокус ставят held, кнопка переключает playing. prefers-reduced-motion проверяется через matchMedia на монтировании и просто выключает показ: движение здесь действие, а не украшение, поэтому отменять его надо целиком. Высота цитаты зафиксирована min-height, чтобы страница не прыгала на смене.

Component source

The same file your agent installs. Here in case you would rather copy it by hand.

"use client"

import { useEffect, useRef, useState } from "react"
import type { ComponentPropsWithoutRef, CSSProperties } from "react"

export type Carousel011Review = {
  text: string
  author: string
  role?: string
}

export type Carousel011Props = Omit<
  ComponentPropsWithoutRef<"section">,
  "children"
> & {
  reviews?: Carousel011Review[]
  /** Секунд на один отзыв. Меньше четырёх — прочитать не успевают. */
  interval?: number
  accent?: string
}

// Идея компонента: отзывы листаются сами, но останавливаются от любого
// намёка на интерес — наведения, фокуса внутри блока и нажатия на паузу.
// Автопрокрутка без остановки не даёт дочитать длинный отзыв, а это ровно
// то, ради чего блок стоит на странице. При prefers-reduced-motion таймер
// не запускается вовсе: движение здесь не украшение, а действие, и отменять
// его надо целиком, а не гасить анимацию.
const STYLES = `
:where([data-vibeui-block="carousel-011"]){
--vibeui-carousel-011-bg:oklch(1 0 0);
--vibeui-carousel-011-fg:oklch(0.22 0.014 265);
--vibeui-carousel-011-muted:oklch(0.56 0.014 265);
--vibeui-carousel-011-border:oklch(0.91 0.006 265);
--vibeui-carousel-011-accent:oklch(0.55 0.17 265);
--vibeui-carousel-011-step:6s;
--vibeui-carousel-011-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="carousel-011"]{
display:flex;flex-direction:column;gap:0.75rem;
width:100%;max-width:28rem;box-sizing:border-box;padding:1rem;
background:var(--vibeui-carousel-011-bg);
border:1px solid var(--vibeui-carousel-011-border);border-radius:1rem;
font-family:var(--vibeui-carousel-011-font);color:var(--vibeui-carousel-011-fg);
}
[data-vibeui-block="carousel-011"] [data-part="quote"]{
margin:0;display:flex;flex-direction:column;gap:0.625rem;
/* Высота держится по самому длинному отзыву: иначе страница прыгает. */
min-height:6.5rem;
}
[data-vibeui-block="carousel-011"] [data-part="text"]{
margin:0;font-size:0.9375rem;line-height:1.5;
}
[data-vibeui-block="carousel-011"] [data-part="text"]::before{content:"«"}
[data-vibeui-block="carousel-011"] [data-part="text"]::after{content:"»"}
[data-vibeui-block="carousel-011"] figcaption{
display:flex;flex-direction:column;gap:0.0625rem;margin-top:auto;
}
[data-vibeui-block="carousel-011"] [data-part="author"]{font-size:0.8125rem;font-weight:650}
[data-vibeui-block="carousel-011"] [data-part="role"]{font-size:0.75rem;color:var(--vibeui-carousel-011-muted)}
[data-vibeui-block="carousel-011"] [data-part="foot"]{display:flex;align-items:center;gap:0.625rem}
[data-vibeui-block="carousel-011"] [data-part="pause"]{
appearance:none;cursor:pointer;flex:none;
display:inline-flex;align-items:center;justify-content:center;
width:1.75rem;height:1.75rem;padding:0;
border:1px solid var(--vibeui-carousel-011-border);border-radius:9999px;
background:var(--vibeui-carousel-011-bg);color:var(--vibeui-carousel-011-fg);
}
[data-vibeui-block="carousel-011"] [data-part="pause"]:focus-visible{outline:2px solid var(--vibeui-carousel-011-accent);outline-offset:2px}
[data-vibeui-block="carousel-011"] [data-part="pause"] svg{width:0.75rem;height:0.75rem;display:block}
[data-vibeui-block="carousel-011"] [data-part="bars"]{display:flex;gap:0.25rem;flex:1;margin:0;padding:0;list-style:none}
[data-vibeui-block="carousel-011"] [data-part="bars"] li{flex:1;height:0.1875rem;border-radius:9999px;background:var(--vibeui-carousel-011-border);overflow:hidden}
/* Полоска показывает, сколько осталось до следующего отзыва: без неё
   переключение выглядит внезапным. */
[data-vibeui-block="carousel-011"] [data-part="fill"]{
display:block;height:100%;width:100%;transform-origin:left;transform:scaleX(0);
background:var(--vibeui-carousel-011-accent);
}
[data-vibeui-block="carousel-011"] li[data-state="done"] [data-part="fill"]{transform:scaleX(1)}
[data-vibeui-block="carousel-011"] li[data-state="live"] [data-part="fill"]{
animation:vibeui-carousel-011-run var(--vibeui-carousel-011-step) linear forwards;
}
[data-vibeui-block="carousel-011"][data-paused="true"] [data-part="fill"]{animation-play-state:paused}
@keyframes vibeui-carousel-011-run{from{transform:scaleX(0)}to{transform:scaleX(1)}}
[data-vibeui-block="carousel-011"] [data-part="count"]{
font-size:0.6875rem;color:var(--vibeui-carousel-011-muted);font-variant-numeric:tabular-nums;
}
@media (prefers-reduced-motion:reduce){
[data-vibeui-block="carousel-011"] *{animation:none!important;transition:none!important}
[data-vibeui-block="carousel-011"] li[data-state="live"] [data-part="fill"]{transform:scaleX(1)}
}
`

const DEFAULT_REVIEWS: Carousel011Review[] = [
  {
    text: "Поставили за вечер, на следующий день собрали первый экран. Ничего не пришлось переписывать под наш проект.",
    author: "Дмитрий Л.",
    role: "фронтенд-разработчик",
  },
  {
    text: "Больше всего понравилось, что компоненты не тянут за собой чужую тему: цвета остались наши.",
    author: "Марина К.",
    role: "продуктовый дизайнер",
  },
  {
    text: "Команда перестала спорить про отступы: теперь это вопрос выбора варианта, а не часового обсуждения.",
    author: "Артём С.",
    role: "тимлид",
  },
]

/**
 * Отзывы с автопрокруткой, которая замирает от наведения, фокуса и паузы.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Carousel011({
  reviews = DEFAULT_REVIEWS,
  interval = 6,
  accent,
  className,
  style,
  ...props
}: Carousel011Props) {
  const [index, setIndex] = useState(0)
  const [held, setHeld] = useState(false)
  const [playing, setPlaying] = useState(true)
  const calm = useRef(false)

  useEffect(() => {
    calm.current =
      typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches
    if (calm.current) setPlaying(false)
  }, [])

  useEffect(() => {
    if (!playing || held || calm.current) return
    const timer = window.setTimeout(
      () => setIndex((value) => (value + 1) % reviews.length),
      interval * 1000,
    )
    return () => window.clearTimeout(timer)
  }, [playing, held, index, interval, reviews.length])

  const paused = held || !playing

  const palette = {
    "--vibeui-carousel-011-step": `${interval}s`,
    ...(accent ? { "--vibeui-carousel-011-accent": accent } : null),
    ...style,
  } as CSSProperties

  const review = reviews[index]

  return (
    <>
      <style href="vibeui-carousel-011" precedence="medium">
        {STYLES}
      </style>
      <section
        {...props}
        data-vibeui-block="carousel-011"
        data-paused={paused}
        aria-roledescription="карусель"
        aria-label="Отзывы"
        className={className}
        style={palette}
        onMouseEnter={() => setHeld(true)}
        onMouseLeave={() => setHeld(false)}
        onFocus={() => setHeld(true)}
        onBlur={() => setHeld(false)}
      >
        <figure data-part="quote" aria-live={paused ? "polite" : "off"}>
          <p data-part="text">{review.text}</p>
          <figcaption>
            <span data-part="author">{review.author}</span>
            {review.role ? <span data-part="role">{review.role}</span> : null}
          </figcaption>
        </figure>
        <div data-part="foot">
          <button
            type="button"
            data-part="pause"
            aria-label={playing ? "Остановить показ" : "Продолжить показ"}
            onClick={() => setPlaying((value) => !value)}
          >
            {playing ? (
              <svg viewBox="0 0 16 16" fill="currentColor">
                <rect x="4" y="3" width="3" height="10" rx="1" />
                <rect x="9" y="3" width="3" height="10" rx="1" />
              </svg>
            ) : (
              <svg viewBox="0 0 16 16" fill="currentColor">
                <path d="M5 3.5v9l8-4.5z" />
              </svg>
            )}
          </button>
          <ul data-part="bars" aria-hidden="true">
            {reviews.map((item, position) => (
              <li
                key={item.author}
                data-state={
                  position < index
                    ? "done"
                    : position === index
                      ? "live"
                      : "next"
                }
              >
                <span data-part="fill" key={`${index}-${position}`} />
              </li>
            ))}
          </ul>
          <span data-part="count">
            {index + 1} / {reviews.length}
          </span>
        </div>
      </section>
    </>
  )
}