Skip to content
Corsair UI
Browse the docs

MotionNo. 109 of 145

Wave Rating

The rating, animated: sweeping a finger, pen or mouse across the stars lifts them in a wave that crests under the pointer, a tip shows the score, and letting go pops the chosen star. Same props and radio group as rating.

A variation of Rating with more motion and the same props: swap one for the other by changing the import.

Installation

pnpm dlx shadcn@latest add @corsair-ui/wave-rating

No setup needed: the CLI finds @corsair-ui in the shadcn registry directory. The files land in your project as source: edit them like your own code. More in Installation.

Also adds
utilsratinguse-media-query
npm
@radix-ui/react-radio-groupclass-variance-authorityclsxlucide-reacttailwind-merge

Examples

Sweep to rate

The stars rise in a wave under the pointer and the tip names the score.

Not rated yet. Sweep a finger or the pointer across the stars; arrow keys work too.

Same props as Rating

Seven stars, a small size without the tip, and the read-only image, which stays still.

API

PropTypeDefault
Every Rating prop

value, defaultValue, onValueChange, max, size, readOnly, disabled, name, getValueLabel and starLabel work as on Rating.

RatingProps—
showTip

A label over the star under the pointer while it sweeps or hovers.

booleantrue
tipLabel

Text of the tip: words like "Smooth sailing" instead of "4 stars".

(stars, max) => stringstarLabel
haptics

A short vibration per star under a finger, where the browser supports it (Chrome on Android).

booleantrue

Built on Radix Radio Group; every prop it takes is passed through.

Styling

Restyle it in your project: className on each part, the theme variables, and these attributes with arbitrary variants, e.g. className="[&_[data-slot=wave-rating-item]]:text-primary".

  • Each star rises inside data-slot="wave-rating-lift", which carries --wave-rating-lift from 0 to 1 as it does: use it for a glow or a colour of your own.
  • The root has data-previewing while a pointer sweeps or hovers, and every radio up to the pointer has data-previewed.
  • The tip is data-slot="wave-rating-tip", in bg-foreground and text-background.
data-slot
  • wave-rating
  • wave-rating-item
  • wave-rating-lift
  • wave-rating-tip
State
  • data-previewedcomputed
  • data-previewingcomputed
  • data-sizecomputed
  • data-valuecomputed

Radix Radio Group also sets its own attributes on the parts, such as data-state and data-disabled.

Accessibility

  • The same radio group as Rating: arrow keys pick a score, each star has a name, and the score submits with a form.
  • The tip is decorative and hidden from screen readers; the radios already say the score.
  • A sweep commits where the finger lets go. A vertical swipe still scrolls the page, and a gesture the browser takes over changes nothing.
  • With prefers-reduced-motion the stars light up without rising, popping or travelling.

Compatibility

Tailwind CSS
3.4 and 4, both checked in CI
React
19
Rendering
Client component ("use client")
Motion
Respects prefers-reduced-motion

Source

1 file, installed as source you own
wave-rating.tsxView on GitHub
"use client";

import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { useRef, useState, type CSSProperties, type MouseEvent, type PointerEvent } from "react";

import { useMediaQuery } from "@/registry/default/hooks/use-media-query";
import { cn } from "@/registry/default/lib/utils";
import { Rating, RatingStar, ratingVariants, type RatingProps } from "@/registry/default/ui/rating";

interface WaveRatingProps extends RatingProps {
  /** Shows a label over the star under the pointer while it sweeps or hovers. */
  showTip?: boolean;
  /** Text of that label. Defaults to `starLabel` ("3 stars"). */
  tipLabel?: (stars: number, max: number) => string;
  /** A short vibration as each star lights up under a finger, where the browser supports it. */
  haptics?: boolean;
}

/** How many stars behind the crest the wave reaches. */
const SPREAD = 3;
/** Delay between neighbouring stars as the wave travels, in ms. */
const STAGGER = 30;
/** Overshoots a little before settling, like a spring. */
const SPRING = "cubic-bezier(0.34, 1.56, 0.64, 1)";
const POP: Keyframe[] = [
  { transform: "scale(1)" },
  { transform: "scale(1.35)" },
  { transform: "scale(1)" },
];
const HOP: Keyframe[] = [
  { transform: "translateY(0)" },
  { transform: "translateY(-4px)" },
  { transform: "translateY(0)" },
];

const defaultStarLabel = (stars: number) => `${stars} ${stars === 1 ? "star" : "stars"}`;

function play(element: Element | null | undefined, keyframes: Keyframe[], duration: number) {
  if (element && typeof element.animate === "function") {
    element.animate(keyframes, { duration, easing: SPRING });
  }
}

