Animate reaction counts (#904)

This commit is contained in:
klopez4212
2026-06-08 10:47:24 -07:00
committed by GitHub
parent 10b6674bd7
commit dd08f988de
6 changed files with 399 additions and 6 deletions
@@ -6,6 +6,7 @@ import type { TimelineReaction } from "@/features/messages/types";
import { cn } from "@/shared/lib/cn";
import { emojiDisplayName } from "@/shared/lib/emojiName";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { AnimatedCount } from "@/shared/ui/AnimatedCount";
import {
isPositiveEmojiParticle,
useEmojiBurst,
@@ -433,7 +434,10 @@ function ReactionPill({
type="button"
>
<EmojiGlyph reaction={reaction} className={REACTION_GLYPH_CLASSES} />
<span className="text-muted-foreground">{reaction.count}</span>
<AnimatedCount
className="text-muted-foreground"
value={reaction.count}
/>
</button>
);
}
@@ -463,7 +467,10 @@ function ReactionPill({
reaction={reaction}
className={REACTION_GLYPH_CLASSES}
/>
<span className="text-muted-foreground">{reaction.count}</span>
<AnimatedCount
className="text-muted-foreground"
value={reaction.count}
/>
</button>
</span>
</PopoverTrigger>
+3 -4
View File
@@ -14,6 +14,7 @@ import { useNoteByIdQuery } from "@/features/pulse/hooks";
import { getReplyParent, noteSnippet } from "@/features/pulse/lib/replies";
import type { UserNote } from "@/shared/api/socialTypes";
import type { ChannelMember, UserProfileSummary } from "@/shared/api/types";
import { AnimatedCount } from "@/shared/ui/AnimatedCount";
import { Markdown } from "@/shared/ui/markdown";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
@@ -149,9 +150,7 @@ export function NoteCard({
const activeActionClass = "text-primary";
const countPlaceholder = <span aria-hidden className="w-2.5" />;
const reactionCountLabel =
reactionCount > 0 ? (
<span className="tabular-nums">{reactionCount}</span>
) : null;
reactionCount > 0 ? <AnimatedCount value={reactionCount} /> : null;
const currentUserAvatarUrl = currentUserProfile?.avatarUrl ?? null;
const replyParentId = getReplyParent(note);
@@ -224,7 +223,7 @@ export function NoteCard({
<button
aria-label={isUpvoted ? "Unlike" : "Like"}
aria-pressed={isUpvoted}
className={`${actionButtonClass} ${isUpvoted ? activeActionClass : ""} disabled:cursor-not-allowed disabled:opacity-45`}
className={`${actionButtonClass} ${isUpvoted ? activeActionClass : ""} disabled:opacity-45`}
disabled={isUpvotePending}
onClick={() => {
if (!isUpvotePending) {
+123
View File
@@ -97,6 +97,129 @@
-webkit-user-select: none;
}
.sprout-animated-count {
display: inline-block;
font-kerning: none;
font-variant-numeric: tabular-nums;
line-height: 1;
white-space: nowrap;
}
.sprout-animated-count__motion {
display: inline-flex;
line-height: 1;
}
.sprout-animated-count__static {
display: none;
}
.sprout-animated-count__slot {
display: inline-block;
height: 1em;
line-height: 1;
}
.sprout-animated-count__slot--digit {
overflow: hidden;
text-align: center;
width: 1ch;
}
.sprout-animated-count__reel {
display: flex;
flex-direction: column;
height: 2em;
line-height: 1;
will-change: transform;
}
.sprout-animated-count__reel > span {
display: block;
height: 1em;
line-height: 1;
}
.sprout-animated-count__slot[data-direction="up"] .sprout-animated-count__reel {
animation: sprout-animated-count-roll-up 260ms cubic-bezier(0.2, 0.8, 0.2, 1)
both;
}
.sprout-animated-count__slot[data-direction="down"]
.sprout-animated-count__reel {
animation: sprout-animated-count-roll-down 260ms
cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.sprout-animated-count__symbol {
display: inline-block;
line-height: 1;
}
.sprout-animated-count__symbol[data-direction="up"] {
animation: sprout-animated-count-symbol-up 220ms
cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.sprout-animated-count__symbol[data-direction="down"] {
animation: sprout-animated-count-symbol-down 220ms
cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
@keyframes sprout-animated-count-roll-up {
from {
transform: translateY(0);
}
to {
transform: translateY(-1em);
}
}
@keyframes sprout-animated-count-roll-down {
from {
transform: translateY(-1em);
}
to {
transform: translateY(0);
}
}
@keyframes sprout-animated-count-symbol-up {
from {
opacity: 0;
transform: translateY(0.25em);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes sprout-animated-count-symbol-down {
from {
opacity: 0;
transform: translateY(-0.25em);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.sprout-animated-count__motion {
display: none;
}
.sprout-animated-count__static {
display: inline;
}
}
/* ── Tiptap rich-text composer ──────────────────────────────────────── */
.rich-text-composer .tiptap {
outline: none;
+183
View File
@@ -0,0 +1,183 @@
import * as React from "react";
import { cn } from "@/shared/lib/cn";
import {
formatAnimatedCount,
getAnimatedCountSlots,
normalizeAnimatedCount,
} from "@/shared/ui/animatedCountParts";
type CountDirection = -1 | 0 | 1;
type AnimatedCountTransition = {
current: string;
direction: CountDirection;
previous: string;
version: number;
};
type AnimatedCountProps = {
className?: string;
value: number;
};
const ANIMATED_COUNT_SETTLE_DELAY_MS = 260;
function directionFor(previous: number, current: number): CountDirection {
if (current > previous) return 1;
if (current < previous) return -1;
return 0;
}
function renderSlotText(value: string) {
return value || "\u00A0";
}
function AnimatedCountSlot({
current,
direction,
isDigit,
previous,
version,
}: {
current: string;
direction: CountDirection;
isDigit: boolean;
previous: string;
version: number;
}) {
if (direction === 0 || previous === current) {
return (
<span
className={cn(
"sprout-animated-count__slot",
isDigit && "sprout-animated-count__slot--digit",
)}
>
{renderSlotText(current)}
</span>
);
}
if (!isDigit) {
return (
<span
className="sprout-animated-count__symbol"
data-direction={direction > 0 ? "up" : "down"}
key={`${version}-${previous}-${current}`}
>
{renderSlotText(current)}
</span>
);
}
return (
<span
className="sprout-animated-count__slot sprout-animated-count__slot--digit"
data-direction={direction > 0 ? "up" : "down"}
>
<span
className="sprout-animated-count__reel"
key={`${version}-${previous}-${current}`}
>
{direction > 0 ? (
<>
<span>{renderSlotText(previous)}</span>
<span>{renderSlotText(current)}</span>
</>
) : (
<>
<span>{renderSlotText(current)}</span>
<span>{renderSlotText(previous)}</span>
</>
)}
</span>
</span>
);
}
export function AnimatedCount({ className, value }: AnimatedCountProps) {
const normalizedValue = normalizeAnimatedCount(value);
const formattedValue = formatAnimatedCount(normalizedValue);
const previousValueRef = React.useRef(normalizedValue);
const [transition, setTransition] = React.useState<AnimatedCountTransition>(
() => ({
current: formattedValue,
direction: 0,
previous: formattedValue,
version: 0,
}),
);
React.useLayoutEffect(() => {
setTransition((currentTransition) => {
if (currentTransition.current === formattedValue) {
previousValueRef.current = normalizedValue;
return currentTransition;
}
const direction = directionFor(previousValueRef.current, normalizedValue);
previousValueRef.current = normalizedValue;
return {
current: formattedValue,
direction,
previous: currentTransition.current,
version: currentTransition.version + 1,
};
});
}, [formattedValue, normalizedValue]);
React.useEffect(() => {
if (
transition.direction === 0 ||
transition.previous === transition.current
) {
return;
}
const timeoutId = window.setTimeout(() => {
setTransition((currentTransition) => {
if (currentTransition.version !== transition.version) {
return currentTransition;
}
return {
...currentTransition,
direction: 0,
previous: currentTransition.current,
};
});
}, ANIMATED_COUNT_SETTLE_DELAY_MS);
return () => window.clearTimeout(timeoutId);
}, [
transition.current,
transition.direction,
transition.previous,
transition.version,
]);
const slots = getAnimatedCountSlots(transition.previous, transition.current);
return (
<span className={cn("sprout-animated-count", className)}>
<span className="sr-only">{transition.current}</span>
<span aria-hidden className="sprout-animated-count__motion">
{slots.map((slot) => (
<AnimatedCountSlot
current={slot.current}
direction={transition.direction}
isDigit={slot.isDigit}
key={`${transition.version}-${slot.place}`}
previous={slot.previous}
version={transition.version}
/>
))}
</span>
<span aria-hidden className="sprout-animated-count__static">
{transition.current}
</span>
</span>
);
}
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
formatAnimatedCount,
getAnimatedCountSlots,
normalizeAnimatedCount,
} from "./animatedCountParts.ts";
test("normalizeAnimatedCount clamps to displayable whole counts", () => {
assert.equal(normalizeAnimatedCount(4.9), 4);
assert.equal(normalizeAnimatedCount(-1), 0);
assert.equal(normalizeAnimatedCount(Number.NaN), 0);
});
test("formatAnimatedCount uses stable grouped ASCII digits", () => {
assert.equal(formatAnimatedCount(1234), "1,234");
});
test("getAnimatedCountSlots right-aligns digits for rollover", () => {
assert.deepEqual(getAnimatedCountSlots("9", "10"), [
{ current: "1", isDigit: true, place: 1, previous: "" },
{ current: "0", isDigit: true, place: 0, previous: "9" },
]);
});
test("getAnimatedCountSlots shrinks to current width after settling", () => {
assert.equal(getAnimatedCountSlots("1,000", "999").length, 5);
assert.deepEqual(getAnimatedCountSlots("999", "999"), [
{ current: "9", isDigit: true, place: 2, previous: "9" },
{ current: "9", isDigit: true, place: 1, previous: "9" },
{ current: "9", isDigit: true, place: 0, previous: "9" },
]);
});
@@ -0,0 +1,47 @@
export type AnimatedCountSlot = {
current: string;
isDigit: boolean;
place: number;
previous: string;
};
const COUNT_FORMATTER = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 0,
});
export function normalizeAnimatedCount(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.trunc(value));
}
export function formatAnimatedCount(value: number): string {
return COUNT_FORMATTER.format(normalizeAnimatedCount(value));
}
export function isAsciiDigit(value: string): boolean {
return value.length === 1 && value >= "0" && value <= "9";
}
export function getAnimatedCountSlots(
previous: string,
current: string,
): AnimatedCountSlot[] {
const width = Math.max(previous.length, current.length);
const previousOffset = width - previous.length;
const currentOffset = width - current.length;
return Array.from({ length: width }, (_, index) => {
const previousCharacter =
index >= previousOffset ? previous[index - previousOffset] : "";
const currentCharacter =
index >= currentOffset ? current[index - currentOffset] : "";
return {
current: currentCharacter ?? "",
isDigit:
isAsciiDigit(previousCharacter) || isAsciiDigit(currentCharacter),
place: width - index - 1,
previous: previousCharacter ?? "",
};
});
}