Files
twenty/packages/twenty-front/src/modules/onboarding/components/WelcomeOverlay/buildWelcomeHalftoneParticles.ts
T
Raphaël Bosi e38d587602 Welcome animation when a workspace is set up (#22622)
## Desktop


https://github.com/user-attachments/assets/87999ff5-9257-4fed-90e0-781322b1db98

## Mobile



https://github.com/user-attachments/assets/0d115cb6-7bca-4229-8fde-0f188e862b00


Greets the user the moment they finish onboarding: a halftone Twenty
mark (brand purple) assembles from scattered particles, shimmers, then
bursts outward to reveal the freshly loaded workspace behind it. Plays
once, ~3s, with a reduced-motion fallback.

`PageChangeEffect` sets a transient `isWelcomeAnimationVisibleState`
atom at the onboarding to workspace redirect seam (gated by
`shouldShowWelcomeAnimationOnNavigate`); `WelcomeOverlay`, mounted in
`WorkspaceAppProviders`, plays it and clears it. Transient by design, so
it never replays on reload.

## Why a canvas rendered on another thread

The overlay plays at the exact moment the workspace mounts behind it,
which is one of the heaviest main-thread stretches in the app (metadata,
providers, first data fetch). Anything animating on the main thread
competes with that work:

- framer-motion and plain main-thread `requestAnimationFrame` visibly
stuttered and froze during the mount, because long tasks starve rAF.
- CSS keyframes stay smooth (they run on the compositor), but can't
drive a ~2900-particle halftone with per-particle physics (staggered
assemble, moving shimmer band, directional burst).

So the halftone runs as a Canvas 2D particle system inside a Web Worker,
drawing to an `OffscreenCanvas` transferred from the main thread. The
whole render loop lives off the main thread, so app-mount jank can't
reach it. A main-thread path is kept as a fallback for browsers without
`OffscreenCanvas` / `transferControlToOffscreen`, and Safari workers
(which lack rAF) fall back to a fixed-interval loop. The renderer is a
pure, DOM-free module so it bundles cleanly into the worker; dot colors
come from CSS vars read on the main thread and passed in.

Also fires for every onboarding completion path and skips the billing
`PlanRequired` detour.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22622?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
2026-07-09 08:47:07 +00:00

81 lines
2.8 KiB
TypeScript

import { type WelcomeHalftoneParticle } from '@/onboarding/components/WelcomeOverlay/welcomeHalftoneParticle.type';
const TAU = Math.PI * 2;
const VIEWBOX_WIDTH = 297.037;
const VIEWBOX_CENTER_X = 148.5;
const VIEWBOX_CENTER_Y = 119.5;
const ASSEMBLE_STAGGER_SECONDS = 0.18;
const ASSEMBLE_JITTER_SECONDS = 0.12;
const MINIMUM_STROKE_WIDTH = 0.6;
const pseudoRandomFromSeed = (seed: number) => {
const noise = Math.sin(seed) * 43758.5453;
return noise - Math.floor(noise);
};
type WelcomeHalftoneParticleLayout = {
particles: WelcomeHalftoneParticle[];
halftoneSize: number;
maxDistanceToCenter: number;
};
export const buildWelcomeHalftoneParticles = (
dashes: readonly (readonly [number, number, number, number])[],
canvasWidth: number,
canvasHeight: number,
): WelcomeHalftoneParticleLayout => {
const halftoneSize = Math.max(canvasWidth * 1.05, canvasHeight * 1.3);
const viewboxToCanvasScale = halftoneSize / VIEWBOX_WIDTH;
const canvasCenterX = canvasWidth / 2;
const canvasCenterY = canvasHeight / 2;
const maxDistanceToCenter =
Math.hypot(VIEWBOX_CENTER_X, VIEWBOX_CENTER_Y) * viewboxToCanvasScale || 1;
const particles = dashes.map(
([dashStartX, dashEndX, dashY, dashStrokeWidth], dashIndex) => {
const targetX =
canvasCenterX +
((dashStartX + dashEndX) / 2 - VIEWBOX_CENTER_X) * viewboxToCanvasScale;
const targetY =
canvasCenterY + (dashY - VIEWBOX_CENTER_Y) * viewboxToCanvasScale;
const distanceToCenter = Math.hypot(
targetX - canvasCenterX,
targetY - canvasCenterY,
);
const scatterAngle = pseudoRandomFromSeed(dashIndex * 1.3) * TAU;
const scatterRadius =
halftoneSize * (0.35 + 0.5 * pseudoRandomFromSeed(dashIndex * 2.1));
return {
targetX,
targetY,
scatterStartX: canvasCenterX + Math.cos(scatterAngle) * scatterRadius,
scatterStartY: canvasCenterY + Math.sin(scatterAngle) * scatterRadius,
dashLength: Math.max(dashEndX - dashStartX, 0) * viewboxToCanvasScale,
strokeWidth: Math.max(
dashStrokeWidth * viewboxToCanvasScale,
MINIMUM_STROKE_WIDTH,
),
burstDirectionX:
distanceToCenter > 0
? (targetX - canvasCenterX) / distanceToCenter
: 0,
burstDirectionY:
distanceToCenter > 0
? (targetY - canvasCenterY) / distanceToCenter
: -1,
distanceToCenter,
assembleDelaySeconds:
ASSEMBLE_STAGGER_SECONDS * (distanceToCenter / maxDistanceToCenter) +
ASSEMBLE_JITTER_SECONDS * pseudoRandomFromSeed(dashIndex * 3.7),
driftPhase: pseudoRandomFromSeed(dashIndex * 5.1) * TAU,
positionAtLeaveStartX: 0,
positionAtLeaveStartY: 0,
opacityAtLeaveStart: 0,
};
},
);
return { particles, halftoneSize, maxDistanceToCenter };
};