Skip to content

Commit 8d512ce

Browse files
feat: add customizable entrance animations for towers
1 parent d0d9790 commit 8d512ce

6 files changed

Lines changed: 96 additions & 31 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ URL Parameter > Theme Default > System Fallback
177177
| `tz` | `string` | No | Omitted = UTC | IANA timezone (e.g. `Asia/Kolkata`, `America/New_York`) — aligns "today" with the user local midnight. Note: `?tz=UTC` is valid but cached separately from omitting `tz`. |
178178
| `lang` | `string` | No | `en` | Language code for labels (`en`, `es`, `hi`, `fr`, `pt`, `ko`, `ja`, `de`, `zh`) |
179179
| `view` | `string` | No | `default` | Rendering mode: `default` (3D Monolith), `monthly` (Compact monthly stats), or `heatmap` (flat 2D contribution heatmap) |
180+
| `entrance` | `string` | No | `rise` | Entrance animation for towers: `rise` (default), `fade`, `slide`, or `none`. |
180181
| `delta_format` | `string` | No | `percent` | Format for month-over-month delta in monthly view: `percent` (e.g. +12%), `absolute` (e.g. +15 commits), or `both` |
181182
| `width` | `number` | No | `300` | Custom width for the SVG canvas (currently only applies to `view=monthly`) |
182183
| `height` | `number` | No | `120` | Custom height for the SVG canvas (currently only applies to `view=monthly`) |

lib/svg/animations.test.ts

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,38 @@
11
import { describe, it, expect } from 'vitest';
2-
import { TOWER_ANIMATION_CSS } from './animations';
2+
import { getTowerAnimationCSS } from './animations';
33

