diff --git a/resources/css/app.css b/resources/css/app.css
index d8de19c39..d4fa57d5d 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -272,6 +272,42 @@
both;
--animate-star-pop: star-pop 450ms cubic-bezier(0.175, 0.885, 0.32, 1.275)
both;
+ --animate-odometer-up-out: odometer-up-out 250ms
+ cubic-bezier(0.16, 1, 0.3, 1) both;
+ --animate-odometer-up-in: odometer-up-in 250ms cubic-bezier(0.16, 1, 0.3, 1)
+ both;
+ --animate-odometer-down-out: odometer-down-out 250ms
+ cubic-bezier(0.16, 1, 0.3, 1) both;
+ --animate-odometer-down-in: odometer-down-in 250ms
+ cubic-bezier(0.16, 1, 0.3, 1) both;
+
+ @keyframes odometer-up-out {
+ to {
+ transform: translateY(-100%);
+ opacity: 0;
+ }
+ }
+
+ @keyframes odometer-up-in {
+ from {
+ transform: translateY(100%);
+ opacity: 0;
+ }
+ }
+
+ @keyframes odometer-down-out {
+ to {
+ transform: translateY(100%);
+ opacity: 0;
+ }
+ }
+
+ @keyframes odometer-down-in {
+ from {
+ transform: translateY(-100%);
+ opacity: 0;
+ }
+ }
--animate-flame-flicker: flame-flicker 5s ease-in-out infinite;
@keyframes flame-flicker {
diff --git a/resources/js/components/commit-star-button.tsx b/resources/js/components/commit-star-button.tsx
index 3f80fa66d..2467f3844 100644
--- a/resources/js/components/commit-star-button.tsx
+++ b/resources/js/components/commit-star-button.tsx
@@ -6,6 +6,7 @@ import CommitStarController from '@/actions/App/Http/Controllers/CommitStarContr
import { ActionTooltip } from '@/components/action-tooltip';
import { CommitStarPollingContext } from '@/components/commit-star-polling';
import { CommitStargazersDialog } from '@/components/commit-stargazers-dialog';
+import { OdometerCount } from '@/components/odometer-count';
import { StarredRemovalContext } from '@/components/starred-removal-context';
import { useCommitNotFound } from '@/hooks/use-deleted-resources';
import { useOwnsCommit } from '@/hooks/use-owns-commit';
@@ -13,7 +14,7 @@ import { refreshSharedState, useSharedState } from '@/hooks/use-shared-state';
import { signIn } from '@/lib/auth';
import { createCommitStarMutations } from '@/lib/commit-star-mutations';
import { commitStateKey } from '@/lib/commit-state';
-import { formatCount, formatExactCount } from '@/lib/numbers';
+import { formatExactCount } from '@/lib/numbers';
import { toastWhenThrottled } from '@/lib/throttle';
import { cn } from '@/lib/utils';
import type { Commit } from '@/types';
@@ -172,7 +173,7 @@ export function CommitStarButton({
aria-keyshortcuts={keyShortcut}
aria-label={starLabel(starred, count)}
className={cn(
- 'group/star inline-flex items-center gap-1 rounded-md px-1.5 py-1 font-mono text-[13px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground',
+ 'group/star inline-flex items-center gap-1 rounded-md px-1.5 py-1 font-mono text-[13px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground has-[[data-test=star-count]:empty]:mr-[calc(0.25rem+1ch)]',
starred && 'text-primary hover:text-primary',
className,
)}
@@ -186,14 +187,7 @@ export function CommitStarButton({
isAnimating && 'motion-safe:animate-star-pop',
)}
/>
- {count > 0 && (
-
- {formatCount(count)}
-
- )}
+
);
@@ -235,14 +229,7 @@ function StarCount({
data-test="own-star-count"
>
- {count > 0 && (
-
- {formatCount(count)}
-
- )}
+
diff --git a/resources/js/components/odometer-count.tsx b/resources/js/components/odometer-count.tsx
new file mode 100644
index 000000000..39e245649
--- /dev/null
+++ b/resources/js/components/odometer-count.tsx
@@ -0,0 +1,109 @@
+import { useLayoutEffect, useRef, useState } from 'react';
+import { formatCount } from '@/lib/numbers';
+import { odometerPlaces } from '@/lib/odometer';
+import { cn } from '@/lib/utils';
+
+const ANIMATION_MS = 250;
+
+interface OdometerCountProps {
+ value: number;
+ className?: string;
+ emptyWhenZero?: boolean;
+}
+
+function displayedCount(value: number, emptyWhenZero: boolean): string {
+ return emptyWhenZero && value === 0 ? '' : formatCount(value);
+}
+
+export function OdometerCount({
+ value,
+ className,
+ emptyWhenZero = false,
+}: OdometerCountProps) {
+ const previousValueRef = useRef(value);
+ const [previous, setPrevious] = useState(() =>
+ displayedCount(value, emptyWhenZero),
+ );
+ const [isAnimating, setIsAnimating] = useState(false);
+ const [direction, setDirection] = useState<'up' | 'down'>('up');
+
+ useLayoutEffect(() => {
+ if (value === previousValueRef.current) {
+ return;
+ }
+
+ const oldValue = previousValueRef.current;
+ previousValueRef.current = value;
+ setPrevious(displayedCount(oldValue, emptyWhenZero));
+ setDirection(value >= oldValue ? 'up' : 'down');
+ setIsAnimating(true);
+
+ const timer = setTimeout(() => setIsAnimating(false), ANIMATION_MS);
+
+ return () => clearTimeout(timer);
+ }, [value, emptyWhenZero]);
+
+ const current = displayedCount(value, emptyWhenZero);
+ const places = odometerPlaces(previous, current);
+
+ return (
+
+ {isAnimating
+ ? places.map(({ char, previousChar, isRolling }, index) => {
+ const place = places.length - index;
+
+ if (!isRolling) {
+ return (
+
+ {char}
+
+ );
+ }
+
+ return (
+
+ {previousChar !== ' ' && (
+
+ {previousChar}
+
+ )}
+ {char !== ' ' ? (
+
+ {char}
+
+ ) : (
+
+ {previousChar}
+
+ )}
+
+ );
+ })
+ : current}
+
+ );
+}
diff --git a/resources/js/lib/odometer.ts b/resources/js/lib/odometer.ts
new file mode 100644
index 000000000..1625665d9
--- /dev/null
+++ b/resources/js/lib/odometer.ts
@@ -0,0 +1,48 @@
+export interface OdometerPlace {
+ /** The character shown at this place now. */
+ char: string;
+ /** The character it replaces, or a space when the count grows into this place. */
+ previousChar: string;
+ /** Whether this place rolls from the previous character. */
+ isRolling: boolean;
+}
+
+function isDigit(char: string): boolean {
+ return char >= '0' && char <= '9';
+}
+
+function rolls(char: string, previousChar: string): boolean {
+ if (char === previousChar) {
+ return false;
+ }
+
+ if (!isDigit(char) && !isDigit(previousChar)) {
+ return false;
+ }
+
+ if (isDigit(char) && isDigit(previousChar)) {
+ return true;
+ }
+
+ return isDigit(char) ? previousChar === ' ' : char === ' ';
+}
+
+/**
+ * The places of the new count, paired with the character each replaces, so the view rolls only what changed. A digit
+ * that appears or disappears rolls; a separator or a shortened count's suffix changes in place.
+ */
+export function odometerPlaces(
+ previous: string,
+ current: string,
+): OdometerPlace[] {
+ const length = Math.max(previous.length, current.length);
+ const currentChars = current.padStart(length, ' ');
+ const previousChars = previous.padStart(length, ' ');
+
+ return Array.from({ length }, (_, index) => {
+ const char = currentChars[index];
+ const previousChar = previousChars[index];
+
+ return { char, previousChar, isRolling: rolls(char, previousChar) };
+ });
+}
diff --git a/tests/Unit/CommitStarButton.test.tsx b/tests/Unit/CommitStarButton.test.tsx
index 55796d0c4..0f68fcc33 100644
--- a/tests/Unit/CommitStarButton.test.tsx
+++ b/tests/Unit/CommitStarButton.test.tsx
@@ -72,7 +72,7 @@ for (const status of ['approved', 'pending', 'rejected'] as const) {
assert.match(body, /disabled=""/);
assert.match(
body,
- /data-test="own-star-count"[^>]*>