Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
58d1fbe
adding WIP stats slice
acatcalledfrank Feb 19, 2026
c02f48a
adding type detection
acatcalledfrank Feb 19, 2026
313f2f2
removing pre/suffix
acatcalledfrank Feb 23, 2026
576133e
cleaning up
acatcalledfrank Feb 23, 2026
c3e44dc
updating delay
acatcalledfrank Feb 25, 2026
299f4ea
making delay consistent; adding underline
acatcalledfrank Feb 25, 2026
f04fcd3
fix: formatting styles
acatcalledfrank Feb 25, 2026
fce8150
fixing delay
acatcalledfrank Feb 25, 2026
d91e89f
props and behaviour tweaks from CMS integration
acatcalledfrank Feb 27, 2026
e9270e9
Merge branch 'master' into ENG-4289_stats-slice
AndyEPhipps Mar 2, 2026
b0e577c
adding accessible stats
acatcalledfrank Mar 2, 2026
b259ba7
adding animation options
acatcalledfrank Mar 2, 2026
1d44aa7
Merge branch 'master' into ENG-4289_stats-slice
AndyEPhipps Mar 2, 2026
1eb2342
adding "none" animation option
acatcalledfrank Mar 3, 2026
2593359
multiples updates from design feedbnack
acatcalledfrank Mar 4, 2026
e19c6e7
Merge branch 'master' into ENG-4289_stats-slice
acatcalledfrank Mar 4, 2026
a78e867
adding tests
acatcalledfrank Mar 4, 2026
2d30b9e
updating demo
acatcalledfrank Mar 4, 2026
2b025d9
centre-aligning wrapper
acatcalledfrank Mar 4, 2026
aa7637d
update body prop type
acatcalledfrank Mar 4, 2026
a5a1ad8
Update StatsSlice.test.js.snap
acatcalledfrank Mar 4, 2026
3c342a8
making characterStagger a prop
acatcalledfrank Mar 4, 2026
3be8c03
splitting out code into separate files
acatcalledfrank Mar 5, 2026
1aaa045
Merge branch 'master' into ENG-4289_stats-slice
acatcalledfrank Mar 5, 2026
ae882dc
Merge branch 'master' into ENG-4289_stats-slice
AndyEPhipps Mar 5, 2026
5124d48
Merge branch 'master' into ENG-4289_stats-slice
AndyEPhipps Mar 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions src/components/Molecules/StatsSlice/StatsSlice.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { isEmpty } from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import StatNodeComponent from './_StatNode';
import {
InnerWrapper,
OuterWrapper
} from './StatsSlice.style';

// MARK: stats slice
const StatsSlice = ({
nodes,
pageBackgroundColour = 'teal_dark',
paddingTop = '2rem',
paddingBottom = '2rem',
ease = 'cubic',
characterStagger = 80,
stringCharacterDuration = '1600ms',
numberCharacterDuration = '2000ms'
}) => {
// if no nodes have been provided, don't render anything
if (isEmpty(nodes)) {
return null;
}

return (
<OuterWrapper
backgroundColour={pageBackgroundColour}
paddingTop={paddingTop}
paddingBottom={paddingBottom}
>
<InnerWrapper>
{nodes?.map((node, index) => {
const key = index + String(node.title);
const nodeComponent = (
<StatNodeComponent
key={key}
stat={String(node.stat)}
characterStagger={characterStagger}
stringCharacterDuration={stringCharacterDuration}
numberCharacterDuration={numberCharacterDuration}
ease={ease}
body={node.body}
/>
);
return nodeComponent;
})}
</InnerWrapper>
</OuterWrapper>
);
};
StatsSlice.propTypes = {
nodes: PropTypes.arrayOf(
PropTypes.shape({
title: PropTypes.string.isRequired,
stat: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
body: PropTypes.node
})
),
pageBackgroundColour: PropTypes.string,
paddingTop: PropTypes.string,
paddingBottom: PropTypes.string,
ease: PropTypes.string,
characterStagger: PropTypes.number,
stringCharacterDuration: PropTypes.string,
numberCharacterDuration: PropTypes.string
};

export default StatsSlice;
60 changes: 60 additions & 0 deletions src/components/Molecules/StatsSlice/StatsSlice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Stats Slice

### Empty stats

```js
<div style={{ paddingTop: "100vh" }}>
<StatsSlice />
</div>
```

### Basic stats

```js
<StatsSlice nodes={[{
title: "123",
stat: "123,456,789.02",
body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam."
}]} />
```

### Multiple stats

```js
<StatsSlice nodes={[{
title: "123",
stat: "£1234.56",
body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi."
},
{
title: "456",
stat: "over 456 lives were saved",
body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam."
},
{
title: "789",
stat: "456,789,123 people",
body: "Ut enim ad minima veniam, quis nostrum exercitationem."
},
{
title: "asd",
stat: "456 people",
body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam."
},
{
title: "xcv",
stat: "$800bn raised",
body: "Ut enim ad minima veniam, quis nostrum exercitationem."
}]} />
```


### No animation

```js
<StatsSlice ease="none" nodes={[{
title: "123",
stat: "£1234.56",
body: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi."
}]} />
```
196 changes: 196 additions & 0 deletions src/components/Molecules/StatsSlice/StatsSlice.style.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import styled, { keyframes } from 'styled-components';
import Text from '../../Atoms/Text/Text';

export const OuterWrapper = styled.div`
display: flex;
justify-content: center;
padding: ${({ paddingTop, paddingBottom }) => `${paddingTop} 1rem ${paddingBottom}`};
background: ${({ theme, backgroundColour }) => theme.color(backgroundColour)};

@media ${({ theme }) => theme.breakpoints2026('M')} {
padding-left: 2rem;
padding-right: 2rem;
}
`;

