Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Mood slider

New

A check-in card whose slider fills red or green from the middle while faces frown and smile above it, with soft synthesised ticks.

mood-slider/mood-slider.tsx
Live · interactive

How are you feeling mentally today?

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • A short check-in of one to five questions, asked one at a time: “How are you feeling today?”.
  • Answers that are a feeling on a scale from bad to great, where a face says it faster than a number.
  • A moment worth a little delight: the ends answer with a ripple, sparkles and a sound, so it suits screens seen once a day, not once a minute.
  • Mobile-first surveys: one big thumb, one button, and the card scales as one drawing to any width.

When not to use

  • Exact numbers like a price, a volume or a 1–10 rating someone must read back. Use a plain slider with its value shown.
  • Questions with named, unordered answers. Use radio buttons or a segmented control.
  • Long forms or surveys of many questions: one card at a time and the animations slow them down. Use a form with plain inputs.
  • Clinical or legal assessments with a validated scale: the faces and the colour fill lead the answer.
  • Several questions that must be seen and compared together: the card only shows the current one.

You can add this component with the shadcn CLI or by hand:

Into components/mood-slider of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/mood-slider.json

Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

daily-check-in.tsx
import { MoodSlider } from './mood-slider';export function DailyCheckIn({ save }: { save: (answers: number[]) => void }) {  return (    <MoodSlider      questions={[        { title: 'How are you feeling mentally today?' },        { title: 'How are you feeling about work today?', labels: ['Stuck', 'Fine', 'Flowing'] },      ]}      onComplete={(answers) => save(answers)}      autoplay={false}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

A dark card that asks one question at a time, “How are you feeling mentally today?”, and takes the answer on a slider that starts in the middle. Drag left and the track fills red to yellow, the left face grows and knits its brows; drag right and it fills yellow to green as the happy face beams. Each step ticks; at the left end a red ripple beats around the face with a soft thud, at the right a green ring and sparkles fly out to two chimes. VOTE blurs the question’s changing words away and brings the next one in.

  • The thumb rides a damped spring (stiffness 560, damping 26, about 15% of bounce) stepped every 4 ms, so a fast drag into an end lands with a small bounce past it. Once per frame the spring writes a single custom property, --p, from 0 to 1, and stops when it settles.
  • The thumb’s transform, the fill’s box and the faces’ sizes are calc()s of --p in the CSS. The fill runs from the middle of the track to the thumb and wraps it 3.75 units around: one box with two gradients, red to yellow and yellow to green, whose opacities cross over in the 2.5% around the middle.
  • Each face sits where the thumb’s middle stops (the dots at the ends, and the middle) and scales from 0.66 of its size to full as the thumb comes within half the track of it.
  • The faces are SVG drawings whose parts are rewritten from --p’s number: the left face’s mouth turns down and its brows drop in, the middle one looks towards the thumb, the right one’s smile widens.
  • At the left end the angry face comes alive: it glances one way and back and blinks every 3.6 s, on its own frame loop that only runs while it can be seen.
  • Reaching the left end sends a red ripple out of the face (1 to 1.42 times in 640 ms), again every 470 ms while a finger holds it there. Reaching the right end flashes a green ring out to 1.62 times in 900 ms and flings three four-pointed sparkles off it, with a fourth a moment later.
  • The sound is synthesised with the Web Audio API, no files: a tick each time the value crosses a step of 5, a sine that drops in pitch over a breath of filtered noise, higher and louder towards the right. Reaching the left end adds a soft thud that falls (260 to 143 Hz in 160 ms); the right end, two chimes a fifth apart, 75 ms after one another. The vote pops with a short rising blip. One AudioContext serves the page: it is made on the reader’s first press or key on the card, suspended 1.5 s after the last sound and closed with the last card; the demo never sounds.
  • VOTE keeps the words the next question shares (“How are you feeling”): the rest squash up into a blur in 180 ms, the counter’s digit rolls up, and the faces shrink away 35 ms apart while the thumb glides back to the middle on a critically damped spring. The next words drop in from a blur and a clip in 320 ms; the new faces pop in with a slight overshoot, left first.
  • The question’s white-to-grey sweep is one gradient across the whole title, shared by its words: each word clips its own part of it, so the words can still move one by one.
  • While on screen and untouched, the demo plays the questions with a touch mark for a finger: left, then all the way right, then VOTE, without a sound. Any key or press hands it to the reader where it was.
  • Every colour is a light-dark() token on the root, so the card follows the page’s color-scheme or the colorScheme prop. By day the card is white on pale grey; the fills and faces are the same on both.
PropTypeDefaultDescription
questions{ title: string; labels?: [string, string, string] }[]two mood questionsThe questions, asked in turn, and what the slider’s left, middle and right mean to screen readers (Bad, Okay, Great by default). After the last vote it starts again at the first; with one question the counter is hidden.
valuenumber—The current question’s answer, 0 to 100, for a controlled slider.
defaultValuenumber50Where the slider starts for each question.
defaultQuestionnumber—The index of the question the card opens on. Setting it turns the demo off.
onChange(value: number, index: number) => void—Called with the new answer and the question’s index as the reader moves the slider, and with the starting value and the next index after a vote.
onVote(value: number, index: number) => void—Called when the reader votes, with the answer and the question’s index.
onComplete(values: number[]) => void—Called after the last question’s vote with every answer.
voteLabelstring'Vote'The button’s words, shown in capitals.
soundbooleantrueSoft synthesised ticks as the value moves, a soft thud at the left end, two chimes at the right one and a pop on VOTE, once the reader has pressed or keyed the card. The demo never makes a sound.
autoplaybooleantrueWhile on screen and untouched, the card plays both questions by itself, with a touch mark for a finger.
colorScheme'light' | 'dark' | 'system'—Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
classNamestring—Extra class for the root.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

mood-slider.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useEffectEvent,  useId,  useLayoutEffect,  useRef,  useState,  type CSSProperties,  type KeyboardEvent as ReactKeyboardEvent,  type PointerEvent as ReactPointerEvent,} from 'react';import { clickSound, type ClickSound } from './click-sound';import { Icon, morphIcon, SMALL, type Life, type Slot } from './icons';import { useAutoplay } from './use-autoplay';import styles from './mood-slider.module.css';/* * Mood slider: a check-in card that asks one question at a time ("How are you feeling mentally * today?") and takes the answer on a slider that starts in the middle. Three faces sit above it, * at the places the thumb reaches at its ends and in its middle; VOTE takes the next question.
mood-slider.module.css
/* Mood slider (mood-slider.tsx): a check-in card on a stage. At night the stage is near-black and   the card a dark grey; by day the card is white on a pale grey.   Every size is in --u, a unit of the card, which is 382 units wide. The stage sets the unit from   its own width (a container query), so the whole card scales as one drawing.   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes   the color-scheme it inherits from the page (light unless the page says otherwise), or the one   its colorScheme prop sets (data-color-scheme). The fills and the faces are the same on both.   Memory. The card is one layout boundary. The thumb moves by a transform; the fill's box and the   icons' sizes are calc()s of one custom property, --p, that the spring writes once per frame. *//* A length that stays the card's unit wherever it is read, even in other type sizes. */@property --u {  syntax: '<length>';  inherits: true;  initial-value: 1px;}.root {  /* The stage, the card and its lift. */  --stage: #efefef;  --card: #fff;
icons.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License./* * The three faces above the slider: bad, okay and great. Each is drawn once in SVG at its full * size; morphIcon() then reshapes its parts from the slider's place, once per frame while the thumb * moves: the left face frowns and knits its brows, the right one smiles wider. */export type Slot = 0 | 1 | 2;/** How large a face is when the thumb is far from it, against its size when the thumb is on it. */export const SMALL = 0.66;const clamp = (value: number) => Math.min(1, Math.max(0, value));const mix = (from: number, to: number, t: number) => from + (to - from) * t;const ease = (t: number) => t * t * (3 - 2 * t);const n = (value: number) => Math.round(value * 100) / 100;/* Faces. A 100-unit drawing: the disc, two eyes, a mouth and, on the left face, two brows. */const FACE = [  // Bad: red, a slight frown that turns angry.  { top: '#ff9152', bottom: '#ea4219', ink: '#b02e0d' },  // Okay: orange, a small smile.
click-sound.ts
/* * The slider's sounds, synthesised with the Web Audio API, no audio file. A tick is a sine blip * that drops in pitch, over a breath of filtered noise for its edge, shaped by an envelope a few * milliseconds long; higher values tick higher and louder. The ends and the vote are the same kind * of blip, a little longer: the low end a soft falling thud, the high end two rising chimes, the * vote a short pop that rises. * * Browsers allow sound only after a gesture of the reader's, so nothing plays before their first * press or key on the card. One AudioContext serves every card on the page: it is created on that * first gesture, suspended when the sounds stop (an idle context still runs its audio thread), and * closed when the last card is gone. *//** A tick's length, in seconds. */const LENGTH = 0.05;/** Ticks closer than this (ms) are skipped: a fast drag would buzz. */const MIN_GAP = 22;/** After this long without a sound (ms), the audio is suspended. */const REST = 1500;let context: AudioContext | null = null;let noise: AudioBuffer | null = null;let users = 0;let lastTick = 0;
use-autoplay.ts
'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(      ([entry]) => {        onScreen = entry.isIntersecting;
  • The track is a real slider (role="slider", 0 to 100), named by the question and read as “Bad, 20 of 100”. Arrows move it by 5, Page Up and Page Down by 20, Home and End to the ends, with a visible focus ring.
  • A press anywhere on the track moves the thumb there; pressing the thumb itself grabs it where it was held. Vertical swipes on a phone still scroll the page.
  • VOTE is a button. The next question is announced in a polite live region (“Question 2 of 2: …”), and so is the end of the round.
  • The faces, rings, sparkles and touch mark are decorative and hidden from screen readers; the counter is spoken through the announcement instead.
  • Sound only follows the reader’s own press or key, stays quiet (the loudest tick is soft), and can be turned off with sound={false}; nothing the card says depends on it.
  • Under reduced motion the thumb goes straight to its place, the question swaps at once, the ends send no ripples or sparkles, the face stays still and the demo never plays.