Files
twenty/packages/twenty-website/src/sections/feature-cards/FeatureCard.tsx
T
Abdullah. 602acf7a16 fix(twenty-website): fill feature-card visual frame on wide viewports (#21876)
## Problem
On the home feature cards, the visual frame is capped at `max-width:
411px` (the scene's design width) and centered. Below ~411px-wide cards
this is invisible, but once a card grows past 411px (wider viewports)
the dark scene stops filling and the **card's light background shows on
both sides** of the visual.

At 1200px everything looks correct because the cards are narrower than
411px and the cap is never engaged; the issue only appears as the
viewport widens.

## Fix
`FeatureCard.tsx`, one file:
- **Remove the `max-width: 411px` cap** (and the now-dead `margin: 0
auto`) from `CardImageFrame` so the frame fills the card width at every
breakpoint. `useScaleToFit` then scales the 411×508 scene up to match —
it's a CSS transform on DOM, so it stays crisp; no raster upscaling.
- **Even out the card gutter** — `CardImage` padding `8px → 16px` (top +
sides) so the visual's inset matches the content's 16px inset instead of
stepping in. Bottom stays `0` (the content block's 16px provides the
bottom gutter).

The visual scenes themselves are untouched — this is purely the
frame/container.

## Before

<img width="1477" height="681" alt="image"
src="https://github.com/user-attachments/assets/731f1ef7-e761-468e-b7aa-a5a06f8ac790"
/>

## After

<img width="1473" height="705" alt="image"
src="https://github.com/user-attachments/assets/99d036a6-d3ad-4682-99c8-f283b5b95171"
/>
2026-06-21 15:05:51 +02:00

178 lines
4.6 KiB
TypeScript

'use client';
import { useLingui } from '@lingui/react';
import { styled } from '@linaria/react';
import { IconUsersGroup } from '@tabler/icons-react';
import NextImage from 'next/image';
import { useRef, useState, type ReactNode } from 'react';
import { FastPathIcon, LiveDataIcon } from '@/icons';
import {
color,
FONT_WEIGHT,
fontFamily,
radius,
spacing,
typeRampDeclarations,
} from '@/tokens';
import { Body } from '@/ui';
import { FamiliarInterfaceVisual } from './familiar-interface/FamiliarInterfaceVisual';
import { type FeatureCardRecord } from './feature-cards.data';
import { LiveDataVisual } from './live-data/LiveDataVisual';
import { FastPathVisual } from './fast-path/FastPathVisual';
const CardContainer = styled.div`
background-color: ${color('black-5')};
border: 1px solid ${color('black-20')};
border-radius: ${radius(2)};
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto auto;
height: 100%;
min-width: 0;
overflow: hidden;
`;
const CardImage = styled.div`
box-sizing: border-box;
padding: ${spacing(4)} ${spacing(4)} 0;
width: 100%;
`;
// The scene design box every feature-card visual is authored in.
const SCENE_DESIGN_WIDTH_PX = 411;
const SCENE_DESIGN_HEIGHT_PX = 508;
// Locked to the scene design box: every scene renders a fixed 411x508
// design box scaled by frame-width / 411, so the frame, the scaled scene,
// and any inner content scale together at every breakpoint. The frame fills
// the card width (no max-width cap) so the dark scene reaches both edges
// instead of leaving the card background showing once the card exceeds 411.
const CardImageFrame = styled.div`
aspect-ratio: ${SCENE_DESIGN_WIDTH_PX} / ${SCENE_DESIGN_HEIGHT_PX};
background-color: ${color('black-10')};
border-radius: 2px;
isolation: isolate;
overflow: hidden;
position: relative;
width: 100%;
`;
const CardContent = styled.div`
display: grid;
grid-template-columns: 1fr;
padding: ${spacing(3)} ${spacing(4)} ${spacing(4)};
& > * + * {
margin-top: ${spacing(2)};
}
`;
const CardTitleRow = styled.div`
align-items: center;
column-gap: ${spacing(2)};
display: grid;
grid-template-columns: auto minmax(0, 1fr);
`;
const IconMarker = styled.div`
align-items: center;
background: ${color('blue')};
border-radius: ${radius(1)};
color: ${color('white')};
display: flex;
height: 22px;
justify-content: center;
width: 22px;
`;
const CardHeading = styled.h3`
${typeRampDeclarations('headingXs')}
font-family: ${fontFamily('sans')};
font-weight: ${FONT_WEIGHT.medium};
min-width: 0;
`;
function FeatureIcon({ icon }: { icon: FeatureCardRecord['icon'] }) {
if (icon === 'users-group') {
return <IconUsersGroup aria-hidden size={14} stroke={2} />;
}
if (icon === 'fast-path') return <FastPathIcon sizePx={14.667} />;
return <LiveDataIcon sizePx={14.667} />;
}
export function FeatureCard({ card }: { card: FeatureCardRecord }) {
const { i18n } = useLingui();
const [isHovered, setIsHovered] = useState(false);
const imageFrameRef = useRef<HTMLDivElement>(null);
const backgroundImage = (
<NextImage
alt=""
fill
sizes={`(max-width: 768px) 100vw, ${SCENE_DESIGN_WIDTH_PX}px`}
src={card.backgroundImageSrc}
style={{ objectFit: 'cover' }}
/>
);
let visual: ReactNode = backgroundImage;
if (card.illustration === 'familiar-interface') {
visual = (
<FamiliarInterfaceVisual
active={isHovered}
backgroundImageSrc={card.backgroundImageSrc}
pointerTargetRef={imageFrameRef}
/>
);
}
if (card.illustration === 'live-data') {
visual = (
<LiveDataVisual
active={isHovered}
backgroundImageSrc={card.backgroundImageSrc}
pointerTargetRef={imageFrameRef}
/>
);
}
if (card.illustration === 'fast-path') {
visual = (
<FastPathVisual
active={isHovered}
backgroundImageSrc={card.backgroundImageSrc}
pointerTargetRef={imageFrameRef}
/>
);
}
return (
<CardContainer
onPointerEnter={() => {
setIsHovered(true);
}}
onPointerLeave={() => {
setIsHovered(false);
}}
>
<CardImage>
<CardImageFrame
data-illustration={card.illustration}
ref={imageFrameRef}
>
{visual}
</CardImageFrame>
</CardImage>
<CardContent>
<CardTitleRow>
<IconMarker aria-hidden>
<FeatureIcon icon={card.icon} />
</IconMarker>
<CardHeading>{i18n._(card.heading)}</CardHeading>
</CardTitleRow>
<Body size="sm">{i18n._(card.body)}</Body>
</CardContent>
</CardContainer>
);
}