Peek rating
- Source
- After bencho.dev ↗
- Author
- Interaction after bencho.dev
- Licence
- Original code — interaction after bencho.dev
Loading…
Hover the stars to preview a rating on the little face; click to keep it.
Code
"use client";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { Surface } from "../ui";
const LABELS = ["Awful", "Meh", "Okay", "Good", "Loved it"];
// mouth curve and eye openness for each score
const FACES = [
{ mouth: "M8 17 Q12 13 16 17", eye: 0.5, bg: "#f6d0c9" },
{ mouth: "M8 16.5 Q12 14.5 16 16.5", eye: 0.7, bg: "#f6e1c9" },
{ mouth: "M8 16 L16 16", eye: 0.85, bg: "#f1ecd2" },
{ mouth: "M8 15 Q12 18 16 15", eye: 1, bg: "#dcefd2" },
{ mouth: "M7.5 14.5 Q12 20 16.5 14.5", eye: 1, bg: "#cfe3f7" },
];
function Star({ fill }: { fill: boolean }) {
return (
<svg viewBox="0 0 24 24" className="h-full w-full">
<path
d="m12 2.8 2.7 5.6 6.1.9-4.4 4.3 1 6.1L12 16.8l-5.4 2.9 1-6.1-4.4-4.3 6.1-.9z"
fill={fill ? "#151515" : "#e4e3de"}
stroke={fill ? "#151515" : "#d6d5cf"}
strokeWidth={1}
strokeLinejoin="round"
/>
</svg>
);
}
export default function PeekRating() {
const [hover, setHover] = useState<number | null>(null);
const [score, setScore] = useState<number | null>(null);
const shown = hover ?? score;
const face = FACES[shown ?? 2];
return (
<Surface>
<div className="flex flex-col items-center gap-[1em]">
<motion.div
animate={{ backgroundColor: face.bg, y: shown === null ? 0 : -4, rotate: shown === null ? 0 : (shown - 2) * 4 }}
transition={{ type: "spring", stiffness: 300, damping: 18 }}
className="grid h-[4.6em] w-[4.6em] place-items-center rounded-full"
>
<svg viewBox="0 0 24 24" className="h-[3.4em] w-[3.4em]" fill="none" stroke="#151515" strokeWidth={1.5} strokeLinecap="round">
<motion.ellipse cx="8.5" cy="10" rx="1.1" animate={{ ry: 1.3 * face.eye }} fill="#151515" stroke="none" />
<motion.ellipse cx="15.5" cy="10" rx="1.1" animate={{ ry: 1.3 * face.eye }} fill="#151515" stroke="none" />
<motion.path animate={{ d: face.mouth }} transition={{ type: "spring", stiffness: 300, damping: 20 }} />
</svg>
</motion.div>
<div className="flex gap-[0.3em]" onPointerLeave={() => setHover(null)}>
{LABELS.map((l, i) => (
<motion.button
key={l}
type="button"
aria-label={l}
onPointerEnter={() => setHover(i)}
onClick={() => setScore(i)}
whileTap={{ scale: 0.8 }}
animate={{ scale: hover === i ? 1.2 : 1, y: hover === i ? -3 : 0 }}
transition={{ type: "spring", stiffness: 500, damping: 20 }}
className="h-[2.2em] w-[2.2em] p-[0.15em]"
>
<Star fill={shown !== null && i <= shown} />
</motion.button>
))}
</div>
<div className="relative h-[1.4em] w-[10em] overflow-hidden text-center">
<AnimatePresence mode="popLayout" initial={false}>
<motion.p
key={shown ?? -1}
initial={{ y: 12, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -12, opacity: 0 }}
className={`text-[0.85em] ${shown === null ? "font-mono text-[0.75em] text-[#8a8984]" : "font-medium"}`}
>
{shown === null ? "how was it?" : LABELS[shown]}
{score !== null && hover === null ? " · saved" : ""}
</motion.p>
</AnimatePresence>
</div>
</div>
</Surface>
);
}
Prompt
Build "Peek rating", a input animation. What it does: Hover the stars to preview a rating on the little face; click to keep it. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS, using motion. Technique: an HTML canvas, the Motion library, inline SVG, pointer tracking. It sits on a light background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://bencho.dev (Interaction after bencho.dev, Original code — interaction after bencho.dev).