| 1 | <template id="nyt-chart-template"> |
| 2 | <div data-composition-id="nyt-chart" data-width="1920" data-height="1080" data-duration="15"> |
| 3 | <!-- Google Fonts Import --> |
| 4 | <link rel="preconnect" href="https://fonts.googleapis.com" /> |
| 5 | <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> |
| 6 | <link |
| 7 | href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:wght@400;700&family=Libre+Franklin:wght@300;400;600&display=block" |
| 8 | rel="stylesheet" |
| 9 | /> |
| 10 | |
| 11 | <div class="chart-container"> |
| 12 | <!-- Header Section --> |
| 13 | <div class="header"> |
| 14 | <h1 class="headline">Monthly Revenue vs. Conversion Rate</h1> |
| 15 | <p class="subtitle">Jan–Jun 2024, in thousands</p> |
| 16 | <div class="key"> |
| 17 | <div class="key-item"> |
| 18 | <div class="key-box revenue-box"></div> |
| 19 | <span class="key-text">Revenue</span> |
| 20 | </div> |
| 21 | <div class="key-item"> |
| 22 | <div class="key-line conversion-line"></div> |
| 23 | <span class="key-text">Conversion Rate</span> |
| 24 | </div> |
| 25 | </div> |
| 26 | </div> |
| 27 | |
| 28 | <!-- SVG Chart Area --> |
| 29 | <svg class="chart-svg" viewBox="0 0 1600 700" preserveAspectRatio="xMidYMid meet"> |
| 30 | <!-- Gridlines Group --> |
| 31 | <g class="gridlines"></g> |
| 32 | |
| 33 | <!-- X-Axis Labels --> |
| 34 | <g class="x-axis-labels"></g> |
| 35 | |
| 36 | <!-- Bars Group (Revenue) --> |
| 37 | <g class="bars-group"></g> |
| 38 | |
| 39 | <!-- Bar Labels Group --> |
| 40 | <g class="bar-labels-group"></g> |
| 41 | |
| 42 | <!-- Line Group (Conversion Rate) --> |
| 43 | <g class="line-group"> |
| 44 | <path class="conversion-path" fill="none" stroke="#326FA8" stroke-width="1.5" /> |
| 45 | <circle class="line-dot" r="5" fill="#326FA8" style="opacity: 0" /> |
| 46 | </g> |
| 47 | |
| 48 | <!-- Line Labels Group --> |
| 49 | <g class="line-labels-group"></g> |
| 50 | |
| 51 | <!-- Baseline --> |
| 52 | <line x1="100" y1="600" x2="1500" y2="600" stroke="black" stroke-width="1" /> |
| 53 | </svg> |
| 54 | |
| 55 | <!-- Source Line --> |
| 56 | <div class="source">Source: Internal analytics</div> |
| 57 | </div> |
| 58 | |
| 59 | <style> |
| 60 | [data-composition-id="nyt-chart"] { |
| 61 | width: 1920px; |
| 62 | height: 1080px; |
| 63 | background-color: #faf9f6; |
| 64 | font-family: "Libre Franklin", sans-serif; |
| 65 | color: #333; |
| 66 | display: flex; |
| 67 | justify-content: center; |
| 68 | align-items: center; |
| 69 | overflow: hidden; |
| 70 | } |
| 71 | |
| 72 | [data-composition-id="nyt-chart"] .chart-container { |
| 73 | width: 1600px; |
| 74 | height: 900px; |
| 75 | display: flex; |
| 76 | flex-direction: column; |
| 77 | position: relative; |
| 78 | transform: scale(1.15); |
| 79 | transform-origin: center center; |
| 80 | } |
| 81 | |
| 82 | [data-composition-id="nyt-chart"] .header { |
| 83 | margin-bottom: 40px; |
| 84 | text-align: left; |
| 85 | } |
| 86 | |
| 87 | [data-composition-id="nyt-chart"] .headline { |
| 88 | font-family: "Libre Baskerville", serif; |
| 89 | font-size: 42px; |
| 90 | margin: 0; |
| 91 | padding: 0; |
| 92 | clip-path: inset(0 100% 0 0); |
| 93 | white-space: nowrap; |
| 94 | } |
| 95 | |
| 96 | [data-composition-id="nyt-chart"] .subtitle { |
| 97 | font-size: 16px; |
| 98 | color: #d4d4d4; |
| 99 | margin: 8px 0 16px 0; |
| 100 | clip-path: inset(0 100% 0 0); |
| 101 | white-space: nowrap; |
| 102 | } |
| 103 | |
| 104 | [data-composition-id="nyt-chart"] .key { |
| 105 | display: flex; |
| 106 | gap: 24px; |
| 107 | opacity: 0; |
| 108 | } |
| 109 | |
| 110 | [data-composition-id="nyt-chart"] .key-item { |
| 111 | display: flex; |
| 112 | align-items: center; |
| 113 | gap: 8px; |
| 114 | } |
| 115 | |
| 116 | [data-composition-id="nyt-chart"] .key-box { |
| 117 | width: 10px; |
| 118 | height: 10px; |
| 119 | background-color: #5c5c5c; |
| 120 | } |
| 121 | |
| 122 | [data-composition-id="nyt-chart"] .key-line { |
| 123 | width: 16px; |
| 124 | height: 1.5px; |
| 125 | background-color: #326fa8; |
| 126 | } |
| 127 | |
| 128 | [data-composition-id="nyt-chart"] .key-text { |
| 129 | font-size: 13px; |
| 130 | color: #666; |
| 131 | } |
| 132 | |
| 133 | [data-composition-id="nyt-chart"] .chart-svg { |
| 134 | width: 100%; |
| 135 | height: 600px; |
| 136 | overflow: visible; |
| 137 | } |
| 138 | |
| 139 | [data-composition-id="nyt-chart"] .source { |
| 140 | position: absolute; |
| 141 | bottom: 0; |
| 142 | left: 0; |
| 143 | font-size: 11px; |
| 144 | color: #999; |
| 145 | opacity: 0; |
| 146 | } |
| 147 | |
| 148 | [data-composition-id="nyt-chart"] .grid-line { |
| 149 | stroke: #e8e8e8; |
| 150 | stroke-width: 0.5px; |
| 151 | opacity: 0; |
| 152 | } |
| 153 | |
| 154 | [data-composition-id="nyt-chart"] .axis-label { |
| 155 | font-size: 14px; |
| 156 | fill: #666; |
| 157 | text-anchor: middle; |
| 158 | } |
| 159 | |
| 160 | [data-composition-id="nyt-chart"] .bar { |
| 161 | fill: #5c5c5c; |
| 162 | } |
| 163 | |
| 164 | [data-composition-id="nyt-chart"] .bar-label { |
| 165 | font-size: 14px; |
| 166 | fill: #333; |
| 167 | text-anchor: middle; |
| 168 | opacity: 0; |
| 169 | } |
| 170 | |
| 171 | [data-composition-id="nyt-chart"] .line-label { |
| 172 | font-size: 14px; |
| 173 | fill: #326fa8; |
| 174 | font-weight: 600; |
| 175 | text-anchor: middle; |
| 176 | opacity: 0; |
| 177 | } |
| 178 | </style> |
| 179 | |
| 180 | <script> |
| 181 | (function initChart() { |
| 182 | // Wait for DOM — the runtime may not have cloned the template content yet |
| 183 | const svg = document.querySelector(`[data-composition-id="nyt-chart"] .chart-svg`); |
| 184 | if (!svg) { |
| 185 | setTimeout(initChart, 50); |
| 186 | return; |
| 187 | } |
| 188 | const tl = gsap.timeline({ paused: true }); |
| 189 | |
| 190 | // Data |
| 191 | const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]; |
| 192 | const revenueData = [8, 12, 15, 11, 18, 22]; // in thousands |
| 193 | const conversionData = [2.1, 2.8, 3.2, 2.9, 3.8, 4.2]; // in percentage |
| 194 | |
| 195 | // Chart Dimensions |
| 196 | const chartWidth = 1400; |
| 197 | const chartHeight = 500; |
| 198 | const startX = 100; |
| 199 | const startY = 600; |
| 200 | const barWidth = 80; |
| 201 | const spacing = (chartWidth - barWidth * months.length) / (months.length + 1); |
| 202 | |
| 203 | // Scales |
| 204 | const maxRevenue = 25; |
| 205 | const maxConversion = 5; |
| 206 | |
| 207 | const getBarY = (val) => startY - (val / maxRevenue) * chartHeight; |
| 208 | const getLineY = (val) => startY - (val / maxConversion) * chartHeight; |
| 209 | const getX = (i) => startX + spacing + i * (barWidth + spacing) + barWidth / 2; |
| 210 | |
| 211 | // 1. Setup Elements (svg already found above) |
| 212 | const gridGroup = svg.querySelector(".gridlines"); |
| 213 | const xAxisGroup = svg.querySelector(".x-axis-labels"); |
| 214 | const barsGroup = svg.querySelector(".bars-group"); |
| 215 | const barLabelsGroup = svg.querySelector(".bar-labels-group"); |
| 216 | const lineLabelsGroup = svg.querySelector(".line-labels-group"); |
| 217 | const conversionPath = svg.querySelector(".conversion-path"); |
| 218 | const lineDot = svg.querySelector(".line-dot"); |
| 219 | |
| 220 | // Create Gridlines (5 lines) |
| 221 | for (let i = 1; i <= 5; i++) { |
| 222 | const y = startY - i * (chartHeight / 5); |
| 223 | const line = document.createElementNS("http://www.w3.org/2000/svg", "line"); |
| 224 | line.setAttribute("x1", startX); |
| 225 | line.setAttribute("y1", y); |
| 226 | line.setAttribute("x2", startX + chartWidth); |
| 227 | line.setAttribute("y2", y); |
| 228 | line.setAttribute("class", "grid-line"); |
| 229 | gridGroup.appendChild(line); |
| 230 | } |
| 231 | |
| 232 | // Create X-Axis Labels & Bars & Bar Labels |
| 233 | const linePoints = []; |
| 234 | months.forEach((month, i) => { |
| 235 | const x = getX(i); |
| 236 | |
| 237 | // X-Axis Label |
| 238 | const label = document.createElementNS("http://www.w3.org/2000/svg", "text"); |
| 239 | label.setAttribute("x", x); |
| 240 | label.setAttribute("y", startY + 30); |
| 241 | label.setAttribute("class", "axis-label"); |
| 242 | label.textContent = month; |
| 243 | xAxisGroup.appendChild(label); |
| 244 | |
| 245 | // Bar |
| 246 | const barHeight = (revenueData[i] / maxRevenue) * chartHeight; |
| 247 | const bar = document.createElementNS("http://www.w3.org/2000/svg", "rect"); |
| 248 | bar.setAttribute("x", x - barWidth / 2); |
| 249 | bar.setAttribute("y", startY); |
| 250 | bar.setAttribute("width", barWidth); |
| 251 | bar.setAttribute("height", 0); |
| 252 | bar.setAttribute("class", "bar"); |
| 253 | barsGroup.appendChild(bar); |
| 254 | |
| 255 | // Bar Label |
| 256 | const barLabel = document.createElementNS("http://www.w3.org/2000/svg", "text"); |
| 257 | barLabel.setAttribute("x", x); |
| 258 | barLabel.setAttribute("y", getBarY(revenueData[i]) - 10); |
| 259 | barLabel.setAttribute("class", "bar-label"); |
| 260 | barLabel.textContent = `$${revenueData[i]}K`; |
| 261 | barLabelsGroup.appendChild(barLabel); |
| 262 | |
| 263 | // Line Point |
| 264 | linePoints.push(`${x},${getLineY(conversionData[i])}`); |
| 265 | |
| 266 | // Line Label |
| 267 | const lineLabel = document.createElementNS("http://www.w3.org/2000/svg", "text"); |
| 268 | lineLabel.setAttribute("x", x); |
| 269 | lineLabel.setAttribute("y", getLineY(conversionData[i]) - 15); |
| 270 | lineLabel.setAttribute("class", "line-label"); |
| 271 | lineLabel.textContent = `${conversionData[i]}%`; |
| 272 | lineLabelsGroup.appendChild(lineLabel); |
| 273 | }); |
| 274 | |
| 275 | // Set Path Data |
| 276 | conversionPath.setAttribute("d", `M ${linePoints.join(" L ")}`); |
| 277 | const pathLength = conversionPath.getTotalLength(); |
| 278 | conversionPath.style.strokeDasharray = pathLength; |
| 279 | conversionPath.style.strokeDashoffset = pathLength; |
| 280 | |
| 281 | // --- ANIMATION TIMELINE --- |
| 282 | |
| 283 | // 2. Headline & Subtitle (Draw on left-to-right) |
| 284 | tl.to( |
| 285 | `[data-composition-id="nyt-chart"] .headline`, |
| 286 | { |
| 287 | clipPath: "inset(0 0% 0 0)", |
| 288 | duration: 1.2, |
| 289 | ease: "power1.inOut", |
| 290 | }, |
| 291 | 0, |
| 292 | ); |
| 293 | |
| 294 | tl.to( |
| 295 | `[data-composition-id="nyt-chart"] .subtitle`, |
| 296 | { |
| 297 | clipPath: "inset(0 0% 0 0)", |
| 298 | duration: 1.2, |
| 299 | ease: "power1.inOut", |
| 300 | }, |
| 301 | 0.2, |
| 302 | ); |
| 303 | |
| 304 | // 3. Inline Key |
| 305 | tl.to( |
| 306 | `[data-composition-id="nyt-chart"] .key`, |
| 307 | { |
| 308 | opacity: 1, |
| 309 | duration: 0.4, |
| 310 | ease: "power2.out", |
| 311 | }, |
| 312 | 1.0, |
| 313 | ); |
| 314 | |
| 315 | // 4. Gridlines (Stagger bottom to top) |
| 316 | tl.to( |
| 317 | `[data-composition-id="nyt-chart"] .grid-line`, |
| 318 | { |
| 319 | opacity: 1, |
| 320 | duration: 0.5, |
| 321 | stagger: 0.25, |
| 322 | ease: "power2.out", |
| 323 | }, |
| 324 | 0.5, |
| 325 | ); |
| 326 | |
| 327 | // 5. Bars (Revenue) |
| 328 | const bars = barsGroup.querySelectorAll(".bar"); |
| 329 | const barLabels = barLabelsGroup.querySelectorAll(".bar-label"); |
| 330 | |
| 331 | bars.forEach((bar, i) => { |
| 332 | const barHeight = (revenueData[i] / maxRevenue) * chartHeight; |
| 333 | const startTime = 1.5 + i * 0.5; // 0.8s duration, 0.3s overlap (0.8 - 0.3 = 0.5 step) |
| 334 | |
| 335 | tl.to( |
| 336 | bar, |
| 337 | { |
| 338 | attr: { |
| 339 | height: barHeight, |
| 340 | y: startY - barHeight, |
| 341 | }, |
| 342 | duration: 0.8, |
| 343 | ease: "power2.out", |
| 344 | }, |
| 345 | startTime, |
| 346 | ); |
| 347 | |
| 348 | // 6. Bar Labels |
| 349 | tl.to( |
| 350 | barLabels[i], |
| 351 | { |
| 352 | opacity: 1, |
| 353 | duration: 0.3, |
| 354 | ease: "power2.out", |
| 355 | }, |
| 356 | startTime + 0.8, |
| 357 | ); |
| 358 | }); |
| 359 | |
| 360 | // 7. Line (Conversion Rate) |
| 361 | // Starts 0.5s after the last bar begins |
| 362 | const lastBarStartTime = 1.5 + (months.length - 1) * 0.5; |
| 363 | const lineStartTime = lastBarStartTime + 0.5; |
| 364 | |
| 365 | tl.to( |
| 366 | conversionPath, |
| 367 | { |
| 368 | strokeDashoffset: 0, |
| 369 | duration: 3, |
| 370 | ease: "none", |
| 371 | }, |
| 372 | lineStartTime, |
| 373 | ); |
| 374 | |
| 375 | // 8. Line Dot & 9. Line Labels |
| 376 | // We need to sync the dot and labels with the path drawing |
| 377 | tl.set( |
| 378 | lineDot, |
| 379 | { opacity: 1, attr: { cx: getX(0), cy: getLineY(conversionData[0]) } }, |
| 380 | lineStartTime, |
| 381 | ); |
| 382 | |
| 383 | const lineLabels = lineLabelsGroup.querySelectorAll(".line-label"); |
| 384 | months.forEach((_, i) => { |
| 385 | if (i > 0) { |
| 386 | const x = getX(i); |
| 387 | const y = getLineY(conversionData[i]); |
| 388 | tl.to( |
| 389 | lineDot, |
| 390 | { |
| 391 | attr: { |
| 392 | cx: x, |
| 393 | cy: y, |
| 394 | }, |
| 395 | duration: 3 / (months.length - 1), |
| 396 | ease: "none", |
| 397 | }, |
| 398 | lineStartTime + ((i - 1) / (months.length - 1)) * 3, |
| 399 | ); |
| 400 | } |
| 401 | |
| 402 | const labelTime = lineStartTime + (i / (months.length - 1)) * 3 + 0.2; |
| 403 | |
| 404 | // Set initial text to 0.0% |
| 405 | lineLabels[i].textContent = "0.0%"; |
| 406 | |
| 407 | // Fade in |
| 408 | tl.to( |
| 409 | lineLabels[i], |
| 410 | { |
| 411 | opacity: 1, |
| 412 | duration: 0.1, |
| 413 | ease: "power2.out", |
| 414 | }, |
| 415 | labelTime, |
| 416 | ); |
| 417 | |
| 418 | // Animate value |
| 419 | const dummy = { val: 0 }; |
| 420 | tl.to( |
| 421 | dummy, |
| 422 | { |
| 423 | val: conversionData[i], |
| 424 | duration: 0.8, |
| 425 | ease: "power2.out", |
| 426 | onUpdate: () => { |
| 427 | lineLabels[i].textContent = dummy.val.toFixed(1) + "%"; |
| 428 | }, |
| 429 | }, |
| 430 | labelTime, |
| 431 | ); |
| 432 | }); |
| 433 | |
| 434 | // 11. Source Line |
| 435 | tl.to( |
| 436 | `[data-composition-id="nyt-chart"] .source`, |
| 437 | { |
| 438 | opacity: 1, |
| 439 | duration: 0.5, |
| 440 | ease: "power2.out", |
| 441 | }, |
| 442 | ">+0.5", |
| 443 | ); |
| 444 | |
| 445 | // Register timeline |
| 446 | window.__timelines = window.__timelines || {}; |
| 447 | window.__timelines["nyt-chart"] = tl; |
| 448 | })(); |
| 449 | </script> |
| 450 | </div> |
| 451 | </template> |
| 452 |