Migrate twenty ui to linaria (#18307)

## Migrate twenty-ui from Emotion to Linaria

Completes the migration of all `twenty-ui` components from Emotion
(runtime CSS-in-JS) to Linaria (zero-runtime, CSS extracted at build
time).

- Replaced `@emotion/styled` with `@linaria/react` across ~170 files
- Removed all Emotion dependencies from `twenty-ui`
- Introduced a CSS custom properties-based theme system:
`themeCssVariables` where every leaf is a `var(--t-xxx)` reference,
injected onto `document.documentElement` by
`ThemeCssVariableInjectorEffect`
- No more `theme` prop threading — styled components reference
`themeCssVariables.x.y` directly at build time
- Updated `twenty-front` consumers to remove `theme={theme}` prop
passing

**Before / After:**
```tsx
// Emotion
color: ${({ theme }) => theme.font.color.primary};
padding: ${({ theme }) => theme.spacing(4)};

// Linaria
color: ${themeCssVariables.font.color.primary};
padding: ${themeCssVariables.spacing[4]};
```

### Theme architecture

Two build-time utilities produce the theme system:

- **`buildThemeReferencingRootCssVariables`** — walks the theme object
and builds a nested mirror where every leaf is a `var(--t-xxx)` string
(evaluated at build time by wyw-in-js)
- **`prepareThemeForRootCssVariableInjection`** — walks the runtime
theme and collects flat `[--css-variable-name, value]` pairs, injected
onto `document.documentElement` by `ThemeCssVariableInjectorEffect`

Both share naming conventions (`camelToKebab`, `SPACING_VALUES`,
`formatSpacingKey`) and are unit tested.

### Spacing cleanup

Spacing scale now uses integers 0–32 (generated via loop), with `0.5`
and `1.5` as the only fractional exceptions. All other fractional
spacing usages (`0.25`, `0.75`, `1.25`, `2.5`, `3.5`) were replaced with
literal pixel values across ~20 twenty-front files.

### Framer Motion integration

Linaria doesn't support `styled(motion.div)` — wrapping a motion element
with `styled()` causes the component body to be stripped at build time.
Instead, we define the styled component first, then wrap it with
`motion.create()`:

```tsx
const StyledBarBase = styled.div`
  background-color: ${themeCssVariables.font.color.primary};
  height: 100%;
`;

const StyledBar = motion.create(StyledBarBase);
```

### Block interpolations

Linaria doesn't support interpolations that return multiple CSS
declarations (Linaria wraps the entire block in a single `var()`,
producing invalid CSS). These were split into individual property
interpolations:

```tsx
// Emotion — single interpolation returning multiple declarations
border-left: ${({ divider, theme }) => {
  const border = `1px solid ${theme.border.color.light}`;
  return divider ? `border-${divider}: ${border}` : '';
}}

// Linaria — one interpolation per property
border-left: ${({ divider }) =>
  divider === 'left' ? `1px solid ${themeCssVariables.border.color.light}` : 'none'};
border-right: ${({ divider }) =>
  divider === 'right' ? `1px solid ${themeCssVariables.border.color.light}` : 'none'};
```

### Dynamic styles via CSS variables

When a component needs to compute styles from multiple props with
complex branching logic (e.g. `Button` combining `variant`, `accent`,
`inverted`, `disabled`, `focus`, `position`), Linaria's prop
interpolations become unwieldy. In those cases we use a
`computeDynamicStyles` function that returns a `CSSProperties` object
injected via `style={}`, referenced from the static CSS with `var()`:

```tsx
const StyledButton = styled.button`
  background: var(--btn-bg);
  border-color: var(--btn-border-color);
  &:hover { background: var(--btn-hover-bg); }
`;

const dynamicStyles = useMemo(() => {
  const s = computeButtonDynamicStyles(variant, accent, ...);
  return { '--btn-bg': s.background, '--btn-hover-bg': s.hoverBackground } as CSSProperties;
}, [variant, accent, ...]);

return <StyledButton style={dynamicStyles} />;
```

### CSS var + unit concatenation

CSS custom properties can't be concatenated with unit suffixes directly
(`var(--x)px` is invalid). Values that need units use `calc()`:

```tsx
// Broken
transition: background ${themeCssVariables.animation.duration.instant}s ease;

// Fixed
transition: background calc(${themeCssVariables.animation.duration.instant} * 1s) ease;
```
This commit is contained in:
Charles Bochet
2026-03-01 15:13:42 +01:00
committed by GitHub
parent 159bb9d70a
commit 1db2a40961
211 changed files with 3682 additions and 2997 deletions
@@ -1,26 +1,27 @@
import React from 'react';
import styled from '@emotion/styled';
import { styled } from '@linaria/react';
import { themeCssVariables } from '@ui/theme';
import { Radio } from './Radio';
const StyledSubscriptionCardContainer = styled.button`
background-color: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
background-color: ${themeCssVariables.background.secondary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
display: flex;
padding: ${({ theme }) => theme.spacing(4)} ${({ theme }) => theme.spacing(3)};
padding: ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[3]};
position: relative;
width: 100%;
:hover {
cursor: pointer;
background: ${({ theme }) => theme.background.tertiary};
background: ${themeCssVariables.background.tertiary};
}
`;
const StyledRadioContainer = styled.div`
position: absolute;
right: ${({ theme }) => theme.spacing(2)};
top: ${({ theme }) => theme.spacing(2)};
right: ${themeCssVariables.spacing[2]};
top: ${themeCssVariables.spacing[2]};
`;
type CardPickerProps = {
@@ -1,6 +1,7 @@
import styled from '@emotion/styled';
import { styled } from '@linaria/react';
import { IconCheck, IconMinus } from '@ui/display/icon/components/TablerIcons';
import { themeCssVariables } from '@ui/theme';
import * as React from 'react';
export enum CheckboxVariant {
@@ -49,131 +50,138 @@ type InputProps = {
disabled?: boolean;
};
const StyledInputContainer = styled.div<InputProps>`
--size: ${({ checkboxSize, hoverable }) => {
const StyledCheckboxContainer = styled.div<InputProps>`
--checkbox-outer-size: ${({ checkboxSize, hoverable }) => {
if (hoverable === true) {
return checkboxSize === CheckboxSize.Large ? '32px' : '24px';
} else {
return checkboxSize === CheckboxSize.Large ? '20px' : '14px';
}
}};
align-items: center;
border-radius: ${({ theme, shape }) =>
--checkbox-label-size: ${({ checkboxSize }) =>
checkboxSize === CheckboxSize.Large ? '18px' : '12px'};
--checkbox-icon-size: ${({ checkboxSize }) =>
checkboxSize === CheckboxSize.Large ? '20px' : '14px'};
--checkbox-bg: ${({ indeterminate, isChecked, disabled, accent }) => {
if (!(indeterminate || isChecked)) return 'transparent';
return disabled === true
? accent === CheckboxAccent.Blue
? themeCssVariables.color.blue7
: themeCssVariables.color.orange7
: accent === CheckboxAccent.Blue
? themeCssVariables.color.blue
: themeCssVariables.color.orange;
}};
--checkbox-border-color: ${({
indeterminate,
isChecked,
variant,
disabled,
accent,
}) => {
switch (true) {
case indeterminate || isChecked:
return disabled === true
? accent === CheckboxAccent.Blue
? themeCssVariables.color.blue7
: themeCssVariables.color.orange7
: accent === CheckboxAccent.Blue
? themeCssVariables.color.blue
: themeCssVariables.color.orange;
case disabled === true:
return themeCssVariables.border.color.strong;
case variant === CheckboxVariant.Primary:
return themeCssVariables.border.color.inverted;
case variant === CheckboxVariant.Tertiary:
return themeCssVariables.border.color.medium;
default:
return themeCssVariables.border.color.secondaryInverted;
}
}};
--checkbox-border-radius: ${({ shape }) =>
shape === CheckboxShape.Rounded
? theme.border.radius.rounded
: theme.border.radius.md};
? themeCssVariables.border.radius.rounded
: themeCssVariables.border.radius.sm};
--checkbox-border-width: ${({ variant, checkboxSize }) =>
checkboxSize === CheckboxSize.Large || variant === CheckboxVariant.Tertiary
? '1.43px'
: '1px'};
--checkbox-cursor: ${({ disabled }) =>
disabled === true ? 'not-allowed' : 'pointer'};
--checkbox-stroke: ${themeCssVariables.font.color.inverted};
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
align-items: center;
border-radius: ${({ shape }) =>
shape === CheckboxShape.Rounded
? themeCssVariables.border.radius.rounded
: themeCssVariables.border.radius.md};
cursor: var(--checkbox-cursor);
display: flex;
padding: ${({ theme, checkboxSize, hoverable }) => {
padding: ${({ checkboxSize, hoverable }) => {
if (hoverable === true) {
return checkboxSize === CheckboxSize.Large
? theme.spacing(1.5)
: theme.spacing(1.25);
return checkboxSize === CheckboxSize.Large ? '6px' : '5px';
} else {
return 0;
return '0';
}
}};
position: relative;
${({ hoverable, isChecked, theme, indeterminate, disabled, accent }) => {
if (!hoverable || disabled === true) return '';
return `&:hover{
background-color: ${
indeterminate || isChecked
? accent === CheckboxAccent.Blue
? theme.background.transparent.blue
: theme.background.transparent.orange
: theme.background.transparent.light
};
}}
}`;
}}
&:hover {
background-color: ${({
hoverable,
isChecked,
indeterminate,
disabled,
accent,
}) => {
if (hoverable !== true || disabled === true) return 'transparent';
if (indeterminate === true || isChecked === true) {
return accent === CheckboxAccent.Blue
? themeCssVariables.background.transparent.blue
: themeCssVariables.background.transparent.orange;
}
return themeCssVariables.background.transparent.light;
}};
}
input + label {
cursor: var(--checkbox-cursor);
height: calc(var(--checkbox-label-size) + 2px);
padding: 0;
position: relative;
width: calc(var(--checkbox-label-size) + 2px);
}
input + label:before {
background: var(--checkbox-bg);
border-color: var(--checkbox-border-color);
border-radius: var(--checkbox-border-radius);
border-style: solid;
border-width: var(--checkbox-border-width);
content: '';
cursor: var(--checkbox-cursor);
display: inline-block;
height: var(--checkbox-label-size);
width: var(--checkbox-label-size);
}
input + label > svg {
--padding: 0px;
height: var(--checkbox-icon-size);
left: var(--padding);
position: absolute;
stroke: var(--checkbox-stroke);
top: var(--padding);
width: var(--checkbox-icon-size);
}
`;
const StyledInput = styled.input<InputProps>`
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
const StyledCheckboxInput = styled.input`
cursor: ${({ disabled }) => (disabled === true ? 'not-allowed' : 'pointer')};
margin: 0;
opacity: 0;
position: absolute;
z-index: 10;
& + label {
--size: ${({ checkboxSize }) =>
checkboxSize === CheckboxSize.Large ? '18px' : '12px'};
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
height: calc(var(--size) + 2px);
padding: 0;
position: relative;
width: calc(var(--size) + 2px);
}
& + label:before {
--size: ${({ checkboxSize }) =>
checkboxSize === CheckboxSize.Large ? '18px' : '12px'};
background: ${({ theme, indeterminate, isChecked, disabled, accent }) => {
if (!(indeterminate || isChecked)) return 'transparent';
return disabled
? accent === CheckboxAccent.Blue
? theme.color.blue7
: theme.color.orange7
: accent === CheckboxAccent.Blue
? theme.color.blue
: theme.color.orange;
}};
border-color: ${({
theme,
indeterminate,
isChecked,
variant,
disabled,
accent,
}) => {
switch (true) {
case indeterminate || isChecked:
return disabled
? accent === CheckboxAccent.Blue
? theme.color.blue7
: theme.color.orange7
: accent === CheckboxAccent.Blue
? theme.color.blue
: theme.color.orange;
case disabled:
return theme.border.color.strong;
case variant === CheckboxVariant.Primary:
return theme.border.color.inverted;
case variant === CheckboxVariant.Tertiary:
return theme.border.color.medium;
default:
return theme.border.color.secondaryInverted;
}
}};
border-radius: ${({ theme, shape }) =>
shape === CheckboxShape.Rounded
? theme.border.radius.rounded
: theme.border.radius.sm};
border-style: solid;
border-width: ${({ variant, checkboxSize }) =>
checkboxSize === CheckboxSize.Large ||
variant === CheckboxVariant.Tertiary
? '1.43px'
: '1px'};
content: '';
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: inline-block;
height: var(--size);
width: var(--size);
}
& + label > svg {
--padding: 0px;
--size: ${({ checkboxSize }) =>
checkboxSize === CheckboxSize.Large ? '20px' : '14px'};
height: var(--size);
left: var(--padding);
position: absolute;
stroke: ${({ theme }) => theme.font.color.inverted};
top: var(--padding);
width: var(--size);
}
`;
export const Checkbox = ({
@@ -205,7 +213,7 @@ export const Checkbox = ({
const checkboxId = React.useId();
return (
<StyledInputContainer
<StyledCheckboxContainer
checkboxSize={size}
variant={variant}
shape={shape}
@@ -216,21 +224,15 @@ export const Checkbox = ({
disabled={disabled}
accent={accent}
>
<StyledInput
<StyledCheckboxInput
autoComplete="off"
type="checkbox"
id={checkboxId}
name="styled-checkbox"
data-testid="input-checkbox"
checked={isInternalChecked}
indeterminate={indeterminate}
variant={variant}
checkboxSize={size}
shape={shape}
isChecked={isInternalChecked}
onChange={handleChange}
disabled={disabled}
accent={accent}
/>
<label htmlFor={checkboxId}>
{indeterminate ? (
@@ -241,6 +243,6 @@ export const Checkbox = ({
<></>
)}
</label>
</StyledInputContainer>
</StyledCheckboxContainer>
);
};
@@ -1,6 +1,8 @@
import styled from '@emotion/styled';
import { styled } from '@linaria/react';
import { motion } from 'framer-motion';
import * as React from 'react';
import { themeCssVariables } from '@ui/theme';
import { RadioGroup } from './RadioGroup';
export enum RadioSize {
@@ -30,12 +32,12 @@ type RadioInputProps = {
'radio-size'?: RadioSize;
};
const StyledRadioInput = styled(motion.input)<RadioInputProps>`
const StyledRadioInputBase = styled.input<RadioInputProps>`
-webkit-appearance: none;
appearance: none;
background-color: transparent;
border: 1px solid ${({ theme }) => theme.font.color.secondary};
border-radius: ${({ theme }) => theme.border.radius.rounded};
border: 1px solid ${themeCssVariables.font.color.secondary};
border-radius: ${themeCssVariables.border.radius.rounded};
height: ${({ 'radio-size': radioSize }) =>
radioSize === RadioSize.Large ? '18px' : '16px'};
margin: 0;
@@ -45,25 +47,26 @@ const StyledRadioInput = styled(motion.input)<RadioInputProps>`
radioSize === RadioSize.Large ? '18px' : '16px'};
:hover {
background-color: ${({ theme, checked }) => {
background-color: ${({ checked }) => {
if (!checked) {
return theme.background.tertiary;
return themeCssVariables.background.tertiary;
}
return '';
}};
outline: 4px solid
${({ theme, checked }) => {
${({ checked }) => {
if (!checked) {
return theme.background.tertiary;
return themeCssVariables.background.tertiary;
}
return theme.color.transparent.blue2;
return themeCssVariables.color.transparent.blue2;
}};
}
&:checked {
background-color: ${({ theme }) => theme.color.blue};
background-color: ${themeCssVariables.color.blue};
border: none;
&::after {
background-color: ${({ theme }) => theme.grayScale.gray1};
background-color: ${themeCssVariables.grayScale.gray1};
border-radius: 50%;
content: '';
height: ${({ 'radio-size': radioSize }) =>
@@ -83,20 +86,26 @@ const StyledRadioInput = styled(motion.input)<RadioInputProps>`
}
`;
const StyledRadioInput = motion.create(StyledRadioInputBase);
type LabelProps = {
disabled?: boolean;
labelPosition?: LabelPosition;
};
const StyledLabel = styled.label<LabelProps>`
color: ${({ theme }) => theme.font.color.primary};
color: ${themeCssVariables.font.color.primary};
cursor: pointer;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular};
margin-left: ${({ theme, labelPosition }) =>
labelPosition === LabelPosition.Right ? theme.spacing(2) : '0px'};
margin-right: ${({ theme, labelPosition }) =>
labelPosition === LabelPosition.Left ? theme.spacing(2) : '0px'};
font-size: ${themeCssVariables.font.size.sm};
font-weight: ${themeCssVariables.font.weight.regular};
margin-left: ${({ labelPosition }) =>
labelPosition === LabelPosition.Right
? themeCssVariables.spacing[2]
: '0px'};
margin-right: ${({ labelPosition }) =>
labelPosition === LabelPosition.Left
? themeCssVariables.spacing[2]
: '0px'};
opacity: ${({ disabled }) => (disabled ? 0.32 : 1)};
`;
@@ -1,7 +1,7 @@
import React from 'react';
import { useTheme } from '@emotion/react';
import { type RadioProps } from './Radio';
import { themeCssVariables } from '@ui/theme';
type RadioGroupProps = React.PropsWithChildren & {
value?: string;
@@ -15,8 +15,6 @@ export const RadioGroup = ({
onValueChange,
children,
}: RadioGroupProps) => {
const theme = useTheme();
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(event);
onValueChange?.(event.target.value);
@@ -27,7 +25,7 @@ export const RadioGroup = ({
{React.Children.map(children, (child) => {
if (React.isValidElement<RadioProps>(child)) {
return React.cloneElement(child, {
style: { marginBottom: theme.spacing(2) },
style: { marginBottom: themeCssVariables.spacing[2] },
checked: child.props.value === value,
onChange: handleChange,
});
@@ -1,8 +1,8 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { styled } from '@linaria/react';
import { IconFilter, IconSearch } from '@ui/display';
import { IconButton } from '@ui/input/button/components/IconButton';
import { type ChangeEvent, type ReactNode, useState } from 'react';
import { ThemeContext, themeCssVariables } from '@ui/theme';
import { type ChangeEvent, type ReactNode, useContext, useState } from 'react';
export type SearchInputProps = {
value: string;
@@ -17,31 +17,35 @@ export type SearchInputProps = {
const StyledWrapper = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
gap: ${themeCssVariables.spacing[2]};
width: 100%;
`;
const StyledInputContainer = styled.div`
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
background-color: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.sm};
box-sizing: border-box;
display: flex;
flex: 1;
gap: ${({ theme }) => theme.spacing(1)};
gap: ${themeCssVariables.spacing[1]};
height: 32px;
padding: 0 ${({ theme }) => theme.spacing(2)};
padding: 0 ${themeCssVariables.spacing[2]};
&:focus-within {
border-color: ${({ theme }) => theme.color.blue};
border-color: ${themeCssVariables.color.blue};
}
`;
const StyledIconContainer = styled.div<{ isFocused: boolean }>`
const StyledIconContainer = styled.div<{
isFocused: boolean;
}>`
align-items: center;
color: ${({ theme, isFocused }) =>
isFocused ? theme.font.color.secondary : theme.font.color.light};
color: ${({ isFocused }) =>
isFocused
? themeCssVariables.font.color.secondary
: themeCssVariables.font.color.light};
display: flex;
justify-content: center;
`;
@@ -49,21 +53,21 @@ const StyledIconContainer = styled.div<{ isFocused: boolean }>`
const StyledInput = styled.input`
background: transparent;
border: none;
color: ${({ theme }) => theme.font.color.primary};
color: ${themeCssVariables.font.color.primary};
flex: 1;
font-family: ${({ theme }) => theme.font.family};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.regular};
font-family: ${themeCssVariables.font.family};
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.regular};
outline: none;
width: 100%;
&::placeholder {
color: ${({ theme }) => theme.font.color.light};
font-weight: ${({ theme }) => theme.font.weight.medium};
color: ${themeCssVariables.font.color.light};
font-weight: ${themeCssVariables.font.weight.medium};
}
&:disabled {
color: ${({ theme }) => theme.font.color.tertiary};
color: ${themeCssVariables.font.color.tertiary};
}
`;
@@ -76,7 +80,7 @@ export const SearchInput = ({
disabled,
className,
}: SearchInputProps) => {
const theme = useTheme();
const { theme } = useContext(ThemeContext);
const [isFocused, setIsFocused] = useState(false);
const filterButton = <IconButton Icon={IconFilter} variant="secondary" />;
@@ -1,5 +1,6 @@
import styled from '@emotion/styled';
import { styled } from '@linaria/react';
import { VisibilityHiddenInput } from '@ui/accessibility';
import { themeCssVariables } from '@ui/theme';
import { motion } from 'framer-motion';
export type ToggleSize = 'small' | 'medium';
@@ -13,8 +14,10 @@ type ContainerProps = {
const StyledContainer = styled.label<ContainerProps>`
align-items: center;
background-color: ${({ theme, isOn, color }) =>
isOn ? (color ?? theme.color.blue) : theme.background.transparent.medium};
background-color: ${({ isOn, color }) =>
isOn
? (color ?? themeCssVariables.color.blue)
: themeCssVariables.background.transparent.medium};
border-radius: 10px;
cursor: pointer;
display: flex;
@@ -28,10 +31,10 @@ const StyledContainer = styled.label<ContainerProps>`
width: ${({ toggleSize }) => (toggleSize === 'small' ? 24 : 32)}px;
`;
const StyledCircle = styled(motion.span)<{
const StyledCircleBase = styled.span<{
size: ToggleSize;
}>`
background-color: ${({ theme }) => theme.background.primary};
background-color: ${themeCssVariables.background.primary};
border-radius: 50%;
display: block;
height: ${({ size }) => (size === 'small' ? 12 : 16)}px;
@@ -40,6 +43,8 @@ const StyledCircle = styled(motion.span)<{
width: ${({ size }) => (size === 'small' ? 12 : 16)}px;
`;
const StyledCircle = motion.create(StyledCircleBase);
export type ToggleProps = {
id?: string;
value?: boolean;
@@ -0,0 +1,54 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { ComponentDecorator } from '@ui/testing';
import { useState } from 'react';
import { SearchInput } from '../SearchInput';
type InteractiveSearchInputProps = {
placeholder?: string;
disabled?: boolean;
autoFocus?: boolean;
className?: string;
};
const InteractiveSearchInput = ({
placeholder,
disabled,
autoFocus,
className,
}: InteractiveSearchInputProps) => {
const [value, setValue] = useState('');
return (
<SearchInput
value={value}
onChange={setValue}
placeholder={placeholder}
disabled={disabled}
autoFocus={autoFocus}
className={className}
/>
);
};
const meta: Meta<typeof InteractiveSearchInput> = {
title: 'UI/Input/SearchInput',
component: InteractiveSearchInput,
decorators: [ComponentDecorator],
};
export default meta;
type Story = StoryObj<typeof InteractiveSearchInput>;
export const Default: Story = {
args: {
placeholder: 'Search...',
},
};
export const Disabled: Story = {
args: {
placeholder: 'Search...',
disabled: true,
},
};