feat(frontend): content micro-animations — stagger, button press, toast polish

- animate-stagger utility + staggerDelay() helper; lists now rise in sequence
  (recordings grid, moderation rows, message list, media queue, dashboard tiles).
- Button gets a subtle active:scale-[0.97] press feedback.
- Toaster: toast-in slide-up, tone-accent border, rounded hover-close target.
- All motion is reduced-motion aware (killed under prefers-reduced-motion).
This commit is contained in:
asepharyana
2026-08-18 11:01:00 +07:00
parent 0cb0b82fb1
commit 217ecc1aa1
10 changed files with 74 additions and 14 deletions
@@ -29,6 +29,7 @@ import {
} from "@/hooks"; } from "@/hooks";
import { formatNumber } from "@/lib/format"; import { formatNumber } from "@/lib/format";
import type { DashboardStats } from "@/lib/types"; import type { DashboardStats } from "@/lib/types";
import { staggerDelay } from "@/lib/utils";
function deriveSignal(stats?: DashboardStats) { function deriveSignal(stats?: DashboardStats) {
if (!stats) return { tone: "signal" as const, label: "nominal" }; if (!stats) return { tone: "signal" as const, label: "nominal" };
@@ -113,23 +114,31 @@ export function DashboardView({
value={formatNumber(s.total_messages)} value={formatNumber(s.total_messages)}
tone="signal" tone="signal"
icon={<MessageSquare className="size-3.5" />} icon={<MessageSquare className="size-3.5" />}
className="animate-stagger"
style={staggerDelay(0)}
/> />
<MetricTile <MetricTile
label="Flagged" label="Flagged"
value={formatNumber(s.total_flagged)} value={formatNumber(s.total_flagged)}
tone={s.total_flagged > 0 ? "vermilion" : "neutral"} tone={s.total_flagged > 0 ? "vermilion" : "neutral"}
hint={`${s.today_flagged} today`} hint={`${s.today_flagged} today`}
className="animate-stagger"
style={staggerDelay(1)}
/> />
<MetricTile <MetricTile
label="Active 24h" label="Active 24h"
value={formatNumber(s.active_users_24h)} value={formatNumber(s.active_users_24h)}
tone="signal" tone="signal"
icon={<Users className="size-3.5" />} icon={<Users className="size-3.5" />}
className="animate-stagger"
style={staggerDelay(2)}
/> />
<MetricTile <MetricTile
label="Voice clips" label="Voice clips"
value={formatNumber(s.total_voice_recordings)} value={formatNumber(s.total_voice_recordings)}
icon={<Mic className="size-3.5" />} icon={<Mic className="size-3.5" />}
className="animate-stagger"
style={staggerDelay(3)}
/> />
</div> </div>
</GlassPanel> </GlassPanel>
@@ -28,6 +28,7 @@ import {
} from "@/hooks"; } from "@/hooks";
import { formatDuration } from "@/lib/format"; import { formatDuration } from "@/lib/format";
import type { MediaState } from "@/lib/types"; import type { MediaState } from "@/lib/types";
import { staggerDelay } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context"; import { useWebSocket } from "@/lib/ws/context";
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) { export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
@@ -205,7 +206,8 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
{queueList.map((item, i) => ( {queueList.map((item, i) => (
<div <div
key={`${item.source}-${i}`} key={`${item.source}-${i}`}
className="flex items-center gap-3 rounded-[10px] border border-hairline bg-white/5 px-3 py-2.5" className="animate-stagger flex items-center gap-3 rounded-[10px] border border-hairline bg-white/5 px-3 py-2.5"
style={staggerDelay(i)}
> >
<span className="mono w-5 text-ink-faint">{i + 1}</span> <span className="mono w-5 text-ink-faint">{i + 1}</span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -45,6 +45,7 @@ import {
safeParseJsonArray, safeParseJsonArray,
} from "@/lib/format"; } from "@/lib/format";
import type { AiStatus, Guild, MessageRecord } from "@/lib/types"; import type { AiStatus, Guild, MessageRecord } from "@/lib/types";
import { staggerDelay } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context"; import { useWebSocket } from "@/lib/ws/context";
export function MessagesView({ export function MessagesView({
@@ -254,16 +255,17 @@ export function MessagesView({
} }
}} }}
> >
{display.map((m) => ( {display.map((m, i) => (
<button <button
key={m.id} key={m.id}
type="button" type="button"
onClick={() => setSelected(m.id)} onClick={() => setSelected(m.id)}
className={`flex w-full items-start gap-3 rounded-[12px] border p-3 text-left transition-colors ${ className={`animate-stagger flex w-full items-start gap-3 rounded-[12px] border p-3 text-left transition-colors ${
selected === m.id selected === m.id
? "border-signal/40 bg-signal/8" ? "border-signal/40 bg-signal/8"
: "border-hairline bg-white/[0.03] hover:bg-white/[0.06]" : "border-hairline bg-white/[0.03] hover:bg-white/[0.06]"
}`} }`}
style={staggerDelay(i)}
> >
<Avatar src={m.avatar_url} name={m.username} size={34} /> <Avatar src={m.avatar_url} name={m.username} size={34} />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -37,6 +37,7 @@ import type {
ModerationActionType, ModerationActionType,
ModerationStats, ModerationStats,
} from "@/lib/types"; } from "@/lib/types";
import { staggerDelay } from "@/lib/utils";
const ACTION_ICON: Record<ModerationActionType, React.ReactNode> = { const ACTION_ICON: Record<ModerationActionType, React.ReactNode> = {
delete_message: <Trash2 className="size-3.5" />, delete_message: <Trash2 className="size-3.5" />,
@@ -217,8 +218,8 @@ export function ModerationView({
} }
/> />
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1"> <div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1">
{(actions ?? []).map((a) => ( {(actions ?? []).map((a, i) => (
<ActionRow key={a.id} a={a} /> <ActionRow key={a.id} a={a} index={i} />
))} ))}
{(actions ?? []).length === 0 && ( {(actions ?? []).length === 0 && (
<div className="py-10 text-center text-xs text-ink-faint"> <div className="py-10 text-center text-xs text-ink-faint">
@@ -232,7 +233,7 @@ export function ModerationView({
); );
} }
function ActionRow({ a }: { a: ModerationAction }) { function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
const tone = const tone =
a.status === "executed" a.status === "executed"
? "signal" ? "signal"
@@ -243,7 +244,10 @@ function ActionRow({ a }: { a: ModerationAction }) {
<AlertTriangle className="size-3.5" /> <AlertTriangle className="size-3.5" />
); );
return ( return (
<div className="flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3"> <div
className="animate-stagger flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3"
style={staggerDelay(index)}
>
<span <span
className={`mt-0.5 ${tone === "vermilion" ? "text-vermilion" : tone === "amber" ? "text-amber" : "text-signal"}`} className={`mt-0.5 ${tone === "vermilion" ? "text-vermilion" : tone === "amber" ? "text-amber" : "text-signal"}`}
> >
@@ -20,6 +20,7 @@ import {
} from "@/hooks"; } from "@/hooks";
import { formatBytes, formatRelativeTime } from "@/lib/format"; import { formatBytes, formatRelativeTime } from "@/lib/format";
import type { VoiceRecording } from "@/lib/types"; import type { VoiceRecording } from "@/lib/types";
import { staggerDelay } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context"; import { useWebSocket } from "@/lib/ws/context";
export function RecordingsView({ export function RecordingsView({
@@ -94,12 +95,13 @@ export function RecordingsView({
/> />
) : ( ) : (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{(items ?? []).map((r) => { {(items ?? []).map((r, i) => {
const up = uploadStatus(r); const up = uploadStatus(r);
return ( return (
<GlassCard <GlassCard
key={r.id} key={r.id}
className="flex flex-col gap-3 transition-colors hover:bg-white/[0.06]" className="animate-stagger flex flex-col gap-3 transition-colors hover:bg-white/[0.06]"
style={staggerDelay(i)}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Avatar src={r.avatar_url} name={r.username} size={38} /> <Avatar src={r.avatar_url} name={r.username} size={38} />
+22 -1
View File
@@ -280,6 +280,25 @@
.animate-fade-up { .animate-fade-up {
animation: fade-up 0.4s cubic-bezier(0.22, 1, 0.36, 1) both; animation: fade-up 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
} }
/* List stagger: each child rises in sequence. Pair with inline
style={{ animationDelay }} set via staggerDelay(). */
.animate-stagger {
animation: fade-up 0.42s cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* Toast: slide up + settle from the dock edge. */
.animate-toast-in {
animation: toast-in 0.22s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(14px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.scan-line::after, .scan-line::after,
@@ -289,7 +308,9 @@
.animate-pulse-ring, .animate-pulse-ring,
.animate-shimmer, .animate-shimmer,
.animate-breathe, .animate-breathe,
.animate-fade-up { .animate-fade-up,
.animate-stagger,
.animate-toast-in {
animation: none !important; animation: none !important;
} }
.scan-line { .scan-line {
@@ -40,7 +40,7 @@ export const Button = ({
return ( return (
<Comp <Comp
className={cn( className={cn(
"inline-flex items-center justify-center whitespace-nowrap transition-all duration-150 select-none", "inline-flex items-center justify-center whitespace-nowrap transition-all duration-150 select-none active:scale-[0.97]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal/60 disabled:opacity-40 disabled:pointer-events-none", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal/60 disabled:opacity-40 disabled:pointer-events-none",
variants[variant], variants[variant],
sizes[size], sizes[size],
@@ -74,8 +74,11 @@ export function Toaster({ position = "bottom-right" }: { position?: string }) {
return ( return (
<div <div
key={t.id} key={t.id}
className="glass pointer-events-auto flex items-start gap-3 p-3.5" className={cn(
style={{ animation: "fade-up 0.18s ease" }} "glass animate-toast-in pointer-events-auto flex items-start gap-3 p-3.5 transition-colors",
t.tone === "signal" && "border-signal/30",
t.tone === "vermilion" && "border-vermilion/30",
)}
> >
<Icon <Icon
className={cn( className={cn(
@@ -95,11 +98,12 @@ export function Toaster({ position = "bottom-right" }: { position?: string }) {
</div> </div>
<button <button
type="button" type="button"
aria-label="Dismiss"
onClick={() => { onClick={() => {
store = store.filter((x) => x.id !== t.id); store = store.filter((x) => x.id !== t.id);
emit(); emit();
}} }}
className="text-ink-faint hover:text-ink" className="rounded-md p-1 text-ink-faint transition-colors hover:bg-white/8 hover:text-ink"
> >
<X className="size-3.5" /> <X className="size-3.5" />
</button> </button>
@@ -37,6 +37,7 @@ export function MetricTile({
spark, spark,
icon, icon,
className, className,
style,
}: { }: {
label: string; label: string;
value: React.ReactNode; value: React.ReactNode;
@@ -45,6 +46,7 @@ export function MetricTile({
spark?: number[]; spark?: number[];
icon?: React.ReactNode; icon?: React.ReactNode;
className?: string; className?: string;
style?: React.CSSProperties;
}) { }) {
const toneColor = const toneColor =
tone === "vermilion" tone === "vermilion"
@@ -60,6 +62,7 @@ export function MetricTile({
"glass p-4 ring-focus transition-transform duration-200 hover:-translate-y-0.5", "glass p-4 ring-focus transition-transform duration-200 hover:-translate-y-0.5",
className, className,
)} )}
style={style}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="eyebrow">{label}</div> <div className="eyebrow">{label}</div>
+13
View File
@@ -4,3 +4,16 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
/**
* Returns an inline style that staggers a list item's entrance animation.
* Pair with the `animate-stagger` class. Caps the delay so long lists still
* appear promptly.
*/
export function staggerDelay(
index: number,
step = 45,
max = 600,
): React.CSSProperties {
return { animationDelay: `${Math.min(index * step, max)}ms` };
}