4-
describe('TOWER_ANIMATION_CSS', () => {
5-
it('contains required CSS classes and keyframes', () => {
6-
expect(TOWER_ANIMATION_CSS).toContain('.cp-tower');
7-
expect(TOWER_ANIMATION_CSS).toContain('@keyframes grow-up');
4+
describe('getTowerAnimationCSS', () => {
5+
it('returns rise animation by default', () => {
6+
const css = getTowerAnimationCSS();
7+
expect(css).toContain('.cp-tower');
8+
expect(css).toContain('@keyframes grow-up');
9+
expect(css).toContain('transform: scaleY(0)');
10+
expect(css).toContain('transform: scaleY(1)');
11+
expect(css).toContain('transform-origin: 0 10px');
812
});
913

10-
it('defines correct transform states for the grow-up animation', () => {
11-
expect(TOWER_ANIMATION_CSS).toContain('transform: scaleY(0)');
12-
expect(TOWER_ANIMATION_CSS).toContain('transform: scaleY(1)');
14+
it('returns fade animation when requested', () => {
15+
const css = getTowerAnimationCSS('fade');
16+
expect(css).toContain('@keyframes fade-in');
17+
expect(css).toContain('opacity: 0');
18+
expect(css).toContain('opacity: 1');
1319
});
1420

15-
it('sets the correct transform-origin for towers to grow from the ground', () => {
16-
expect(TOWER_ANIMATION_CSS).toContain('transform-origin: 0 10px');
21+
it('returns slide animation when requested', () => {
22+
const css = getTowerAnimationCSS('slide');
23+
expect(css).toContain('@keyframes slide-down');
24+
expect(css).toContain('transform: translateY(-20px)');
25+
});
26+
27+
it('returns static render when entrance is none', () => {
28+
const css = getTowerAnimationCSS('none');
29+
expect(css).toContain('transform: scaleY(1)');
30+
expect(css).not.toContain('@keyframes');
1731
});
1832

1933
it('includes accessibility support for prefers-reduced-motion', () => {
20-
expect(TOWER_ANIMATION_CSS).toContain('prefers-reduced-motion');
21-
// Ensure that the animation is disabled for reduced motion
22-
expect(TOWER_ANIMATION_CSS).toContain('animation: none !important');
34+
const css = getTowerAnimationCSS('rise');
35+
expect(css).toContain('prefers-reduced-motion');
36+
expect(css).toContain('animation: none !important');
2337
});
2438
});

lib/svg/animations.ts

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,62 @@
2020
// towers scale upward from their ground tile rather than from the SVG origin.
2121
const TOWER_BASE_Y = 10;
2222

23-
export const TOWER_ANIMATION_CSS = `
24-
.cp-tower {
25-
transform: scaleY(0);
26-
transform-origin: 0 ${TOWER_BASE_Y}px;
27-
animation: grow-up 1.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
23+
export function getTowerAnimationCSS(
24+
entrance: 'rise' | 'fade' | 'slide' | 'none' = 'rise'
25+
): string {
26+
if (entrance === 'none') {
27+
return `
28+
.cp-tower { transform: scaleY(1); opacity: 1; }
29+
`;
2830
}
29-
@keyframes grow-up {
30-
from { transform: scaleY(0); }
31-
to { transform: scaleY(1); }
32-
}
33-
@media (prefers-reduced-motion: reduce) {
34-
.cp-tower { animation: none !important; transform: scaleY(1) !important; }
31+
32+
let baseStyles = '';
33+
let keyframes = '';
34+
35+
if (entrance === 'rise') {
36+
baseStyles = `
37+
transform: scaleY(0);
38+
transform-origin: 0 ${TOWER_BASE_Y}px;
39+
animation: grow-up 1.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
40+
`;
41+
keyframes = `
42+
@keyframes grow-up {
43+
from { transform: scaleY(0); }
44+
to { transform: scaleY(1); }
45+
}
46+
`;
47+
} else if (entrance === 'fade') {
48+
baseStyles = `
49+
opacity: 0;
50+
animation: fade-in 1.2s ease-out forwards;
51+
`;
52+
keyframes = `
53+
@keyframes fade-in {
54+
from { opacity: 0; }
55+
to { opacity: 1; }
56+
}
57+
`;
58+
} else if (entrance === 'slide') {
59+
baseStyles = `
60+
opacity: 0;
61+
transform: translateY(-20px);
62+
animation: slide-down 1.2s cubic-bezier(0.16, 1, 0.3, 1) forwards;
63+
`;
64+
keyframes = `
65+
@keyframes slide-down {
66+
from { opacity: 0; transform: translateY(-20px); }
67+
to { opacity: 1; transform: translateY(0); }
68+
}
69+
`;
3570
}
36-
`;
71+
72+
return `
73+
.cp-tower {
74+
${baseStyles}
75+
}
76+
${keyframes}
77+
@media (prefers-reduced-motion: reduce) {
78+
.cp-tower { animation: none !important; transform: scaleY(1) translateY(0) !important; opacity: 1 !important; }
79+
}
80+
`;
81+
}

lib/svg/generator.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import type { BadgeParams, ContributionCalendar, StreakStats, MonthlyStats } from '../../types';
44
import { getLabels, type BadgeLabels } from '../i18n/badgeLabels';
55
import { AUTO_THEME_DARK, AUTO_THEME_LIGHT, themes } from './themes';
6-
import { TOWER_ANIMATION_CSS } from './animations';
6+
import { getTowerAnimationCSS } from './animations';
77
import { computeTowers, type TowerData } from './layout';
88
import {
99
sanitizeFont,
@@ -195,7 +195,8 @@ function renderStyle(
195195
text: string,
196196
accent: string,
197197
sf: number,
198-
bg: string
198+
bg: string,
199+
entrance: 'rise' | 'fade' | 'slide' | 'none' = 'rise'
199200
): string {
200201
const fs = (n: number) => Math.round(n * sf * 10) / 10;
201202
const isLightBg = getLuminance(bg) > 0.5;
@@ -206,7 +207,7 @@ function renderStyle(
206207
<style>
207208
@import url('https://fonts.googleapis.com/css2?family=Fira+Code&amp;family=JetBrains+Mono&amp;family=Roboto&amp;family=Syncopate:wght@400;700&amp;family=Space+Grotesk:wght@400;500;600;700&amp;display=swap');
208209
${googleFontsImport}
209-
${TOWER_ANIMATION_CSS}
210+
${getTowerAnimationCSS(entrance)}
210211
.scan-line {
211212
animation: scan-sweep var(--scan-speed, 8s) linear infinite;
212213
transform-box: fill-box;
@@ -524,7 +525,7 @@ export function generateSVG(
524525
return `
525526
<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 ${W} ${H}" fill="none" role="img">
526527
${renderHeader(safeUser, stats, sf, params)}
527-
${renderStyle(selectedFont, statsFont, googleFontsImport, text, mainAccentHex, sf, bg)}
528+
${renderStyle(selectedFont, statsFont, googleFontsImport, text, mainAccentHex, sf, bg, params.entrance || 'rise')}
528529
<rect width="${W}" height="${H}" rx="${radius}" fill="${params.hideBackground ? 'transparent' : bg}" ${borderAttr} />
529530
<g transform="translate(0, ${Math.round(20 * sf)})">${towers}</g>
530531
${renderIsometricLabels(calendar, params, text, sf)}
@@ -585,7 +586,7 @@ function generateAutoThemeSVG(
585586
:root { --cp-bg: #${light.bg}; --cp-text: #${light.text}; --cp-accent: #${light.accent}; --cp-label-fill: ${lightLabelFill}; --cp-label-opacity: ${lightLabelOpacity}; }
586587
@media (prefers-color-scheme: dark) { :root { --cp-bg: #${dark.bg}; --cp-text: #${dark.text}; --cp-accent: #${dark.accent}; --cp-label-fill: ${darkLabelFill}; --cp-label-opacity: ${darkLabelOpacity}; } }
587588
.cp-bg-fill { fill: var(--cp-bg); } .cp-text-fill { fill: var(--cp-text); color: var(--cp-text); } .cp-accent-fill { fill: var(--cp-accent); color: var(--cp-accent); }
588-
${TOWER_ANIMATION_CSS}
589+
${getTowerAnimationCSS(params.entrance || 'rise')}
589590
.scan-line {
590591
animation: scan-sweep var(--scan-speed, 8s) linear infinite;
591592
transform-box: fill-box;
@@ -1917,7 +1918,7 @@ function generateAutoThemeVersusSVG(
19171918
:root { --cp-bg: #${light.bg}; --cp-text: #${light.text}; --cp-accent: #${light.accent}; --cp-label-fill: ${lightLabelFill}; --cp-label-opacity: ${lightLabelOpacity}; }
19181919
@media (prefers-color-scheme: dark) { :root { --cp-bg: #${dark.bg}; --cp-text: #${dark.text}; --cp-accent: #${dark.accent}; --cp-label-fill: ${darkLabelFill}; --cp-label-opacity: ${darkLabelOpacity}; } }
19191920
.cp-bg-fill { fill: var(--cp-bg); } .cp-text-fill { fill: var(--cp-text); color: var(--cp-text); } .cp-accent-fill { fill: var(--cp-accent); color: var(--cp-accent); }
1920-
${TOWER_ANIMATION_CSS}
1921+
${getTowerAnimationCSS(params.entrance || 'rise')}
19211922
.scan-line {
19221923
animation: scan-sweep var(--scan-speed, 8s) linear infinite;
19231924
transform-box: fill-box;

lib/validations.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ export const streakParamsSchema = z.object({
240240
.string()
241241
.optional()
242242
.transform((val) => val === 'true' || val === '1'),
243+
entrance: z.enum(['rise', 'fade', 'slide', 'none']).catch('rise').default('rise'),
243244
});
244245

245246
export const githubParamsSchema = z.object({

types/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,9 @@ export interface BadgeParams {
142142
/** Duration of the radar scan line animation (e.g. '4s', '8s', '12s'). Defaults to '8s'. */
143143
speed: SpeedString;
144144

145+
/** Animation style for the isometric towers on load: 'rise' (default), 'fade', 'slide', or 'none'. */
146+
entrance?: 'rise' | 'fade' | 'slide' | 'none';
147+
145148
/** Tower height scaling algorithm. 'linear' scales proportionally; 'log' uses logarithmic scale for high contributors. Defaults to 'linear'. */
146149
scale: Scale;
147150

0 commit comments

Comments
 (0)