Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions packages/gamut-styles/src/themes/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createTheme } from '@codecademy/variance';
import {
borderRadii,
containerQueries,
coreElevation,
corePalette,
elements,
fontFamily,
Expand Down Expand Up @@ -140,6 +141,7 @@ export const coreTheme = createTheme({
1: `1px solid ${colors['border-primary']}`,
2: `2px solid ${colors['border-primary']}`,
}))
.addScale('elevation', coreElevation)
.createScaleVariables('elements')
.addName('core')
.build();
Expand Down
2 changes: 2 additions & 0 deletions packages/gamut-styles/src/themes/percipio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createTheme } from '@codecademy/variance';

import {
fontWeightMediumTitle,
percipioElevation,
percipioFontFamily,
percipioPalette,
} from '../variables';
Expand Down Expand Up @@ -62,6 +63,7 @@ export const percipioTheme = createTheme({
},
},
})
.addScale('elevation', percipioElevation)
.addName('percipio')
.build();

Expand Down
74 changes: 74 additions & 0 deletions packages/gamut-styles/src/variables/elevation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
type ThemeColors = Record<string, string>;

/**
* The visual treatment of a surface at a single elevation state.
* These are type aliases rather than interfaces so they keep the implicit
* index signatures `addScale`'s constraint requires.
*/
export type ElevationStateStyles = {
shadow: string;
transform: string;
};

/**
* State keys are camelCase: variance's `LiteralPaths` splits token paths on
* `-`, so a hyphenated key like `hover-mirrored` would resolve to `never` and
* drop out of the theme type. `hoverMirrored` is for surfaces that cast their
* shadow to the opposite side (e.g. Card's `patternRight` shadow).
*/
export type ElevationScale = {
rest: ElevationStateStyles;
hover: ElevationStateStyles;
hoverMirrored: ElevationStateStyles;
};

export type ElevationState = keyof ElevationScale;

/**
* Elevation scales describe how a surface renders shadow and lift at rest and
* on hover. Each theme provides its own scale via
* `.addScale('elevation', ...)`, which passes in that theme's `colors` — so
* `colors['shadow-primary']` resolves per theme and per color mode without
* this file ever importing a theme (which would be a circular dependency).
*
* The shared return type guarantees every theme emits the same elevation
* tokens, so lookups like `theme.elevation['hover-shadow']` are safe under
* any theme.
*/
export type ElevationScaleFactory = (theme: {
colors: ThemeColors;
}) => ElevationScale;

export const coreElevation: ElevationScaleFactory = ({ colors }) => {
const shadowPrimary = colors['shadow-primary'];
const offset = 8;
const lift = 4;
const shadow = (x: number) => `${x}px ${offset}px 0 0 ${shadowPrimary}`;

return {
rest: { shadow: `0 0 0 0 ${shadowPrimary}`, transform: 'none' },
hover: {
shadow: shadow(-offset),
transform: `translate(${lift}px, -${lift}px)`,
},
hoverMirrored: {
shadow: shadow(offset),
transform: `translate(-${lift}px, -${lift}px)`,
},
};
};

export const percipioElevation: ElevationScaleFactory = ({ colors }) => {
const shadowPrimary = colors['shadow-primary'];
const shadowSecondary = colors['shadow-secondary'];
const hover = `0 1px 4px 0 ${shadowPrimary}, 0 2px 11px 0 ${shadowSecondary}`;

return {
rest: {
shadow: `0 1px 4px 0 ${shadowPrimary}, 0 2px 7px 0 ${shadowPrimary}`,
transform: 'none',
},
hover: { shadow: hover, transform: 'none' },
hoverMirrored: { shadow: hover, transform: 'none' },
};
};
1 change: 1 addition & 0 deletions packages/gamut-styles/src/variables/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './borderRadii';
export * from './colors';
export * from './elements';
export * from './elevation';
export * from './responsive';
export * from './spacing';
export * from './timing';
Expand Down
7 changes: 2 additions & 5 deletions packages/gamut/src/Card/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { borderRadii, Colors } from '@codecademy/gamut-styles';
import * as React from 'react';

import { DynamicCardWrapper, MotionBox, StaticCardWrapper } from './elements';
import { hoverShadowLeft, hoverShadowRight, patternFadeInOut } from './styles';
import { patternFadeInOut, useCardElevation } from './styles';
import { CardProps } from './types';