/** 0 to 1: how high a star rises, highest under the pointer and fading out behind it. */
function liftOf(star: number, crest: number | null) {
  if (crest === null || star > crest) return 0;
  return Math.max(0, 1 - (crest - star) / SPREAD);
}

/**
 * The rating, animated: sweep a finger, pen or mouse across the stars and they
 * rise in a wave that crests under it, while a tip above shows the score; let
 * go (or click) and the chosen star pops. It takes the same props as `rating`
 * and keeps everything else about it: a radio group for the keyboard and
 * screen readers, form submission under `name`, and the read-only image (which
 * does not move). The tip is decorative, since each radio already says its
 * score. Vertical swipes still scroll the page. With `prefers-reduced-motion`
 * the stars light up without rising, popping or travelling.
 *
 * Each rising star exposes its height as `--wave-rating-lift` (0 to 1) on
 * `data-slot="wave-rating-lift"`, for a glow or a colour of your own.
 *
 * @example
 * <WaveRating
 *   defaultValue={4}
 *   onValueChange={setScore}
 *   aria-label="Rate the court"
 *   tipLabel={(stars) => ["Bad", "Meh", "OK", "Good", "Great"][stars - 1] ?? ""}
 * />
 */
function WaveRating({ showTip, tipLabel, haptics, ...props }: WaveRatingProps) {
  if (props.readOnly) return <Rating {...props} />;
  return (
    <InteractiveWaveRating showTip={showTip} tipLabel={tipLabel} haptics={haptics} {...props} />
  );
}

