diff --git a/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/content.tsx b/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/content.tsx
index ccb951efd56d..98daf451100b 100644
--- a/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/content.tsx
+++ b/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/content.tsx
@@ -2,6 +2,7 @@ import { currentUserCan, getScriptData } from '@automattic/jetpack-script-data';
import { decodeEntities } from '@wordpress/html-entities';
import { __, sprintf } from '@wordpress/i18n';
import { Text } from '@wordpress/ui';
+import clsx from 'clsx';
import useSitePurchases from '../../../data/use-site-purchases';
import { getMyJetpackWindowInitialState } from '../../../data/utils/get-my-jetpack-window-state';
import { isHeaderControlsEnabled } from '../../header-controls/slot';
@@ -10,6 +11,8 @@ import ProductCardsSection from '../../product-cards-section';
import OverviewSections from '../../product-cards-section/overview-sections';
import SeoOptInCard from '../../seo-opt-in-card';
import { A4AUpsell } from './a4a-upsell';
+import { TypedText, useOverviewLoadAnimation } from './load-animation';
+import loadAnimationStyles from './load-animation/style.module.scss';
import styles from './styles.module.scss';
import type { ReactNode } from 'react';
@@ -29,6 +32,14 @@ export function OverviewContent( { beforeContent }: { beforeContent?: ReactNode
const name =
getMyJetpackWindowInitialState()?.userFirstName ||
decodeEntities( getScriptData()?.user?.current_user?.display_name ?? '' );
+ const { animate, onPlayed } = useOverviewLoadAnimation();
+ const welcome = name
+ ? sprintf(
+ /* translators: %s: The current user's first name, or their display name. */
+ __( 'Welcome to Jetpack, %s!', 'jetpack-my-jetpack' ),
+ name
+ )
+ : __( 'Welcome to Jetpack', 'jetpack-my-jetpack' );
return (
@@ -37,17 +48,11 @@ export function OverviewContent( { beforeContent }: { beforeContent?: ReactNode
<>
}>
- { name
- ? sprintf(
- /* translators: %s: The current user's first name, or their display name. */
- __( 'Welcome to Jetpack, %s!', 'jetpack-my-jetpack' ),
- name
- )
- : __( 'Welcome to Jetpack', 'jetpack-my-jetpack' ) }
+ { animate ? : welcome }
{ beforeContent }
-
+
>
) : (
diff --git a/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/load-animation/index.tsx b/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/load-animation/index.tsx
new file mode 100644
index 000000000000..d436e68b5437
--- /dev/null
+++ b/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/load-animation/index.tsx
@@ -0,0 +1,70 @@
+import { VisuallyHidden } from '@wordpress/ui';
+import { useCallback, useState } from 'react';
+import styles from './style.module.scss';
+import type { CSSProperties, FC } from 'react';
+
+let hasPlayed = false;
+
+/**
+ * Whether the Overview's load animation plays: until it has played once in a
+ * page load, unless the user prefers reduced motion.
+ *
+ * @return Whether to animate, and the callback that marks the animation played.
+ */
+export function useOverviewLoadAnimation(): { animate: boolean; onPlayed: () => void } {
+ const [ animate ] = useState(
+ () => ! hasPlayed && ! window.matchMedia?.( '(prefers-reduced-motion: reduce)' ).matches
+ );
+ // Marked once played, not on mount, so a mount that is replaced at once doesn't use it up.
+ const onPlayed = useCallback( () => {
+ hasPlayed = true;
+ }, [] );
+ return { animate, onPlayed };
+}
+
+/**
+ * Splits text into user-perceived characters, so an emoji types as one.
+ *
+ * @param text - The text.
+ * @return The characters.
+ */
+function splitCharacters( text: string ): string[] {
+ if ( typeof Intl !== 'undefined' && 'Segmenter' in Intl ) {
+ return Array.from( new Intl.Segmenter().segment( text ), ( { segment } ) => segment );
+ }
+ return Array.from( text );
+}
+
+/**
+ * Text that types itself out with a caret; assistive tech reads it whole.
+ *
+ * @param props - Component props.
+ * @param props.text - The text to type.
+ * @param props.onTyped - Called when the last character finishes animating.
+ * @return The rendered text.
+ */
+export const TypedText: FC< { text: string; onTyped?: () => void } > = ( { text, onTyped } ) => {
+ const characters = splitCharacters( text );
+ return (
+ <>
+
+ }>{ text }
+
+
+ { characters.map( ( character, index ) => (
+
+ { character }
+
+ ) ) }
+
+ >
+ );
+};
diff --git a/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/load-animation/style.module.scss b/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/load-animation/style.module.scss
new file mode 100644
index 000000000000..7dd999ea9b3b
--- /dev/null
+++ b/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/load-animation/style.module.scss
@@ -0,0 +1,77 @@
+// Copying takes the visible letters, not this copy as well.
+.typed-label {
+ user-select: none;
+}
+
+// Letters are laid out from the start and only revealed, so nothing shifts.
+.typed {
+ --typed-step: calc(1s / var(--typed-count));
+
+ > span {
+ position: relative;
+ animation: typed-letter 400ms ease-out backwards;
+ animation-delay: calc(var(--typed-index) * var(--typed-step));
+
+ // The caret sits on the newest letter's inline end, taking up no space.
+ &::after {
+ content: "";
+ position: absolute;
+ inset-block: 0.1em;
+ inset-inline-start: 100%;
+ border-inline-start: 2px solid currentColor;
+ opacity: 0;
+ animation: typed-caret calc(var(--typed-step) * 2) linear;
+ animation-delay: calc(var(--typed-index) * var(--typed-step));
+ }
+
+ &:last-child::after {
+ animation-duration: 1s;
+ animation-iteration-count: 2;
+ }
+ }
+}
+
+@keyframes typed-letter {
+
+ 0% {
+ visibility: hidden;
+ }
+
+ 0.01% {
+ visibility: visible;
+ color: var(--wpds-color-foreground-content-neutral-weak);
+ }
+}
+
+@keyframes typed-caret {
+
+ 0%,
+ 49.99% {
+ opacity: 1;
+ }
+
+ 50%,
+ 100% {
+ opacity: 0;
+ }
+}
+
+// Each section rises in turn, 70ms apart.
+.sections > * {
+ animation: section-rise 300ms ease-out backwards;
+ animation-delay: calc(var(--section-index, 0) * 70ms);
+
+ @for $i from 2 through 3 {
+ &:nth-child(#{$i}) {
+ --section-index: #{$i - 1};
+ }
+ }
+}
+
+@keyframes section-rise {
+
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+}
diff --git a/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/test/content.test.tsx b/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/test/content.test.tsx
index 1e00083af818..e8df8e2a044b 100644
--- a/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/test/content.test.tsx
+++ b/projects/packages/my-jetpack/_inc/components/my-jetpack-tab-panel/overview/test/content.test.tsx
@@ -1,5 +1,5 @@
import '@testing-library/jest-dom';
-import { render, screen } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
import { OverviewContent } from '../content';
jest.mock( '../../../../data/use-site-purchases', () => jest.fn() );
@@ -43,4 +43,71 @@ describe( 'OverviewContent', () => {
.compareDocumentPosition( screen.getByText( 'Notice' ) )
).toBe( Node.DOCUMENT_POSITION_FOLLOWING );
} );
+
+ describe( 'load animation', () => {
+ const heading = 'Welcome to Jetpack, Admin!';
+ let Content: typeof OverviewContent;
+ const setReducedMotion = ( matches: boolean ) =>
+ Object.defineProperty( window, 'matchMedia', {
+ configurable: true,
+ value: () => ( { matches } ),
+ } );
+ const originalMatchMedia = Object.getOwnPropertyDescriptor( window, 'matchMedia' );
+
+ beforeEach( async () => {
+ window.JetpackScriptData = {
+ user: { current_user: { display_name: 'Admin', capabilities: {} } },
+ } as typeof window.JetpackScriptData;
+ setReducedMotion( false );
+ // A fresh module registry resets the once-per-page-load flag; React must stay shared.
+ const react = jest.requireActual( 'react' );
+ await jest.isolateModulesAsync( async () => {
+ jest.doMock( 'react', () => react );
+ ( { OverviewContent: Content } = await import( '../content' ) );
+ } );
+ } );
+
+ afterEach( () => {
+ if ( originalMatchMedia ) {
+ Object.defineProperty( window, 'matchMedia', originalMatchMedia );
+ } else {
+ delete window.matchMedia;
+ }
+ } );
+
+ const typedText = () =>
+ screen.queryByText( ( _, element ) => element?.textContent === heading, {
+ selector: 'h2 [aria-hidden="true"]',
+ } );
+
+ it( 'types the heading for sighted users only, giving assistive tech the whole of it', () => {
+ render(
);
+
+ expect( screen.getByRole( 'heading', { level: 2, name: heading } ) ).toBeInTheDocument();
+ expect( typedText() ).toBeInTheDocument();
+ } );
+
+ it( 'shows the heading at once with reduced motion', () => {
+ setReducedMotion( true );
+ render(
);
+
+ expect( typedText() ).not.toBeInTheDocument();
+ expect( screen.getByRole( 'heading', { level: 2, name: heading } ) ).toBeInTheDocument();
+ } );
+
+ it.each( [
+ [ 'does not replay on returning to the tab', true, false ],
+ [ 'still plays when the Overview remounts before it has played', false, true ],
+ ] )( '%s', ( _, played, animates ) => {
+ const { unmount } = render(
);
+ if ( played ) {
+ // jsdom has no AnimationEvent, so React listens for the prefixed name.
+ fireEvent( screen.getByText( '!' ), new Event( 'webkitAnimationEnd', { bubbles: true } ) );
+ }
+ unmount();
+ render(
);
+
+ expect( Boolean( typedText() ) ).toBe( animates );
+ } );
+ } );
} );
diff --git a/projects/packages/my-jetpack/_inc/components/product-cards-section/overview-sections/index.tsx b/projects/packages/my-jetpack/_inc/components/product-cards-section/overview-sections/index.tsx
index 47ce20bc8c5f..6878ac32a203 100644
--- a/projects/packages/my-jetpack/_inc/components/product-cards-section/overview-sections/index.tsx
+++ b/projects/packages/my-jetpack/_inc/components/product-cards-section/overview-sections/index.tsx
@@ -1,6 +1,7 @@
import { __ } from '@wordpress/i18n';
import { dashboard, shield, trendingUp } from '@wordpress/icons';
import { Text } from '@wordpress/ui';
+import clsx from 'clsx';
import { PRODUCT_STATUSES } from '../../../constants';
import { PRODUCT_SLUGS } from '../../../data/constants';
import useProduct from '../../../data/products/use-product';
@@ -20,9 +21,11 @@ import type { FC } from 'react';
/**
* The Overview sections.
*
+ * @param {object} props - Component props.
+ * @param {string} props.className - Added once the sections have loaded.
* @return {import('react').JSX.Element} The sections.
*/
-const OverviewSections: FC = () => {
+const OverviewSections: FC< { className?: string } > = ( { className = '' } ) => {
const { filteredOwnedProducts: slugs, isLoading } = useFilteredProducts();
const { showFullJetpackStatsCard = false } = getMyJetpackWindowInitialState( 'myJetpackFlags' );
const { userIsAdmin = false, canUserViewStats = false } = getMyJetpackWindowInitialState();
@@ -90,7 +93,7 @@ const OverviewSections: FC = () => {
}
return (
-
+
{ isLoading ? (
<>
diff --git a/projects/packages/my-jetpack/changelog/update-my-jetpack-overview-v1-load-animation b/projects/packages/my-jetpack/changelog/update-my-jetpack-overview-v1-load-animation
new file mode 100644
index 000000000000..9e239385407f
--- /dev/null
+++ b/projects/packages/my-jetpack/changelog/update-my-jetpack-overview-v1-load-animation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: added
+
+Overview: Type out the welcome heading and fade the sections in on first load, behind the `my-jetpack-overview-v1` flag.