type BorderRadiusToken = keyof typeof borderRadii;
Expand All @@ -30,10 +30,7 @@ export const Card: React.FC<CardProps> = ({
const hasPattern = shadow === 'patternLeft' || shadow === 'patternRight';
const isOutline = shadow === 'outline';

const setHoverShadow =
shadow === 'patternRight'
? hoverShadowRight(resolvedBorderRadius)
: hoverShadowLeft(resolvedBorderRadius);
const setHoverShadow = useCardElevation(shadow, resolvedBorderRadius);

const initialVariant = isOutline ? 'initialOutline' : 'initial';
const animateVariant = isOutline ? 'animateOutline' : 'animate';
Expand Down
124 changes: 70 additions & 54 deletions packages/gamut/src/Card/styles.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,39 @@
import { theme, timingValues, variant } from '@codecademy/gamut-styles';
import {
ElevationState,
theme,
timingValues,
variant,
} from '@codecademy/gamut-styles';
import { StyleProps } from '@codecademy/variance';
import { Theme, useTheme } from '@emotion/react';

/**
* The theme's elevation scale is flattened to dashcase tokens (`rest-shadow`,
* `hoverMirrored-transform`, ...), so per-state groups don't exist on the
* theme at runtime. This regroups one state's tokens into a spreadable style
* object, mapping the `shadow` token onto the `boxShadow` property
* framer-motion animates.
*/
const getElevationStyles = (
elevation: Theme['elevation'],
state: ElevationState
) => ({
boxShadow: elevation[`${state}-shadow`],
transform: elevation[`${state}-transform`],
});

const SHADOW_OFFSET = 8;
const SHADOW_OFFSET_INITIAL = 6;
const TRANSFORM_OFFSET = 4;

const REST_TRANSITION = {
duration: timingValues.fast / 1000,
ease: 'easeOut',
} as const;

const HOVER_TRANSITION = {
duration: timingValues.fast / 1000,
ease: 'easeIn',
} as const;

export const cardVariants = variant({
defaultVariant: 'default',
Expand Down Expand Up @@ -61,59 +92,44 @@ export const patternFadeInOut = {
},
};

export const hoverShadowLeft = (borderRadius?: string) => ({
initial: {
boxShadow: `0px 0px 0 ${theme.colors['shadow-primary']}`,
borderRadius,
transition: {
duration: timingValues.fast / 1000,
ease: 'easeOut',
/**
* Motion variants for a Card's hover elevation, read from the active theme's
* `elevation` scale so each theme controls its own shadow and lift.
* `patternRight` cards cast their shadow on the opposite side, so they use the
* `hoverMirrored` tokens.
*/
export const useCardElevation = (
shadow: StyleProps<typeof shadowVariants>['shadow'],
borderRadius?: string
) => {
const { elevation } = useTheme();
const hoverState: ElevationState =
shadow === 'patternRight' ? 'hoverMirrored' : 'hover';

return {
initial: {
...getElevationStyles(elevation, 'rest'),
borderRadius,
transition: REST_TRANSITION,
},
},
initialOutline: {
boxShadow: `-${SHADOW_OFFSET_INITIAL}px ${SHADOW_OFFSET_INITIAL}px 0 0px ${theme.colors['background-current']}, -${SHADOW_OFFSET_INITIAL}px ${SHADOW_OFFSET_INITIAL}px 0 1px ${theme.colors['border-primary']}`,
borderRadius,
transition: {
duration: timingValues.fast / 1000,
ease: 'easeOut',
// outline variants keep their bespoke two-layer shadow but share the
// elevation scale's transforms
initialOutline: {
...getElevationStyles(elevation, 'rest'),
boxShadow: `-${SHADOW_OFFSET_INITIAL}px ${SHADOW_OFFSET_INITIAL}px 0 0px ${theme.colors['background-current']}, -${SHADOW_OFFSET_INITIAL}px ${SHADOW_OFFSET_INITIAL}px 0 1px ${theme.colors['border-primary']}`,
borderRadius,
transition: REST_TRANSITION,
},
},
animate: {
transform: `translate(${TRANSFORM_OFFSET}px, -${TRANSFORM_OFFSET}px)`,
boxShadow: `-${SHADOW_OFFSET}px ${SHADOW_OFFSET}px 0 ${theme.colors['shadow-primary']}`,
borderRadius,
transition: {
duration: timingValues.fast / 1000,
ease: 'easeIn',
animate: {
...getElevationStyles(elevation, hoverState),
borderRadius,
transition: HOVER_TRANSITION,
},
},
animateOutline: {
transform: `translate(${TRANSFORM_OFFSET}px, -${TRANSFORM_OFFSET}px)`,
boxShadow: `-${SHADOW_OFFSET}px ${SHADOW_OFFSET}px 0 0px ${theme.colors['shadow-primary']}, -${SHADOW_OFFSET}px ${SHADOW_OFFSET}px 0 1px ${theme.colors['shadow-primary']}`,
borderRadius,
transition: {
duration: timingValues.fast / 1000,
ease: 'easeIn',
},
},
});

export const hoverShadowRight = (borderRadius?: string) => ({
initial: {
boxShadow: `0px 0px 0 ${theme.colors['shadow-primary']}`,
borderRadius,
transition: {
duration: timingValues.fast / 1000,
ease: 'easeOut',
animateOutline: {
...getElevationStyles(elevation, 'hover'),
boxShadow: `-${SHADOW_OFFSET}px ${SHADOW_OFFSET}px 0 0px ${theme.colors['shadow-primary']}, -${SHADOW_OFFSET}px ${SHADOW_OFFSET}px 0 1px ${theme.colors['shadow-primary']}`,
borderRadius,
transition: HOVER_TRANSITION,
},
},
animate: {
transform: `translate(-${TRANSFORM_OFFSET}px, -${TRANSFORM_OFFSET}px)`,
boxShadow: `${SHADOW_OFFSET}px ${SHADOW_OFFSET}px 0 ${theme.colors['shadow-primary']}`,
borderRadius,
transition: {
duration: timingValues.fast / 1000,
ease: 'easeIn',
},
},
});
};
};
10 changes: 10 additions & 0 deletions packages/styleguide/src/lib/Foundations/Theme/CoreTheme.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,13 @@ export const lorem = `Lorem ipsum dolor sit amet, consectetur adipiscing elit, s
**Key**: `borderRadii`

<TokenTable {...TABLES.borderRadii} />

## Elevation

**Key**: `elevation`

Elevation describes how a surface (such as `Card`) renders shadow and lift. The scale has three states — `rest`, `hover`, and `hoverMirrored` — each providing a `shadow` and a `transform` token, accessible with a dashcase key `elevation['${state}-${property}']`. The `hoverMirrored` state is used when a surface casts its shadow to the right instead of the left (e.g. `Card`'s `patternRight` shadow).

Shadow colors reference the `shadow-primary` color mode alias, so they respond to light and dark mode automatically, and themes can provide their own scale via `addScale('elevation', ...)` — Percipio, for example, replaces the hard offset shadow with a soft blurred one and no lift.

<TokenTable {...TABLES.elevation} />
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,11 @@ Percipio currently only supports `light` mode.
The Percipio theme uses Skillsoft Sans for accent text and Skillsoft Text for body text. Roboto Mono is used for monospace, and Roboto sans-serif for the `system` font family slot.

<TokenTable {...TABLES.percipioFontFamily} />

## Elevation

**Key**: `elevation`

The Percipio theme provides its own elevation scale: surfaces cast a soft, blurred shadow rather than the Core theme's hard offset shadow, and they do not lift on hover — every `transform` token is `none`. The scale has the same three states as the Core theme — `rest`, `hover`, and `hoverMirrored` — each providing a `shadow` and a `transform` token accessible with a dashcase key `elevation['${state}-${property}']`, so components like `Card` work across themes without changes.

<TokenTable {...TABLES.percipioElevation} />
80 changes: 79 additions & 1 deletion packages/styleguide/src/lib/Foundations/shared/elements.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import {
Background,
coreSwatches,
css,
ElevationState,
lxStudioColors,
theme,
trueColors,
} from '@codecademy/gamut-styles';
// eslint-disable-next-line gamut/import-paths
import * as ALL_PROPS from '@codecademy/gamut-styles/src/variance/config';
import { useTheme } from '@emotion/react';
import { Theme, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import kebabCase from 'lodash/kebabCase';
import { useMemo } from 'react';
Expand Down Expand Up @@ -541,3 +542,80 @@ export const getPropRows = (key: keyof typeof ALL_PROPS) =>
id: prop,
...config,
}));

const ElevationExample = styled(Box)<{
exampleShadow?: string;
exampleTransform?: string;
}>(
css({
bg: 'background-current',
border: 1,
display: 'inline-block',
height: '3rem',
width: '5rem',
}),
({ exampleShadow, exampleTransform }) => ({
boxShadow: exampleShadow,
transform: exampleTransform,
})
);

const elevationStates: ElevationState[] = ['rest', 'hover', 'hoverMirrored'];

/**
* Every theme emits the same elevation tokens (see `ElevationScale` in
* gamut-styles), so each theme's docs page can build its table from its own
* scale — see `percipioElements` for the Percipio table.
*/
export const createElevationTable = (elevationScale: Theme['elevation']) => ({
rows: elevationStates.map((id) => ({
id,
shadow: elevationScale[`${id}-shadow`],
transform: elevationScale[`${id}-transform`],
})),
columns: [
PROP_COLUMN,
{
...PATH_COLUMN,
render: ({ id }: any) => (
<Box display="grid" gap={4}>
<Code>{`theme.elevation['${id}-shadow']`}</Code>
<Code>{`theme.elevation['${id}-transform']`}</Code>
</Box>
),
},
{
key: 'value',
name: 'Value',
size: 'lg',
render: ({ shadow, transform }: any) => (
<Box display="grid" gap={4} maxWidth="24rem">
<Code>{shadow}</Code>
<Code>{transform}</Code>
</Box>
),
},
{
key: 'example',
name: 'Example',
size: 'fill',
// dashed outline marks the resting position the transform lifts from
render: ({ shadow, transform }: any) => (
<Box
border={1}
borderColor="border-tertiary"
borderStyle="dashed"
display="inline-block"
m={12}
>
<ElevationExample
exampleShadow={shadow}
exampleTransform={transform}
/>
</Box>
),
},
],
});

export const elevation = createElevationTable(theme.elevation);
Loading
Loading