function InteractiveWaveRating({
  value: valueProp,
  defaultValue = 0,
  onValueChange,
  max = 5,
  disabled,
  required,
  size = "default",
  name,
  dir,
  "aria-label": label = "Rating",
  starLabel = defaultStarLabel,
  showTip = true,
  tipLabel,
  haptics = true,
  getValueLabel: _getValueLabel,
  readOnly: _readOnly,
  className,
  onPointerDown,
  onPointerMove,
  onPointerUp,
  onPointerCancel,
  onPointerLeave,
  onClickCapture,
  ...props
}: WaveRatingProps) {
  const reduced = useMediaQuery("(prefers-reduced-motion: reduce)");
  const [internal, setInternal] = useState(defaultValue);
  const [preview, setPreview] = useState<number | null>(null);
  const [tip, setTip] = useState<{ star: number; x: number; moving: boolean } | null>(null);
  const count = Math.max(1, Math.floor(max));
  const value = Math.min(Math.max(valueProp ?? internal, 0), count);
  const picked = Math.round(value);
  const shown = preview ?? picked;
  const stars = Array.from({ length: count }, (_, index) => index + 1);

  const items = useRef<(HTMLButtonElement | null)[]>([]);
  const pops = useRef<(HTMLSpanElement | null)[]>([]);
  const bubble = useRef<HTMLSpanElement>(null);
  const crest = useRef<number | null>(null);
  const sweep = useRef<number | null>(null);
  const swallowClick = useRef(false);

  /** The star nearest to the pointer, so sweeping past either end keeps the first or last. */
  const starAt = (clientX: number) => {
    let nearest: number | null = null;
    let distance = Infinity;
    items.current.forEach((item, index) => {
      if (!item) return;
      const { left, right } = item.getBoundingClientRect();
      const away = clientX < left ? left - clientX : clientX > right ? clientX - right : 0;
      if (away < distance) {
        distance = away;
        nearest = index + 1;
      }
    });
    return nearest;
  };

  const show = (star: number, pointerType: string) => {
    if (crest.current === star) return;
    const moving = crest.current !== null;
    crest.current = star;
    setPreview(star);
    const item = items.current[star - 1];
    if (item) setTip({ star, x: item.offsetLeft + item.offsetWidth / 2, moving });
    if (moving && !reduced) play(bubble.current, HOP, 200);
    if (haptics && pointerType === "touch" && typeof navigator.vibrate === "function") {
      navigator.vibrate(8);
    }
  };

  const hide = () => {
    crest.current = null;
    setPreview(null);
  };

  const commit = (star: number) => {
    if (valueProp === undefined) setInternal(star);
    if (star !== picked) onValueChange?.(star);
    if (!reduced) play(pops.current[star - 1], POP, 360);
  };

  const begin = (event: PointerEvent<HTMLDivElement>) => {
    onPointerDown?.(event);
    if (event.defaultPrevented || disabled || event.button !== 0) return;
    // The star pressed is the one under the pointer; assistive tech may not send coordinates.
    const pressed = items.current.findIndex(
      (item) => item !== null && item.contains(event.target as Node)
    );
    const star = pressed >= 0 ? pressed + 1 : starAt(event.clientX);
    if (star === null) return;
    sweep.current = event.pointerId;
    event.currentTarget.setPointerCapture(event.pointerId);
    show(star, event.pointerType);
  };

  const move = (event: PointerEvent<HTMLDivElement>) => {
    onPointerMove?.(event);
    if (disabled) return;
    if (sweep.current === null ? event.pointerType === "touch" : sweep.current !== event.pointerId)
      return;
    const star = starAt(event.clientX);
    if (star !== null) show(star, event.pointerType);
  };

  const end = (event: PointerEvent<HTMLDivElement>) => {
    onPointerUp?.(event);
    if (sweep.current !== event.pointerId) return;
    sweep.current = null;
    const star = crest.current;
    // A mouse is still over the stars, so its preview stays until it leaves.
    if (event.pointerType !== "mouse") hide();
    if (star === null) return;
    // The click that follows the release would pick the radio under the pointer again.
    swallowClick.current = true;
    setTimeout(() => {
      swallowClick.current = false;
    });
    commit(star);
    if (event.currentTarget.contains(document.activeElement)) items.current[star - 1]?.focus();
  };

  const cancel = (event: PointerEvent<HTMLDivElement>) => {
    onPointerCancel?.(event);
    if (sweep.current !== event.pointerId) return;
    sweep.current = null;
    hide();
  };

  const leave = (event: PointerEvent<HTMLDivElement>) => {
    onPointerLeave?.(event);
    if (sweep.current === null) hide();
  };

  const swallow = (event: MouseEvent<HTMLDivElement>) => {
    onClickCapture?.(event);
    if (!swallowClick.current) return;
    swallowClick.current = false;
    event.preventDefault();
    event.stopPropagation();
  };

  return (
    <RadioGroupPrimitive.Root
      data-slot="wave-rating"
      data-size={size}
      data-value={picked}
      data-previewing={preview !== null ? "" : undefined}
      aria-label={label}
      value={picked > 0 ? String(picked) : ""}
      onValueChange={(next) => commit(Number(next))}
      orientation="horizontal"
      loop={false}
      disabled={disabled}
      required={required}
      name={name}
      dir={dir}
      className={cn(ratingVariants({ size }), "relative touch-pan-y select-none", className)}
      onPointerDown={begin}
      onPointerMove={move}
      onPointerUp={end}
      onPointerCancel={cancel}
      onPointerLeave={leave}
      onClickCapture={swallow}
      {...props}
    >
      {stars.map((star) => {
        const lift = reduced ? 0 : liftOf(star, preview);
        return (
          <RadioGroupPrimitive.Item
            key={star}
            ref={(node) => {
              items.current[star - 1] = node;
            }}
            value={String(star)}
            aria-label={starLabel(star)}
            data-slot="wave-rating-item"
            data-previewed={preview !== null && star <= preview ? "" : undefined}
            className={cn(
              "inline-flex cursor-pointer rounded-[4px] outline-none",
              "focus-visible:ring-ring/50 focus-visible:ring-[3px]",
              "disabled:cursor-not-allowed disabled:opacity-50"
            )}
          >
            <span
              data-slot="wave-rating-lift"
              className="inline-flex transition-transform duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] motion-reduce:transition-none"
              style={
                {
                  "--wave-rating-lift": lift,
                  transform:
                    lift > 0 ? `translateY(${-lift * 35}%) scale(${1 + lift * 0.2})` : undefined,
                  // The wave travels: stars further behind the crest move a moment later.
                  transitionDelay:
                    lift > 0 && preview !== null ? `${(preview - star) * STAGGER}ms` : "0ms",
                } as CSSProperties
              }
            >
              <span
                ref={(node) => {
                  pops.current[star - 1] = node;
                }}
                className="inline-flex"
              >
                <RatingStar fill={star <= shown ? 1 : 0} />
              </span>
            </span>
          </RadioGroupPrimitive.Item>
        );
      })}
      {showTip && tip ? (
        <span
          aria-hidden="true"
          data-slot="wave-rating-tip"
          className={cn(
            "pointer-events-none absolute bottom-full left-0 mb-2 ease-out motion-reduce:transition-none",
            tip.moving
              ? "transition-[transform,opacity] duration-200"
              : "transition-opacity duration-200",
            preview === null ? "opacity-0" : "opacity-100"
          )}
          style={{ transform: `translateX(${tip.x}px) translateX(-50%)` }}
        >
          <span
            ref={bubble}
            className="bg-foreground text-background block rounded-md px-2 py-1 text-xs font-medium whitespace-nowrap shadow-md"
          >
            {(tipLabel ?? starLabel)(tip.star, count)}
          </span>
        </span>
      ) : null}
    </RadioGroupPrimitive.Root>
  );
}

export { WaveRating, type WaveRatingProps };