Forms
MoodSlider
Your mood is the weather. A cut-paper sun climbs out of the rain as you drag up, the clouds part, the hills turn green and its face comes round to a smile. Built on a native range input.
Download
- React
- Tailwind CSS v4
- motion
components/ui/mood-slider.tsx
"use client";
import { useEffect, useId, useState } from "react";
import { motion, useReducedMotion, useSpring, useTransform } from "motion/react";
type MoodSliderProps = {
/** The question, shown on the sky. */
label?: string;
/** Mood names, lowest first. */
moods?: string[];
/** Value (controlled), 1 to the number of moods. */
value?: number;
/** Value at start (uncontrolled). */
defaultValue?: number;
/** Called with the new value and its mood name. */
onChange?: (value: number, mood: string) => void;
/** Form field name. Sends the number. */
name?: string;
/** Set the width here. Everything scales with it. */
className?: string;
};
const MOODS = ["Awful", "Low", "Okay", "Good", "Great"];
const RAYS = Array.from({ length: 12 }, (_, i) => i * 30);
const RAIN = Array.from({ length: 22 }, (_, i) => [24 + i * 17 + (i % 3) * 5, 70 + ((i * 37) % 90)]);
const TRACK = [44, 376];
/* MoodSlider: your mood is the weather. A cut-paper sun climbs out of the rain as you slide up, the clouds part, the hills turn green and its face comes round to a smile. A native range input does the work, so keyboard and touch are free. */
export function MoodSlider({ label = "How is today?", moods = MOODS, value, defaultValue, onChange, name, className = "w-full max-w-lg" }: MoodSliderProps) {
const reduce = useReducedMotion();
const id = useId().replace(/:/g, "");
const [inner, setInner] = useState(defaultValue ?? Math.ceil(moods.length / 2));
const v = value ?? inner;
const n = moods.length;
const mood = moods[v - 1];
const m = useSpring((v - 1) / (n - 1), reduce ? { stiffness: 10000, damping: 500 } : { stiffness: 90, damping: 16 });
useEffect(() => m.set((v - 1) / (n - 1)), [m, v, n]);
const top = useTransform(m, [0, 0.5, 1], ["#2c3648", "#6f8fae", "#8fd3ea"]);
const low = useTransform(m, [0, 0.5, 1], ["#55637a", "#e8c9a0", "#fdeaa8"]);
const sunY = useTransform(m, [0, 1], [50, 0]);
const sunFill = useTransform(m, [0, 0.5, 1], ["#a6adb8", "#f4c35a", "#ffb21e"]);
const rayScale = useTransform(m, [0.25, 1], [0.6, 1]);
const rayOpacity = useTransform(m, [0.25, 0.6], [0, 1]);
const cloudLeft = useTransform(m, [0, 1], [0, -190]);
const cloudRight = useTransform(m, [0, 1], [0, 200]);
const cloudFill = useTransform(m, [0, 0.6, 1], ["#46526a", "#d5dbe5", "#ffffff"]);
const rainOpacity = useTransform(m, [0, 0.42], [1, 0]);
const farHill = useTransform(m, [0, 0.5, 1], ["#3a4557", "#5f8a6c", "#6dbb6a"]);
const nearHill = useTransform(m, [0, 0.5, 1], ["#2a3342", "#3f6f54", "#3f9a58"]);
const mouth = useTransform(m, (t) => `M186 ${138 - (t - 0.5) * 8} Q210 ${138 + (t - 0.5) * 56} 234 ${138 - (t - 0.5) * 8}`);
const browOuter = useTransform(m, [0, 0.5, 1], [102, 96, 90]);
const browInner = useTransform(m, [0, 0.5, 1], [90, 96, 92]);
const browL = useTransform([browOuter, browInner], ([o, i]) => `M176 ${o} L198 ${i}`);
const browR = useTransform([browOuter, browInner], ([o, i]) => `M244 ${o} L222 ${i}`);
const eye = useTransform(m, [0, 0.5, 1], [3, 7, 8]);
const blush = useTransform(m, [0.6, 1], [0, 0.7]);
const tear = useTransform(m, [0, 0.2], [1, 0]);
const thumb = useTransform(m, [0, 1], TRACK);
return (
<div className={`relative @container aspect-[420/300] overflow-hidden rounded-[3cqw] ${className}`}>
<svg aria-hidden viewBox="0 0 420 300" className="absolute inset-0 size-full select-none">
<defs>
<linearGradient id={`${id}s`} x1="0" y1="0" x2="0" y2="1">
<motion.stop offset="0" style={{ stopColor: top }} />
<motion.stop offset="0.8" style={{ stopColor: low }} />
</linearGradient>
</defs>
<rect width="420" height="300" fill={`url(#${id}s)`} />
<motion.g style={{ y: sunY }}>
<motion.g style={{ opacity: rayOpacity }}>
<motion.g style={{ scale: rayScale, originX: "210px", originY: "118px", transformBox: "view-box" }}>
<motion.g
style={{ originX: "210px", originY: "118px", transformBox: "view-box" }}
animate={reduce ? undefined : { rotate: 360 }}
transition={{ duration: 60, repeat: Infinity, ease: "linear" }}
>
{RAYS.map((a) => (
<path key={a} d="M203 42 L210 22 L217 42Z" fill="#ffc846" transform={`rotate(${a} 210 118)`} />
))}
</motion.g>
</motion.g>
</motion.g>
<motion.circle cx="210" cy="120" r="62" style={{ fill: sunFill }} />
<circle cx="210" cy="120" r="62" fill="none" stroke="#000" strokeOpacity="0.12" strokeWidth="2" />
<motion.g style={{ opacity: blush }}>
<ellipse cx="172" cy="130" rx="11" ry="7" fill="#f7775b" />
<ellipse cx="248" cy="130" rx="11" ry="7" fill="#f7775b" />
</motion.g>
<motion.g
style={{ originY: "112px", transformBox: "view-box" }}
animate={reduce ? undefined : { scaleY: [1, 1, 0.1, 1] }}
transition={{ duration: 4.6, times: [0, 0.95, 0.975, 1], repeat: Infinity }}
>
<motion.ellipse cx="187" cy="112" rx="7" fill="#2b2a33" style={{ ry: eye }} />
<motion.ellipse cx="233" cy="112" rx="7" fill="#2b2a33" style={{ ry: eye }} />
</motion.g>
<motion.path d={browL} fill="none" stroke="#2b2a33" strokeWidth="4" strokeLinecap="round" />
<motion.path d={browR} fill="none" stroke="#2b2a33" strokeWidth="4" strokeLinecap="round" />
<motion.path d={mouth} fill="none" stroke="#2b2a33" strokeWidth="5" strokeLinecap="round" />
<motion.path d="M181 122c-5 7-6 10-3 12s6 0 5-4z" fill="#8cc6ef" style={{ opacity: tear }} />
</motion.g>
<motion.g style={{ opacity: rainOpacity }}>
<motion.g
animate={reduce ? undefined : { y: [0, 26] }}
transition={{ duration: 0.55, repeat: Infinity, ease: "linear" }}
>
{RAIN.map(([x, y]) => (
<path key={x} d={`M${x} ${y}l-5 16`} stroke="#b9cbe6" strokeWidth="2" strokeLinecap="round" />
))}
</motion.g>
</motion.g>
<motion.g style={{ x: cloudLeft }}>
<path d="M-20 92a30 30 0 0 1 44-30a44 44 0 0 1 82-6a36 36 0 0 1 62 16a26 26 0 0 1 22 42H-10a22 22 0 0 1-10-22Z" fill="#000" fillOpacity="0.14" transform="translate(0 5)" />
<motion.path d="M-20 92a30 30 0 0 1 44-30a44 44 0 0 1 82-6a36 36 0 0 1 62 16a26 26 0 0 1 22 42H-10a22 22 0 0 1-10-22Z" style={{ fill: cloudFill }} />
</motion.g>
<motion.g style={{ x: cloudRight }}>
<path d="M196 66a34 34 0 0 1 62-14a40 40 0 0 1 74 0a32 32 0 0 1 58 22a22 22 0 0 1 34 34H200a24 24 0 0 1-4-42Z" fill="#000" fillOpacity="0.14" transform="translate(0 5)" />
<motion.path d="M196 66a34 34 0 0 1 62-14a40 40 0 0 1 74 0a32 32 0 0 1 58 22a22 22 0 0 1 34 34H200a24 24 0 0 1-4-42Z" style={{ fill: cloudFill }} />
</motion.g>
<path d="M0 208C70 170 130 176 196 204S340 216 420 176V300H0Z" fill="#000" fillOpacity="0.14" transform="translate(0 -4)" />
<motion.path d="M0 208C70 170 130 176 196 204S340 216 420 176V300H0Z" style={{ fill: farHill }} />
<path d="M0 246C90 212 190 232 260 240S380 226 420 214V300H0Z" fill="#000" fillOpacity="0.14" transform="translate(0 -4)" />
<motion.path d="M0 246C90 212 190 232 260 240S380 226 420 214V300H0Z" style={{ fill: nearHill }} />
<rect x="12" y="11" width={label.length * 8.6 + 16} height="26" rx="2" fill="#f6efdf" transform="rotate(-1 12 11)" />
<text x="20" y="29" fontSize="15" fontWeight="600" fill="#2b2a33" transform="rotate(-1 12 11)">
{label}
</text>
<rect x={408 - mood.length * 9.4 - 16} y="11" width={mood.length * 9.4 + 16} height="26" rx="2" fill="#f6efdf" transform="rotate(1.2 408 11)" />
<text x="400" y="29" fontSize="15" fontWeight="700" textAnchor="end" fill="#2b2a33" transform="rotate(1.2 408 11)">
{mood}
</text>
<rect x={TRACK[0] - 8} y="263" width={TRACK[1] - TRACK[0] + 16} height="12" rx="6" fill="#f6efdf" />
<rect x={TRACK[0] - 8} y="263" width={TRACK[1] - TRACK[0] + 16} height="12" rx="6" fill="none" stroke="#000" strokeOpacity="0.2" />
{moods.map((word, i) => (
<circle key={word} cx={TRACK[0] + ((TRACK[1] - TRACK[0]) * i) / (n - 1)} cy="269" r="2.5" fill="#9b8f76" />
))}
<motion.circle cy="269" r="13" fill="#ffffff" stroke="#2b2a33" strokeWidth="3" style={{ cx: thumb }} />
</svg>
<label htmlFor={`${id}r`} className="sr-only">
{label}
</label>
<input
id={`${id}r`}
type="range"
name={name}
min={1}
max={n}
step={1}
value={v}
aria-valuetext={mood}
onChange={(e) => {
const next = Number(e.target.value);
if (value === undefined) setInner(next);
onChange?.(next, moods[next - 1]);
}}
style={{ left: `${((TRACK[0] - 18) / 420) * 100}%`, width: `${((TRACK[1] - TRACK[0] + 36) / 420) * 100}%` }}
className="peer absolute bottom-[1%] h-[20%] cursor-grab appearance-none bg-transparent opacity-0 active:cursor-grabbing [&::-moz-range-thumb]:size-8 [&::-webkit-slider-thumb]:size-8 [&::-webkit-slider-thumb]:appearance-none"
/>
<span
aria-hidden
style={{ left: `${((TRACK[0] - 10) / 420) * 100}%`, width: `${((TRACK[1] - TRACK[0] + 20) / 420) * 100}%` }}
className="pointer-events-none absolute bottom-[4%] h-[12%] rounded-full opacity-0 outline-2 outline-offset-2 outline-white peer-focus-visible:opacity-100"
/>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/mood-slider.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/mood-slider-demo.tsx
import { MoodSlider } from "@/components/ui/mood-slider"; export default function MoodSliderExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[#141822] p-2"> <MoodSlider className="w-[min(130cqh,94cqw,520px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| label | string | "How is today?" | The question, shown on the sky. |
| moods | string[] | 5 moods | Mood names, lowest first. |
| value | number | - | Controlled value, 1 to the number of moods. |
| defaultValue | number | middle mood | Uncontrolled start value. |
| onChange | (value: number, mood: string) => void | - | Called with the new value and its mood name. |
| name | string | - | Form field name. Sends the number. |
| className | string | "w-full max-w-lg" | Set the width here. Everything scales with it. |