export const InnerWrapper = styled.div`
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 1rem;
max-width: 1152px;

@media ${({ theme }) => theme.breakpoints2026('L')} {
flex-direction: row;
gap: 2rem;
}
`;

export const StatContainer = styled.div`
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1rem;
background: #ffffff;
padding: 1rem;
flex: 1 1 0px;
min-width: 30%;
border-radius: 1rem;
box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 1rem;

@media ${({ theme }) => theme.breakpoints2026('M')} {
padding: 2rem;
}
`;

export const ValueContainer = styled.div`
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding-bottom: 0.5rem;
`;

const clipIn = keyframes`
0% {
clip-path: inset(0px 100% 0px 0px);
}
100% {
clip-path: inset(0px 0% 0px 0px);
}
`;

export const ValueUnderline = styled.img`
position: absolute;
width: 100%;
height: 4px;
left: 0;
bottom: 0px;
animation-name: ${clipIn};
animation-duration: 0.7s;
animation-timing-function: cubic-bezier(0.219, -0.011, 0.164, 0.987);
animation-delay: ${({ delay }) => delay}ms;
animation-fill-mode: both;

// ease = none and reduced motion both disable the transition

@media (prefers-reduced-motion: reduce) {
animation-delay: 0;
animation-name: none;
}
&[data-ease="none"] {
animation-delay: 0;
animation-name: none;
}
`;

export const StatValue = styled.div`
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
text-transform: uppercase;
font-family: ${({ theme }) => theme.fontFamilies('Anton')};
font-size: ${({ theme }) => theme.fontSize('xl')};
line-height: 1;

@media ${({ theme }) => theme.breakpoints2026('M')} {
font-size: ${({ theme }) => theme.fontSize('xxl')};
}

@media ${({ theme }) => theme.breakpoints2026('L')} {
font-size: ${({ theme }) => theme.fontSize('xxl')};
}
`;

export const Word = styled.span`
display: flex;
`;

export const AnimatedCharacter = styled.div`
position: relative;
overflow: hidden;
// small amount of extra padding to avoid descending characters like "," from being cut off;
// we shouldn't need to worry about longer descenders like "g" as we're using caps
padding-bottom: 0.06rem;
`;

export const SpacingCharacter = styled.div`
visibility: hidden;
white-space: pre-wrap;
`;

export const AnimatedDigit = styled.div`
position: absolute;
top: 0;
left: 0;

transition-delay: ${({ delay }) => delay}ms;
transition-property: transform;
transition-duration: ${({ duration }) => duration};

// ease = none and reduced motion both disable the transition
@media (prefers-reduced-motion: reduce) {
transition-property: none;
transition-delay: 0;
}
&[data-ease="none"] {
transition-property: none;
transition-delay: 0;
}

// easing functions from https://easingwizard.com/
&[data-ease="cubic"] {
transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
&[data-ease="overshoot"] {
transition-timing-function: linear(
0,
0.329 8.8%,
0.59 18%,
0.787 27.7%,
0.863 32.8%,
0.926 38.2%,
0.968 43.1%,
1 48.3%,
1.022 53.7%,
1.034 59.6%,
1.035 69.8%,
1.006 90.7%,
1
);
}
&[data-ease="bounce"] {
transition-timing-function: linear(0, 0.384 15.4%, 0.833 35.8%, 1 44.7%, 0.919 51.5%, 0.9 54.7%, 0.894 58%, 0.911 63.8%, 1 77.4%, 0.985 84.4%, 1);
}
`;

export const AccessibleValue = styled.div`
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
z-index: 1;
opacity: 0;
text-transform: uppercase;
font-family: ${({ theme }) => theme.fontFamilies('Anton')};
font-size: ${({ theme }) => theme.fontSize('xl')};
line-height: 1;

@media ${({ theme }) => theme.breakpoints2026('M')} {
font-size: ${({ theme }) => theme.fontSize('xxl')};
}

@media ${({ theme }) => theme.breakpoints2026('L')} {
font-size: ${({ theme }) => theme.fontSize('xxl')};
}
`;

export const Body = styled(Text)`
font-size: ${({ theme }) => theme.fontSize('s')};

@media ${({ theme }) => theme.breakpoints2026('M')} {
font-size: ${({ theme }) => theme.fontSize('m')};
}
`;
38 changes: 38 additions & 0 deletions src/components/Molecules/StatsSlice/StatsSlice.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import 'jest-styled-components';
import React from 'react';
import renderWithTheme from '../../../../tests/hoc/shallowWithTheme';
import StatsSlice from './StatsSlice';
import './_test-utils';

it('Stats Slices with no nodes should not render', () => {
const statsEl = renderWithTheme(<StatsSlice />).toJSON();
expect(statsEl).toBeNull();
});

it('Stats Slices with a single node should render', () => {
const statsEl = renderWithTheme(<StatsSlice ease="none" nodes={[{
title: "123",
stat: "123,456,789.02",
body: "hello"
}]} />).toJSON();
expect(statsEl).toMatchSnapshot();
});

it('Stats Slices with multiple nodes should render', () => {
const statsEl = renderWithTheme(<StatsSlice nodes={[{
title: "123",
stat: "123,456,789.02",
body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam."
},
{
title: "456",
stat: "456,789,123.02",
body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam."
},
{
title: "789",
stat: "789,123,456.02",
body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam."
}]} />).toJSON();
expect(statsEl).toMatchSnapshot();
});
Loading