Skip to content

Commit c14271d

Browse files
committed
fix: prevent layout shift in SummaryCard loading states
- Set explicit height on description skeleton wrapper to match description's rendered height - Normal mode: includes marginTop spacing (2px between lines) - Compact mode: reduced by 2px to account for webkit-line-clamp rendering difference - Fix button skeleton width for flat variant (auto-width 160px instead of full-width) - Ensures smooth transition from loading to data mode without height shifts
1 parent 5f38bf2 commit c14271d

File tree

4 files changed

+199
-20
lines changed

4 files changed

+199
-20
lines changed

packages/ui/src/components/Card/SummaryCard.module.css

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,21 @@
242242
overflow: hidden;
243243
}
244244

245+
/* Description Skeleton Wrapper - explicit height to match description */
246+
.descriptionSkeleton {
247+
display: flex;
248+
flex-direction: column;
249+
align-items: stretch;
250+
/* Match description height exactly - include marginTop spacing (2px between lines) */
251+
height: calc(var(--ai-line-height-body-small) * var(--description-lines, 2) + 2px * (var(--description-lines, 2) - 1));
252+
}
253+
254+
/* Compact size uses caption line-height - match description's actual rendered height (slightly shorter due to webkit-line-clamp) */
255+
.summaryCard[data-size='compact'] .descriptionSkeleton {
256+
height: calc(var(--ai-line-height-caption) * var(--description-lines, 2) - 2px);
257+
gap: 0; /* No gap - line-height handles spacing in description */
258+
}
259+
245260
/* Metadata Section */
246261
.metadata {
247262
font-size: var(--ai-font-size-body-small);
@@ -250,6 +265,15 @@
250265
gap: var(--ai-spacing-4);
251266
}
252267

