Questionnaire
An assistant asking what it cannot guess, one question at a time, with the answers read back before they go.
Before I draft the renewal note, three quick questions.
The idea
Five questions at once in a chat is a form to read before you can start; one at a time is a conversation, with a thin ink line saying how much is left. Steps slide the way you are going and leave quickly. The rows are real radios and checkboxes, so arrows and Space work as on any form; one highlight slides under pointer and keyboard, and what is chosen is an ink mark, never a background, so pointing and choosing can't be confused. Number keys pick, Enter moves on.
Install
npm i mihirmodi-ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { Questionnaire, type QuestionnaireStep } from "mihirmodi-ui";
const steps: QuestionnaireStep[] = [
{
id: "audience",
kind: "single",
title: "Who is the note for?",
options: [
{ value: "annual", label: "Annual customers up for renewal" },
{ value: "monthly", label: "Monthly customers on the fence" },
],
other: "Someone else…", // a row you can type into instead
},
{
id: "mention",
kind: "multiple",
title: "What should it mention?",
optional: true, // Skip appears; the summary says Skipped
options: [
{ value: "price", label: "The held price" },
{ value: "dashboard", label: "The new dashboard" },
],
},
{ id: "avoid", kind: "text", title: "Anything it should not say?", optional: true },
];
// 1–9 pick, Enter moves on, and the summary lets any answer be changed.
<Questionnaire
steps={steps}
onSubmit={(answers) => send(answers)} // { audience: "annual", mention: ["price"], avoid: null }
/>Props
| Prop | Type | Notes |
|---|---|---|
| stepsQuestionnaireStep[] | QuestionnaireStep[] | `{ id, kind, title, description?, optional? }` — `single` and `multiple` take `options` and `other?`; `text` takes `placeholder?`. |
| onSubmit(answers) => void | (answers) => void | `{ [id]: string | string[] | null }`. A skipped step is null. |
| otherboolean | string | boolean | string | On a choice step: a last row you can type into instead. A string is its placeholder. |
| optionalboolean | boolean | Shows Skip, lets Next pass empty, and the summary says Skipped. |
| submitLabel / sentLabelReactNode | ReactNode= "Send answers" / "Sent" | The summary's button, and what it says after. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
"use client";
import { useCallback, useEffect, useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion, type Variants } from "motion/react";
import { Button } from "../actions/Button.js";
import { Kbd } from "../actions/Kbd.js";
import { CheckIcon } from "../../lib/glyph.js";
import { CLOSE, FLAT, SLIDE } from "../../lib/motion.js";
export type QuestionnaireOption = {
value: string;
label: ReactNode;
/** A second line under the label. */
description?: ReactNode;
};
type StepBase = {
/** The key this step's answer is stored under. */
id: string;
title: ReactNode;
description?: ReactNode;
/** Shows Skip, and lets Next pass with nothing chosen. The summary says "Skipped". */
optional?: boolean;
};
export type QuestionnaireStep =
| (StepBase & {
kind: "single" | "multiple";
options: QuestionnaireOption[];
/** A last row you can type into instead of choosing — its placeholder, or true for "Something else". */
other?: boolean | string;
})
| (StepBase & { kind: "text"; placeholder?: string });
/** A choice's value, the typed text, an array for `multiple`, or null for a skipped step. */
export type QuestionnaireAnswer = string | string[] | null;
export type QuestionnaireProps = {
steps: QuestionnaireStep[];
onSubmit: (answers: Record<string, QuestionnaireAnswer>) => void;
/** The summary's button. */
submitLabel?: ReactNode;
/** What the summary says once sent. */
sentLabel?: ReactNode;
className?: string;
};
type Draft = { picked: string[]; text: string };
const EMPTY: Draft = { picked: [], text: "" };
function answerOf(step: QuestionnaireStep, d: Draft | undefined): QuestionnaireAnswer {
if (!d) return null;
const text = d.text.trim();
if (step.kind === "text") return text || null;
if (step.kind === "single") return text || d.picked[0] || null;
const all = text ? [...d.picked, text] : d.picked;
return all.length ? all : null;
}
function describe(step: QuestionnaireStep, a: QuestionnaireAnswer): ReactNode {
if (a === null) return null;
if (step.kind === "text") return a;
const name = (v: string) => step.options.find((o) => o.value === v)?.label ?? v;
if (Array.isArray(a))
return a.map((v, i) => (
<span key={v}>
{i > 0 && ", "}
{name(v)}
</span>
));
return name(a);
}
/**
* An assistant asking the reader something it cannot guess, one question at a
* time, with the answers read back before they are sent.
*
* One question at a time because a form of five questions in a chat is a wall
* to read before you can start; one at a time is a conversation, and the thin
* ink line under the counter says how much of it is left. Steps slide the way
* you are going — forward from the right, back from the left — so the motion
* says which way you moved, and the outgoing step leaves quickly and flat.
*
* The options are real radios and checkboxes under the rows, so arrows, Space
* and a screen reader all work as they do on any form. One shared highlight
* slides between rows under the pointer and the keyboard focus; what is chosen
* is said by an ink mark, never by a background, so pointing at a row and
* choosing it can never be confused. Number keys pick, Enter moves on, and
* the summary at the end lets any answer be changed before it is sent.
*/
export function Questionnaire({
steps,
onSubmit,
submitLabel = "Send answers",
sentLabel = "Sent",
className = "",
}: QuestionnaireProps) {
const reduce = useReducedMotion();
const uid = useId();
const n = steps.length;
const [index, setIndex] = useState(0);
const [dir, setDir] = useState(1);
const [drafts, setDrafts] = useState<Record<string, Draft>>({});
const [nudge, setNudge] = useState(false);
const [sent, setSent] = useState(false);
const fromSummary = useRef(false);
const wantFocus = useRef(false);
const body = useRef<HTMLDivElement | null>(null);
const step = index < n ? steps[index] : undefined;
const draft = (step && drafts[step.id]) || EMPTY;
const atSummary = index >= n;
const go = (to: number) => {
setDir(to > index ? 1 : -1);
setNudge(false);
wantFocus.current = true;
if (to >= n) fromSummary.current = false;
setIndex(Math.max(0, Math.min(n, to)));
};
const setDraft = (id: string, fn: (d: Draft) => Draft) => {
setNudge(false);
setDrafts((all) => ({ ...all, [id]: fn(all[id] ?? EMPTY) }));
};
const advance = () => go(fromSummary.current ? n : index + 1);
// A selector list matches in document order, so the preference has to be walked by hand.
const focusFirst = useCallback((root: HTMLElement | null) => {
if (!root) return;
for (const sel of ["input:checked", "input", "textarea", "[data-autofocus]"]) {
const el = root.querySelector<HTMLElement>(sel);
if (el) return el.focus({ preventScroll: true });
}
}, []);
const next = () => {
if (!step) return;
if (answerOf(step, draft) === null && !step.optional) {
setNudge(true);
focusFirst(body.current);
return;
}
advance();
};
const skip = () => {
if (!step) return;
setDrafts((all) => ({ ...all, [step.id]: EMPTY }));
advance();
};
const submit = () => {
if (sent) return;
const answers: Record<string, QuestionnaireAnswer> = {};
for (const s of steps) answers[s.id] = answerOf(s, drafts[s.id]);
setSent(true);
onSubmit(answers);
};
const choose = (s: Extract<QuestionnaireStep, { options: unknown }>, value: string) =>
setDraft(s.id, (d) =>
s.kind === "single"
? { picked: [value], text: "" }
: { ...d, picked: d.picked.includes(value) ? d.picked.filter((v) => v !== value) : [...d.picked, value] }
);
const onKeyDown = (e: KeyboardEvent<HTMLFormElement>) => {
if (e.metaKey || e.ctrlKey || e.altKey || e.nativeEvent.isComposing) return;
const t = e.target as HTMLElement;
const typing = t.tagName === "TEXTAREA" || (t.tagName === "INPUT" && (t as HTMLInputElement).type === "text");
if (e.key === "Enter" && t.tagName !== "BUTTON") {
if (t.tagName === "TEXTAREA" && e.shiftKey) return;
e.preventDefault();
if (atSummary) submit();
else next();
return;
}
if (!typing && step && step.kind !== "text" && /^[1-9]$/.test(e.key)) {
const opt = step.options[Number(e.key) - 1];
if (!opt) return;
e.preventDefault();
choose(step, opt.value);
// Keep the keyboard where the choice was made, so Space and arrows carry on from it.
body.current?.querySelector<HTMLInputElement>(`input[value="${CSS.escape(opt.value)}"]`)?.focus({ preventScroll: true });
}
};
const variants: Variants = {
enter: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, x: d * 28 }),
center: { opacity: 1, x: 0, transition: reduce ? FLAT : { x: SLIDE, opacity: { duration: 0.2, ease: "easeOut" } } },
exit: (d: number) => (reduce ? { opacity: 0, transition: FLAT } : { opacity: 0, x: d * -20, transition: CLOSE }),
};
const progress = atSummary ? 1 : index / n;
const nudgeId = `${uid}-nudge`;
return (
<LazyMotion features={domAnimation} strict>
<form
onSubmit={(e) => e.preventDefault()}
onKeyDown={onKeyDown}
className={`rounded-2xl border-[0.5px] border-gray-alpha-400 bg-background-100 p-4 ${className}`}
>
<div className="flex items-center justify-between gap-3 text-[12px] tabular-nums text-faint">
<span aria-live="polite">{atSummary ? "Your answers" : `Question ${index + 1} of ${n}`}</span>
{step?.optional && <span>Optional</span>}
</div>
<div
role="progressbar"
aria-label="Questions answered"
aria-valuemin={0}
aria-valuemax={n}
aria-valuenow={atSummary ? n : index}
className="mt-2 h-[2px] overflow-hidden rounded-full bg-gray-alpha-200"
>
<div
className="h-full origin-left rounded-full bg-ink transition-transform duration-300 ease-geist motion-reduce:transition-none"
style={{ transform: `scaleX(${progress})` }}
/>
</div>
{/* Clipped sideways only, at the box's own edge (the negative margin reaches back over the padding),
so a step slides out under the border while the rows' highlight still bleeds past the text. */}
<div className="-mx-4 mt-4 overflow-x-clip px-4">
<AnimatePresence mode="wait" initial={false} custom={dir}>
<m.div
key={index}
custom={dir}
variants={variants}
initial="enter"
animate="center"
exit="exit"
>
<Pane
onMount={(el) => {
body.current = el;
if (wantFocus.current) {
wantFocus.current = false;
focusFirst(el);
}
}}
>
{step ? (
<StepView
uid={`${uid}-${index}`}
step={step}
draft={draft}
nudge={nudge}
nudgeId={nudgeId}
reduce={!!reduce}
onChoose={(v) => step.kind !== "text" && choose(step, v)}
onText={(text) =>
setDraft(step.id, (d) => (step.kind === "single" ? { picked: text ? [] : d.picked, text } : { ...d, text }))
}
/>
) : (
<Summary
steps={steps}
drafts={drafts}
sent={sent}
onChange={(i) => {
fromSummary.current = true;
go(i);
}}
/>
)}
</Pane>
</m.div>
</AnimatePresence>
</div>
<div className="mt-4 flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={() => go(index - 1)} disabled={index === 0 || sent}>
Back
</Button>
<span className="min-w-0 flex-1 truncate text-[12px] text-faint" id={nudgeId} aria-live="polite">
{nudge ? "Choose an answer to continue" : sent ? null : atSummary ? "Enter to send" : "Enter to continue"}
</span>
{step?.optional && (
<Button size="sm" variant="ghost" onClick={skip}>
Skip
</Button>
)}
{atSummary ? (
sent ? (
<span className="inline-flex h-7 items-center gap-1.5 px-1 text-[12px] font-medium text-gray-1000">
<CheckIcon className="h-3.5 w-3.5" /> {sentLabel}
</span>
) : (
<Button size="sm" variant="primary" onClick={submit} data-autofocus>
{submitLabel}
</Button>
)
) : (
<Button size="sm" variant="primary" onClick={next}>
{fromSummary.current || index === n - 1 ? "Review" : "Next"}
</Button>
)}
</div>
</form>
</LazyMotion>
);
}
/** The step's own box. Mounting is the signal that a step has arrived — the outgoing one is still mounted while it leaves. */
function Pane({ onMount, children }: { onMount: (el: HTMLDivElement) => void; children: ReactNode }) {
const el = useRef<HTMLDivElement>(null);
const cb = useRef(onMount);
cb.current = onMount;
useEffect(() => {
if (el.current) cb.current(el.current);
}, []);
return <div ref={el}>{children}</div>;
}
type Rect = { y: number; h: number };
function StepView({
uid,
step,
draft,
nudge,
nudgeId,
reduce,
onChoose,
onText,
}: {
uid: string;
step: QuestionnaireStep;
draft: Draft;
nudge: boolean;
nudgeId: string;
reduce: boolean;
onChoose: (value: string) => void;
onText: (text: string) => void;
}) {
const [bar, setBar] = useState<Rect | null>(null);
const [shown, setShown] = useState(false);
const titleId = `${uid}-title`;
const descId = `${uid}-desc`;
const describedBy = [step.description ? descId : "", nudge ? nudgeId : ""].filter(Boolean).join(" ") || undefined;
const move = (el: HTMLElement) => {
setBar({ y: el.offsetTop, h: el.offsetHeight });
setShown(true);
};
const row = {
onMouseEnter: (e: { currentTarget: HTMLElement }) => move(e.currentTarget),
// Only keyboard focus draws the bar: a clicked row is already marked, and a
// highlight left behind on it would read as a second kind of chosen.
onFocus: (e: { currentTarget: HTMLElement; target: HTMLElement }) => {
if (e.target.matches(":focus-visible")) move(e.currentTarget);
},
onBlur: () => setShown(false),
};
const head = (
<>
<legend id={titleId} className="text-[15px] font-medium leading-[1.4] text-gray-1000">
{step.title}
</legend>
{step.description && (
<p id={descId} className="mt-1 text-[13px] leading-[1.5] text-muted">
{step.description}
</p>
)}
</>
);
if (step.kind === "text") {
return (
<fieldset aria-describedby={describedBy}>
{head}
<textarea
rows={3}
value={draft.text}
onChange={(e) => onText(e.target.value)}
placeholder={step.placeholder ?? "Type an answer"}
aria-labelledby={titleId}
aria-invalid={nudge || undefined}
className="mt-3 block w-full resize-none rounded-[10px] border-[0.5px] border-gray-alpha-400 bg-background-100 px-3 py-2 text-[14px] leading-[1.5] text-gray-1000 transition-colors duration-150 placeholder:text-faint hover:border-gray-alpha-600"
/>
<p className="mt-1.5 text-[12px] text-faint">Shift+Enter for a new line</p>
</fieldset>
);
}
const multiple = step.kind === "multiple";
const otherOn = draft.text.trim().length > 0;
const placeholder = typeof step.other === "string" ? step.other : "Something else";
return (
<fieldset aria-describedby={describedBy} aria-invalid={nudge || undefined}>
{head}
<div
className="relative isolate -mx-2 mt-3 flex flex-col"
onMouseLeave={() => {
// Back to the row the keyboard is on, if it is on one; otherwise away.
const focused = document.activeElement;
const at = focused instanceof HTMLElement && focused.matches(":focus-visible") ? focused.closest("label") : null;
if (at) move(at);
else setShown(false);
}}
>
<m.div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 -z-10 rounded-[8px] bg-gray-alpha-200"
initial={false}
animate={bar ? { y: bar.y, height: bar.h, opacity: shown ? 1 : 0 } : { opacity: 0 }}
transition={reduce ? FLAT : { ...SLIDE, opacity: { duration: 0.16, ease: "easeOut" } }}
/>
{step.options.map((o, i) => {
const on = draft.picked.includes(o.value);
return (
<label key={o.value} className="flex cursor-pointer items-start gap-3 rounded-[8px] px-2 py-2.5" {...row}>
<input
type={multiple ? "checkbox" : "radio"}
name={uid}
value={o.value}
checked={on}
onChange={() => onChoose(o.value)}
className="sr-only"
/>
<Mark on={on} multiple={multiple} />
<span className="min-w-0 flex-1">
<span className="block text-[14px] leading-[1.4] text-gray-1000">{o.label}</span>
{o.description && <span className="mt-0.5 block text-[13px] leading-[1.45] text-muted">{o.description}</span>}
</span>
{i < 9 && (
<span aria-hidden className="mt-px">
<Kbd>{String(i + 1)}</Kbd>
</span>
)}
</label>
);
})}
{step.other && (
<label className="flex cursor-text items-center gap-3 rounded-[8px] px-2 py-1.5" {...row}>
<Mark on={otherOn} multiple={multiple} />
<input
type="text"
value={draft.text}
onChange={(e) => onText(e.target.value)}
placeholder={placeholder}
aria-label={placeholder}
className="h-7 min-w-0 flex-1 bg-transparent text-[14px] text-gray-1000 placeholder:text-faint"
/>
</label>
)}
</div>
</fieldset>
);
}
/** The chosen mark: a ring that fills with ink, or a box that fills and takes a check. */
function Mark({ on, multiple }: { on: boolean; multiple: boolean }) {
const shape = multiple ? "rounded-[5px]" : "rounded-full";
return (
<span
aria-hidden
className={`mt-[2px] flex h-4 w-4 shrink-0 items-center justify-center border transition-[background-color,border-color] duration-150 ease-out ${shape} ${
on ? "border-ink bg-ink text-background-100" : "border-gray-alpha-600 bg-background-100"
}`}
>
{multiple ? (
<CheckIcon
className={`h-3 w-3 transition-[opacity,transform] duration-150 ease-geist motion-reduce:transition-none ${on ? "scale-100 opacity-100" : "scale-50 opacity-0"}`}
/>
) : (
<span
className={`h-1.5 w-1.5 rounded-full bg-background-100 transition-transform duration-150 ease-geist motion-reduce:transition-none ${on ? "scale-100" : "scale-0"}`}
/>
)}
</span>
);
}
function Summary({
steps,
drafts,
sent,
onChange,
}: {
steps: QuestionnaireStep[];
drafts: Record<string, Draft>;
sent: boolean;
onChange: (index: number) => void;
}) {
return (
<div>
<h3 className="text-[15px] font-medium leading-[1.4] text-gray-1000" tabIndex={-1} data-autofocus>
{sent ? "Thanks — that is everything I needed." : "Here is what I will work from."}
</h3>
<dl className="mt-2">
{steps.map((s, i) => {
const a = answerOf(s, drafts[s.id]);
return (
<div key={s.id} className="flex items-start gap-3 border-b-[0.5px] border-gray-alpha-400 py-2.5 last:border-b-0">
<div className="min-w-0 flex-1">
<dt className="text-[12px] leading-[1.4] text-faint">{s.title}</dt>
<dd className={`mt-0.5 text-[14px] leading-[1.45] ${a === null ? "text-faint" : "text-gray-1000"}`}>
{a === null ? "Skipped" : describe(s, a)}
</dd>
</div>
{!sent && (
<Button size="sm" variant="ghost" onClick={() => onChange(i)} aria-label={`Change: ${typeof s.title === "string" ? s.title : `question ${i + 1}`}`}>
Change
</Button>
)}
</div>
);
})}
</dl>
</div>
);
}
"use client";
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
import { Spinner } from "./Spinner.js";
export type ButtonVariant = "primary" | "secondary" | "ghost" | "glass";
export type ButtonSize = "sm" | "md" | "lg" | "icon" | "icon-sm";
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
/**
* `primary` is the one ink fill on a screen. `secondary` is a hairline on the
* page. `ghost` has no edge until you point at it. `glass` floats over content.
*/
variant?: ButtonVariant;
size?: ButtonSize;
/** Swap the label's leading mark for a spinner and refuse presses, keeping the width. */
loading?: boolean;
/** A mark before the label. */
icon?: ReactNode;
/** A mark after the label — an arrow, a shortcut. */
trailing?: ReactNode;
};
/** The class strings, exported so a link or a menu row can wear a button without being one. */
export const buttonVariants: Record<ButtonVariant, string> = {
primary: "bg-ink text-background-100 hover:bg-[color-mix(in_srgb,var(--color-ink)_86%,var(--color-background-100))]",
secondary:
"border-[0.5px] border-gray-alpha-400 bg-background-100 text-gray-1000 hover:border-gray-alpha-600 hover:bg-gray-100",
ghost: "text-muted hover:bg-gray-alpha-200 hover:text-gray-1000",
glass: "glass-nav dock-shadow border-[0.5px] border-gray-alpha-400 text-gray-1000 hover:bg-[var(--glass-menu-bg)]",
};
export const buttonSizes: Record<ButtonSize, string> = {
sm: "h-7 gap-1.5 rounded-[8px] px-2.5 text-[12px]",
md: "h-9 gap-2 rounded-[10px] px-3.5 text-[13px]",
lg: "h-10 gap-2 rounded-[12px] px-4 text-[14px]",
icon: "h-9 w-9 rounded-[10px]",
"icon-sm": "h-7 w-7 rounded-[8px]",
};
/** Everything a button shares whatever it wears: the press, the transition, the disabled dim. */
export const buttonBase =
"inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap font-medium leading-none transition-[background-color,border-color,color,transform,opacity] duration-150 ease-geist active:scale-[0.97] disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none motion-reduce:active:scale-100";
export function buttonClass(variant: ButtonVariant = "secondary", size: ButtonSize = "md", extra = "") {
return `${buttonBase} ${buttonVariants[variant]} ${buttonSizes[size]} ${extra}`;
}
/**
* A button, in four materials and five sizes.
*
* Only `primary` is filled, and a screen should have one of them: the fill is
* how the eye finds the thing to do next, and two fills make the reader choose
* between them before they have read either. Everything else is a hairline or
* nothing at all. There is deliberately no red "destructive" variant — the
* palette is monochrome, and what makes a destructive button safe is the
* words on it and the confirmation in front of it (see AlertDialog), not a
* colour.
*
* The press is a 3% shrink on the house curve: enough to feel the button
* take the click, too small to read as movement. While `loading`, the leading
* mark becomes a spinner in the same box, so the label does not jump and the
* button does not change width under the cursor.
*/
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ variant = "secondary", size = "md", loading = false, icon, trailing, className = "", children, disabled, type = "button", ...rest },
ref
) {
const lead = loading ? <Spinner size={size === "sm" || size === "icon-sm" ? 12 : 14} /> : icon;
const iconOnly = size === "icon" || size === "icon-sm";
return (
<button
ref={ref}
type={type}
disabled={disabled || loading}
aria-busy={loading || undefined}
className={buttonClass(variant, size, className)}
{...rest}
>
{iconOnly ? (
(lead ?? children)
) : (
<>
{lead && <span className="flex shrink-0 items-center">{lead}</span>}
{children}
{trailing && <span className="-mr-0.5 flex shrink-0 items-center opacity-70">{trailing}</span>}
</>
)}
</button>
);
});
import type { ReactNode } from "react";
export type KbdProps = {
/** One key, or several pressed together: `["⌘", "K"]`. */
keys?: string[];
children?: ReactNode;
className?: string;
};
/**
* A key, drawn as a key: a raised cap with a lip along its foot, in tabular
* figures so a column of shortcuts lines up. Several keys pressed together sit
* in one `<kbd>` with a hairline gap between caps rather than a "+", which is
* how a keyboard looks and how a menu's shortcut column is read.
*/
export function Kbd({ keys, children, className = "" }: KbdProps) {
const caps = keys ?? [children];
return (
<kbd className={`inline-flex items-center gap-[3px] font-sans ${className}`}>
{caps.map((k, i) => (
<span
key={i}
className="inline-flex h-5 min-w-5 items-center justify-center rounded-[5px] border-[0.5px] border-gray-alpha-400 bg-background-100 px-1 text-[11px] font-medium leading-none tabular-nums text-muted shadow-[inset_0_-1px_0_var(--color-gray-alpha-300)]"
>
{k}
</span>
))}
</kbd>
);
}
/**
* The motion presets the newer components share. The older ones (Dock,
* DropMenu, HighlightList) carry their own copies of the same numbers, which is
* deliberate: each of them documents why its curve is what it is, in place.
*/
/** One slide for anything that re-targets its position. */
export const SLIDE = { type: "spring", bounce: 0.2, duration: 0.4 } as const;
/** Something arriving that should land with a small settle. */
export const LIFT = { type: "spring", bounce: 0.28, duration: 0.5 } as const;
/** The disagreeing pair behind squash-and-stretch: see DropMenu. */
export const GROW = { type: "spring", stiffness: 620, damping: 19, mass: 0.85 } as const;
export const CROSS = { type: "spring", stiffness: 380, damping: 30 } as const;
/** Closing is quick and flat. A bounce on the way out reads as indecision. */
export const CLOSE = { duration: 0.13, ease: "easeIn" } as const;
/** Under reduced motion: a short plain fade, nothing that travels. */
export const FLAT = { duration: 0.15 } as const;
/** Expo-out, for anything that also drives a blur — it must not overshoot. */
export const EASE_OUT_EXPO = [0.16, 1, 0.3, 1] as const;
/** The house curve: overshoots a touch, for transforms only. */
export const EASE_GEIST = [0.175, 0.885, 0.32, 1.1] as const;
/** A block entering a list: fade and 4px. Opacity never springs. */
export function enter(reduce: boolean | null) {
return {
initial: reduce ? { opacity: 0 } : { opacity: 0, y: 4 },
animate: { opacity: 1, y: 0 },
transition: reduce ? FLAT : { duration: 0.2, ease: EASE_GEIST },
};
}