268+
.metadataItem {
269+
display: flex;
270+
align-items: center;
271+
gap: 6px;
272+
height: var(--ai-line-height-body-small); /* 20px - matches skeleton height exactly */
273+
line-height: var(--ai-line-height-body-small); /* Prevent line-height from affecting height */
274+
box-sizing: border-box; /* Ensure consistent box model */
275+
}
276+
253277
.customIcon {
254278
display: inline-flex;
255279
align-items: center;

packages/ui/src/components/Card/SummaryCard.module.css.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ declare const styles: {
1111
readonly subtitle: string;
1212
readonly badge: string;
1313
readonly description: string;
14+
readonly descriptionSkeleton: string;
1415
readonly metadata: string;
16+
readonly metadataItem: string;
1517
readonly customIcon: string;
1618
readonly metadataSeparator: string;
1719
readonly buttonSection: string;

packages/ui/src/components/Card/SummaryCard.stories.tsx

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,30 @@ const LoadingTransitionDemo: React.FC = () => {
127127
style={{ maxWidth: `${CARD_WIDTH}px` }}
128128
/>
129129
</div>
130+
131+
<div>
132+
<p style={{ fontSize: '12px', color: 'var(--ai-color-text-secondary)', marginBottom: '8px' }}>
133+
With Metadata Skeleton (NEW)
134+
</p>
135+
<SummaryCard
136+
images={SAMPLE_IMAGES.restaurant}
137+
imageAspectRatio="4/3"
138+
title="Card with Metadata"
139+
subtitle="Airbnb style"
140+
badge="4.8"
141+
description="Stunning oceanfront property with modern architecture and beach access."
142+
descriptionLines={2}
143+
metadata={[
144+
{ label: '3 bed' },
145+
{ label: '2 bath' },
146+
{ label: 'Beach view' },
147+
{ label: 'Breakfast' },
148+
]}
149+
buttonText="View Details"
150+
loading={isLoading}
151+
style={{ maxWidth: `${CARD_WIDTH}px` }}
152+
/>
153+
</div>
130154
</div>
131155
</div>
132156
);
@@ -1101,6 +1125,89 @@ const SummaryCardsComponent: React.FC = () => {
11011125
<FlatLoadingTransitionDemo />
11021126
</div>
11031127

1128+
{/* Metadata Skeleton */}
1129+
<div style={{ marginBottom: '32px' }}>
1130+
<h3 style={{ fontSize: '14px', fontWeight: '600', marginBottom: '12px', color: 'var(--ai-color-text-primary)' }}>
1131+
Metadata Skeleton Support (NEW)
1132+
</h3>
1133+
<p style={{ fontSize: '13px', color: 'var(--ai-color-text-secondary)', marginBottom: '16px' }}>
1134+
When loading=true and metadata array is provided, skeleton automatically renders placeholders
1135+
for each metadata item - no manual configuration needed!
1136+
</p>
1137+
<div
1138+
style={{
1139+
display: 'grid',
1140+
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
1141+
gap: '24px',
1142+
alignItems: 'start',
1143+
}}
1144+
>
1145+
<div>
1146+
<p style={{ fontSize: '12px', color: 'var(--ai-color-text-secondary)', marginBottom: '8px' }}>
1147+
2 metadata items (loading)
1148+
</p>
1149+
<SummaryCard
1150+
images={SAMPLE_IMAGES.restaurant}
1151+
imageAspectRatio="4/3"
1152+
title="Restaurant Card"
1153+
subtitle="With metadata"
1154+
badge="9.2"
1155+
description="Sample description text"
1156+
descriptionLines={2}
1157+
metadata={[
1158+
{ icon: 'clock', label: '10 min' },
1159+
{ icon: 'map-pin', label: 'Downtown' },
1160+
]}
1161+
buttonText="View"
1162+
loading={true}
1163+
style={{ maxWidth: `${CARD_WIDTH}px` }}
1164+
/>
1165+
</div>
1166+
1167+
<div>
1168+
<p style={{ fontSize: '12px', color: 'var(--ai-color-text-secondary)', marginBottom: '8px' }}>
1169+
4 metadata items (Airbnb style)
1170+
</p>
1171+
<SummaryCard
1172+
images={SAMPLE_IMAGES.restaurant}
1173+
imageAspectRatio="4/3"
1174+
title="Beach House"
1175+
subtitle="Oceanfront property"
1176+
badge="4.8"
1177+
description="Stunning views with modern architecture"
1178+
descriptionLines={2}
1179+
metadata={[
1180+
{ label: '3 bed' },
1181+
{ label: '2 bath' },
1182+
{ label: 'Beach view' },
1183+
{ label: 'Breakfast' },
1184+
]}
1185+
buttonText="View Details"
1186+
loading={true}
1187+
style={{ maxWidth: `${CARD_WIDTH}px` }}
1188+
/>
1189+
</div>
1190+
1191+
<div>
1192+
<p style={{ fontSize: '12px', color: 'var(--ai-color-text-secondary)', marginBottom: '8px' }}>
1193+
No metadata (no skeleton)
1194+
</p>
1195+
<SummaryCard
1196+
images={SAMPLE_IMAGES.restaurant}
1197+
imageAspectRatio="4/3"
1198+
title="Simple Card"
1199+
subtitle="No metadata"
1200+
badge="9.0"
1201+
description="Card without metadata items"
1202+
descriptionLines={2}
1203+
buttonText="View"
1204+
loading={true}
1205+
style={{ maxWidth: `${CARD_WIDTH}px` }}
1206+
/>
1207+
</div>
1208+
</div>
1209+
</div>
1210+
11041211
{/* Best Practices */}
11051212
<div style={{ background: 'var(--ai-color-bg-secondary)', border: '1px solid var(--ai-color-border)', borderRadius: '8px', padding: '16px' }}>
11061213
<h3 style={{ fontSize: '14px', fontWeight: '600', marginBottom: '8px', color: 'var(--ai-color-text-primary)' }}>
@@ -1109,6 +1216,7 @@ const SummaryCardsComponent: React.FC = () => {
11091216
<ul style={{ margin: 0, paddingLeft: '20px', fontSize: '13px', lineHeight: '1.6', color: 'var(--ai-color-text-secondary)' }}>
11101217
<li><strong>CSS aspect-ratio:</strong> Responsive skeleton dimensions without fixed pixel heights</li>
11111218
<li><strong>Dynamic line-clamp:</strong> Description skeleton matches configured line count</li>
1219+
<li><strong>Metadata skeleton auto-detect:</strong> Skeleton count matches metadata array length</li>
11121220
<li><strong>Opacity fade:</strong> Smooth 300ms transition between loading and content states</li>
11131221
<li><strong>Delayed animation:</strong> 100ms delay prevents flash on fast loads (&lt;100ms)</li>
11141222
<li><strong>Zero CLS:</strong> Cumulative Layout Shift score remains 0 during transitions</li>

packages/ui/src/components/Card/SummaryCard.tsx

Lines changed: 65 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@ export interface SummaryCardProps extends Omit<CardProps, 'children'> {
125125
* Metadata items to display below description (e.g., read time, date).
126126
* Array of items with optional icon and label.
127127
*
128+
* When loading={true}, the skeleton will automatically render the same number
129+
* of skeleton placeholders as items in the metadata array (similar to descriptionLines behavior).
130+
*
128131
* Icons can be:
129132
* - Icon name string from the icon library (e.g., 'clock', 'calendar-today')
130133
* - Custom React element (e.g., <CustomIcon />, <svg>...</svg>)
@@ -505,7 +508,7 @@ const SummaryCardComponent = React.forwardRef<HTMLDivElement, SummaryCardProps>(
505508
data-variant={variant}
506509
{...cardProps}
507510
>
508-
{/* Loading State - Single Image */}
511+
{/* Loading State - Single Image */}
509512
{loading && isSingleImage && (
510513
<div className={styles.loadingContainer} role="status" aria-live="polite">
511514
{/* Image Skeleton */}
@@ -516,7 +519,7 @@ const SummaryCardComponent = React.forwardRef<HTMLDivElement, SummaryCardProps>(
516519
</div>
517520

518521
{/* Content Skeleton */}
519-
{(hasHeader || hasDescription) && (
522+
{(hasHeader || hasDescription || metadata) && (
520523
<div className={styles.contentSection}>
521524
{hasHeader && (
522525
<div className={styles.titleRow}>
@@ -528,14 +531,28 @@ const SummaryCardComponent = React.forwardRef<HTMLDivElement, SummaryCardProps>(
528531
</div>
529532
)}
530533

534+
{/* Metadata Skeleton */}
535+
{metadata && metadata.length > 0 && (
536+
<div className={styles.metadata}>
537+
{metadata.map((_, index) => (
538+
<Skeleton
539+
key={index}
540+
width={60}
541+
height={20}
542+
borderRadius={8}
543+
/>
544+
))}
545+
</div>
546+
)}
547+
531548
{hasDescription && (
532-
<div>
549+
<div className={styles.descriptionSkeleton} style={{ '--description-lines': descriptionLines } as React.CSSProperties}>
533550
{Array.from({ length: descriptionLines }).map((_, index) => (
534551
<Skeleton
535552
key={index}
536553
width={index === descriptionLines - 1 ? '80%' : '100%'}
537-
height={14}
538-
style={index > 0 ? { marginTop: 'var(--ai-spacing-2)' } : undefined}
554+
height={size === 'compact' ? 16 : 20}
555+
style={index > 0 && size !== 'compact' ? { marginTop: '2px' } : undefined}
539556
/>
540557
))}
541558
</div>
@@ -545,8 +562,8 @@ const SummaryCardComponent = React.forwardRef<HTMLDivElement, SummaryCardProps>(
545562

546563
{/* Button Skeleton */}
547564
{hasButton && (
548-
<div className={styles.buttonSection}>
549-
<Skeleton width="100%" height={44} borderRadius={22} />
565+
<div className={styles.buttonSection} data-full-width={isButtonFullWidth}>
566+
<Skeleton width={isButtonFullWidth ? "100%" : 160} height={44} borderRadius={22} />
550567
</div>
551568
)}
552569

@@ -567,7 +584,7 @@ const SummaryCardComponent = React.forwardRef<HTMLDivElement, SummaryCardProps>(
567584
</div>
568585

569586
{/* Content Skeleton */}
570-
{(hasHeader || hasDescription) && (
587+
{(hasHeader || hasDescription || metadata) && (
571588
<div className={styles.contentSection}>
572589
{hasHeader && (
573590
<div className={styles.titleRow}>
@@ -580,13 +597,27 @@ const SummaryCardComponent = React.forwardRef<HTMLDivElement, SummaryCardProps>(
580597
)}
581598

582599
{hasDescription && (
583-
<div>
600+
<div className={styles.descriptionSkeleton} style={{ '--description-lines': descriptionLines } as React.CSSProperties}>
584601
{Array.from({ length: descriptionLines }).map((_, index) => (
585602
<Skeleton
586603
key={index}
587604
width={index === descriptionLines - 1 ? '80%' : '100%'}
588-
height={14}
589-
style={index > 0 ? { marginTop: 'var(--ai-spacing-2)' } : undefined}
605+
height={size === 'compact' ? 16 : 20}
606+
style={index > 0 && size !== 'compact' ? { marginTop: '2px' } : undefined}
607+
/>
608+
))}
609+
</div>
610+
)}
611+
612+
{/* Metadata Skeleton */}
613+
{metadata && metadata.length > 0 && (
614+
<div className={styles.metadata}>
615+
{metadata.map((_, index) => (
616+
<Skeleton
617+
key={index}
618+
width={60}
619+
height={20}
620+
borderRadius={8}
590621
/>
591622
))}
592623
</div>
@@ -596,8 +627,8 @@ const SummaryCardComponent = React.forwardRef<HTMLDivElement, SummaryCardProps>(
596627

597628
{/* Button Skeleton */}
598629
{hasButton && (
599-
<div className={styles.buttonSection}>
600-
<Skeleton width="100%" height={44} borderRadius={22} />
630+
<div className={styles.buttonSection} data-full-width={isButtonFullWidth}>
631+
<Skeleton width={isButtonFullWidth ? "100%" : 160} height={44} borderRadius={22} />
601632
</div>
602633
)}
603634

@@ -609,7 +640,7 @@ const SummaryCardComponent = React.forwardRef<HTMLDivElement, SummaryCardProps>(
609640
{loading && !hasImages && (
610641
<div className={styles.loadingContainer} role="status" aria-live="polite">
611642
{/* Content Skeleton */}
612-
{(hasHeader || hasDescription) && (
643+
{(hasHeader || hasDescription || metadata) && (
613644
<div className={styles.contentSection}>
614645
{hasHeader && (
615646
<div className={styles.titleRow}>
@@ -622,13 +653,27 @@ const SummaryCardComponent = React.forwardRef<HTMLDivElement, SummaryCardProps>(
622653
)}
623654

624655
{hasDescription && (
625-
<div>
656+
<div className={styles.descriptionSkeleton} style={{ '--description-lines': descriptionLines } as React.CSSProperties}>
626657
{Array.from({ length: descriptionLines }).map((_, index) => (
627658
<Skeleton
628659
key={index}
629660
width={index === descriptionLines - 1 ? '80%' : '100%'}
630-
height={14}
631-
style={index > 0 ? { marginTop: 'var(--ai-spacing-2)' } : undefined}
661+
height={size === 'compact' ? 16 : 20}
662+
style={index > 0 && size !== 'compact' ? { marginTop: '2px' } : undefined}
663+
/>
664+
))}
665+
</div>
666+
)}
667+
668+
{/* Metadata Skeleton */}
669+
{metadata && metadata.length > 0 && (
670+
<div className={styles.metadata}>
671+
{metadata.map((_, index) => (
672+
<Skeleton
673+
key={index}
674+
width={60}
675+
height={20}
676+
borderRadius={8}
632677
/>
633678
))}
634679
</div>
@@ -638,8 +683,8 @@ const SummaryCardComponent = React.forwardRef<HTMLDivElement, SummaryCardProps>(
638683

639684
{/* Button Skeleton */}
640685
{hasButton && (
641-
<div className={styles.buttonSection}>
642-
<Skeleton width="100%" height={44} borderRadius={22} />
686+
<div className={styles.buttonSection} data-full-width={isButtonFullWidth}>
687+
<Skeleton width={isButtonFullWidth ? "100%" : 160} height={44} borderRadius={22} />
643688
</div>
644689
)}
645690

@@ -744,7 +789,7 @@ const SummaryCardComponent = React.forwardRef<HTMLDivElement, SummaryCardProps>(
744789
<div className={styles.metadata}>
745790
{metadata.map((item, index) => (
746791
<React.Fragment key={index}>
747-
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
792+
<div className={styles.metadataItem}>
748793
{item.icon &&
749794
(typeof item.icon === 'string' ? (
750795
<Icon name={item.icon as IconName} size="sm" tone="secondary" />

0 commit comments

Comments
 (0)