Compare commits

...

38 Commits

Author SHA1 Message Date
Calvin Jhunjhuwala
bdd0c24919 clean up, tighter code 2026-02-20 18:20:39 -08:00
Calvin Jhunjhuwala
14d1d76193 adding CardStatsList 2026-02-20 09:21:43 -08:00
Calvin Jhunjhuwala
cb0c06f404 adding showcase page 2026-02-18 17:31:12 -08:00
Calvin
a97a009d93 Add CardsIconGrid and CardsTextGrid Section Patterns (#3505)
* CardsIconGrid and CardsTextGrid + showcase pages

* cleaning up code

* more code clean up

* using the spacing tokens for values
2026-02-18 16:19:01 -08:00
Aria Keshmiri
df074e625b Merge pull request #3488 from XRPLF/feature/optimize
Feature/optimize
2026-02-17 14:30:46 -08:00
Calvin
a4b1925b31 [feat] Add LinkTextCard and LinkTextDirectory Components (#3501)
* adding linktextcard + linktextdirectory

* adding dark mode

* code review clean up

* code review comments
2026-02-17 12:21:10 -08:00
akcodez
26d1cf102c test 2026-02-13 10:50:27 -08:00
akcodez
a41a9e31cc fix optimization 2026-02-10 10:54:28 -08:00
akcodez
159ac52acc stashing work 2026-02-10 10:36:41 -08:00
Calvin
c7e01d322a Merge pull request #3485 from XRPLF/pattern/tile-link
Add TileLink Component and LinkSmallGrid Pattern
2026-02-09 15:03:21 -08:00
Calvin Jhunjhuwala
17582d543d updating comments in scss file 2026-02-09 14:58:48 -08:00
Calvin Jhunjhuwala
986ca23ff7 removing import for break points 2026-02-09 14:54:44 -08:00
Calvin Jhunjhuwala
acb2476d7d tweaks to pattern and section 2026-02-07 10:55:48 -08:00
Calvin Jhunjhuwala
f5c38ffe77 cleaning up key 2026-02-06 23:51:37 -08:00
Calvin Jhunjhuwala
ee6a32d159 adding TileLinks pattern, adding LinkSmallGrid 2026-02-06 23:37:57 -08:00
Calvin
e1d18bd621 Merge pull request #3480 from XRPLF/section/feature-single-topic
Add FeatureSingleTopic pattern component with associated styles
2026-02-05 09:59:59 -08:00
Calvin Jhunjhuwala
a85dc47781 fix button utils, remove separate file, enhance within main BUttonGroup file 2026-02-05 09:48:40 -08:00
Calvin Jhunjhuwala
eecd14d763 adding button group utils for validation, cleaning up styling for FeatureSingleTopic 2026-02-04 16:20:03 -08:00
Calvin Jhunjhuwala
42282a2012 Merge branch 'xrpl-brand-update-2026' of github.com:XRPLF/xrpl-dev-portal into section/feature-single-topic 2026-02-04 14:57:33 -08:00
akcodez
6442318205 Enhance FeatureSingleTopic component with button variant support and responsive behavior updates
- Updated the FeatureSingleTopic component to allow configurable button variants (primary or secondary) based on the `singleButtonVariant` prop.
- Improved mobile and tablet responsiveness by ensuring content always appears above the image, regardless of orientation.
- Refined button rendering logic for better clarity in the README and component documentation.
- Adjusted SCSS styles for improved spacing and alignment across different screen sizes.
2026-02-04 11:29:56 -08:00
Calvin
1ee76bfbea Merge pull request #3482 from XRPLF/quick-fix-020426
fix back to html.light, will consolidate later
2026-02-04 11:24:33 -08:00
Calvin Jhunjhuwala
d558b7474d fix back to html.light, will consolidate later 2026-02-04 11:22:59 -08:00
Calvin
da49b0a154 Merge pull request #3477 from XRPLF/section/carousel-feature-image
Add CarouselButton component and integrate into CarouselCardList and …
2026-02-04 11:19:14 -08:00
Calvin Jhunjhuwala
01931bd177 stylesheet clean up 2026-02-04 11:14:42 -08:00
Calvin Jhunjhuwala
c2ef761b01 cleaning up the files, removing dead css, leveraging the grid more 2026-02-03 16:26:47 -08:00
akcodez
d6ce246420 Add FeatureSingleTopic pattern component with associated styles
- Introduced the FeatureSingleTopic component, enhancing layout flexibility with responsive design.
- Added SCSS styles for various screen sizes, including media queries for improved spacing and alignment.
- Implemented dark mode styles for better accessibility and visual consistency.
2026-02-03 13:29:24 -08:00
akcodez
33c6315510 Refactor CarouselCardList and CarouselFeatured to streamline button rendering logic using a map function for navigation buttons, enhancing code maintainability and consistency across components. 2026-02-03 10:40:08 -08:00
akcodez
af0b8cd40a Refactor CarouselFeatured to use ButtonGroup for button management, simplifying button rendering logic and improving code maintainability. 2026-02-03 10:38:45 -08:00
akcodez
95c4ffaa1b merge buttongroup in 2026-02-03 10:29:25 -08:00
Calvin
08c5572f16 Merge pull request #3475 from XRPLF/go/feat/FeaturedVideoSection
Add FeaturedVideoHero pattern component with showcase and styles
2026-02-03 10:25:12 -08:00
Calvin Jhunjhuwala
b49bc02dd2 fixing button and button groups for feature 2 column 2026-02-02 17:03:04 -08:00
Calvin Jhunjhuwala
65a61c5e47 fixing spacing and alignments 2026-02-02 16:20:50 -08:00
Calvin Jhunjhuwala
237ddc3c74 cleaning up styles, aligning text to bottom for section 2026-02-02 15:57:20 -08:00
Calvin
dd6cfd34fe Merge pull request #3474 from XRPLF/pattern/button-group
Add ButtonGroup pattern component
2026-02-02 14:30:26 -08:00
Calvin Jhunjhuwala
7b601da3a0 updating to add more warnings/dev feedback 2026-02-02 13:20:12 -08:00
gabriel-ortiz
6021b458e6 Add FeaturedVideoHero pattern component with showcase and styles
- Introduced the FeaturedVideoHero component featuring a responsive layout with a headline, optional subtitle, call-to-action buttons, and a video element.
- Added associated SCSS styles for the component to ensure proper spacing and theming.
- Created a showcase page to demonstrate the usage of the FeaturedVideoHero pattern.
- Updated types to include design constraints for buttons and video elements.
- Implemented validation for required props to enhance development experience.
2026-01-30 13:47:38 -08:00
Calvin Jhunjhuwala
e5f3bf75e3 minor add for maxnumber 2026-01-30 10:30:53 -08:00
Calvin Jhunjhuwala
7dd32d63da working button group, updated examples 2026-01-29 16:50:32 -08:00
155 changed files with 8068 additions and 34770 deletions

View File

@@ -14,6 +14,7 @@ export const navItems: NavItem[] = [
{ label: "Use Cases", labelTranslationKey: "navbar.usecases", href: "/use-cases", hasSubmenu: true },
{ label: "Community", labelTranslationKey: "navbar.community", href: "/community", hasSubmenu: true },
{ label: "Network", labelTranslationKey: "navbar.network", href: "/resources", hasSubmenu: true },
{ label: "Showcase", labelTranslationKey: "navbar.network", href: "/showcase", hasSubmenu: false },
];
// Develop submenu data structure

View File

@@ -66,6 +66,9 @@ module.exports = {
/^container/, // All container classes
/^row$/, // Row class
/^col-/, // Column classes
/^bds-grid__col/, // PageGrid column classes (dynamic span values)
/^bds-grid__offset/, // PageGrid offset classes
/^bds-[a-z0-9-]+--/, // BDS BEM modifier classes (e.g. bds-callout-media-banner--green, bds-tile-link--primary)
/^g-/, // Gap utilities
/^p-/, // Padding utilities
/^m-/, // Margin utilities

View File

@@ -1900,4 +1900,34 @@ html.dark {
}
}
}
}
// =============================================================================
// No Padding Modifier
// =============================================================================
// When .bds-btn--no-padding is applied, removes all padding and left-aligns content.
// Useful for tertiary buttons in block layouts where left alignment is needed.
// =============================================================================
.bds-btn--no-padding {
padding: 0 !important;
justify-content: flex-start !important;
// Override all state paddings
&:hover:not(:disabled):not(.bds-btn--disabled),
&:focus-visible:not(:disabled):not(.bds-btn--disabled),
&:active:not(:disabled):not(.bds-btn--disabled) {
padding: 0 !important;
}
// Responsive overrides
@include media-breakpoint-down(xl) {
padding: 0 !important;
&:hover:not(:disabled):not(.bds-btn--disabled),
&:focus-visible:not(:disabled):not(.bds-btn--disabled),
&:active:not(:disabled):not(.bds-btn--disabled) {
padding: 0 !important;
}
}
}

View File

@@ -31,6 +31,12 @@ export interface ButtonProps {
href?: string;
/** Link target - only applies when href is provided */
target?: '_self' | '_blank';
/**
* Force no padding and left-align text.
* When true, removes all padding and aligns content to the left.
* Useful for tertiary buttons in block layouts where left alignment is needed.
*/
forceNoPadding?: boolean;
}
/**
@@ -115,6 +121,7 @@ export const Button: React.FC<ButtonProps> = ({
ariaLabel,
href,
target = '_self',
forceNoPadding = false,
}) => {
// Hide icon when disabled (per design spec)
const shouldShowIcon = showIcon && !disabled;
@@ -131,6 +138,7 @@ export const Button: React.FC<ButtonProps> = ({
'bds-btn--disabled': disabled,
'bds-btn--no-icon': !shouldShowIcon,
'bds-btn--force-color': forceColor,
'bds-btn--no-padding': forceNoPadding,
},
className
);

View File

@@ -14,37 +14,40 @@
// .bds-card-icon__label - Text label
// .bds-card-icon__arrow - Arrow icon wrapper
@import '../../../styles/breakpoints';
// =============================================================================
// Design Tokens
// =============================================================================
// Note: Uses centralized spacing tokens from _spacing.scss where applicable.
// Component-specific values (heights, icon sizes) remain local.
// Focus border
// Focus border - uses centralized token
$bds-card-icon-focus-border-color: $black;
$bds-card-icon-focus-border-width: 2px;
$bds-card-icon-focus-border-width: $bds-focus-border-width; // 2px from _spacing.scss
// Animation (matching TileLogo/CardOffgrid)
$bds-card-icon-transition-duration: 200ms;
$bds-card-icon-transition-timing: cubic-bezier(0.98, 0.12, 0.12, 0.98);
// Animation - uses centralized tokens from _animations.scss
$bds-card-icon-transition-duration: $bds-transition-duration; // 200ms
$bds-card-icon-transition-timing: $bds-transition-timing;
// -----------------------------------------------------------------------------
// Responsive Size Tokens
// -----------------------------------------------------------------------------
// Heights and icon sizes are component-specific (not part of spacing scale)
// Padding uses centralized spacing tokens
// SM breakpoint (mobile - default)
$bds-card-icon-height-sm: 136px;
$bds-card-icon-padding-sm: 8px;
$bds-card-icon-padding-sm: $bds-space-sm; // 8px - spacing('sm')
$bds-card-icon-icon-size-sm: 56px;
// MD breakpoint (tablet)
$bds-card-icon-height-md: 140px;
$bds-card-icon-padding-md: 12px;
$bds-card-icon-padding-md: $bds-space-md; // 12px - spacing('md')
$bds-card-icon-icon-size-md: 60px;
// LG breakpoint (desktop)
$bds-card-icon-height-lg: 144px;
$bds-card-icon-padding-lg: 16px;
$bds-card-icon-padding-lg: $bds-space-lg; // 16px - spacing('lg')
$bds-card-icon-icon-size-lg: 64px;
// =============================================================================

View File

@@ -18,6 +18,8 @@
// =============================================================================
// Design Tokens (from Figma)
// =============================================================================
// Note: Uses centralized spacing tokens from _spacing.scss where applicable.
// Component-specific values (heights, dimensions) remain local.
// Card dimensions - LG (Large) variant (default, ≥992px)
$bds-card-image-height-lg: 620px;
@@ -32,29 +34,29 @@ $bds-card-image-height-sm: 536px;
$bds-card-image-image-height-sm: 268px;
// Spacing - LG (Large) variant (default, ≥992px)
$bds-card-image-gap-lg: 24px; // Gap between image and content
$bds-card-image-title-gap-lg: 12px; // Gap between title and subtitle
$bds-card-image-content-padding: 8px; // Horizontal padding for content (same for all)
$bds-card-image-button-margin-lg: 32px; // Margin above button
$bds-card-image-gap-lg: $bds-space-2xl; // 24px - Gap between image and content
$bds-card-image-title-gap-lg: $bds-space-md; // 12px - Gap between title and subtitle
$bds-card-image-content-padding: $bds-space-sm; // 8px - Horizontal padding for content
$bds-card-image-button-margin-lg: $bds-space-3xl; // 32px - Margin above button
// Spacing - MD (Medium) variant (576px - 991px)
$bds-card-image-gap-md: 16px; // Gap between image and content
$bds-card-image-title-gap-md: 8px; // Gap between title and subtitle
$bds-card-image-button-margin-md: 24px; // Margin above button
$bds-card-image-gap-md: $bds-space-lg; // 16px - Gap between image and content
$bds-card-image-title-gap-md: $bds-space-sm; // 8px - Gap between title and subtitle
$bds-card-image-button-margin-md: $bds-space-2xl; // 24px - Margin above button
// Spacing - SM (Small) variant (<576px)
$bds-card-image-gap-sm: 16px; // Gap between image and content
$bds-card-image-title-gap-sm: 8px; // Gap between title and subtitle
$bds-card-image-button-margin-sm: 24px; // Margin above button
$bds-card-image-gap-sm: $bds-space-lg; // 16px - Gap between image and content
$bds-card-image-title-gap-sm: $bds-space-sm; // 8px - Gap between title and subtitle
$bds-card-image-button-margin-sm: $bds-space-2xl; // 24px - Margin above button
// Colors
$bds-card-image-bg: $white;
$bds-card-image-image-bg: $gray-100;
$bds-card-image-text-color: #141414; // Neutral black from Figma
// Animation
$bds-card-image-transition-duration: 150ms;
$bds-card-image-transition-timing: cubic-bezier(0.98, 0.12, 0.12, 0.98);
// Animation - uses centralized tokens from _animations.scss
$bds-card-image-transition-duration: 150ms; // Component-specific (faster than default)
$bds-card-image-transition-timing: $bds-transition-timing;
// =============================================================================
// Base Card Styles

View File

@@ -21,18 +21,20 @@
// =============================================================================
// Design Tokens
// =============================================================================
// Note: Uses centralized spacing tokens from _spacing.scss where applicable.
// Component-specific values (dimensions, icon sizes) remain local.
// Dimensions (from Figma design spec)
$bds-card-offgrid-width: 400px;
$bds-card-offgrid-height: 480px;
$bds-card-offgrid-padding: 24px;
$bds-card-offgrid-padding: $bds-space-2xl; // 24px - spacing('2xl')
$bds-card-offgrid-icon-container: 84px;
$bds-card-offgrid-icon-size: 68px;
$bds-card-offgrid-content-gap: 40px;
$bds-card-offgrid-content-gap: $bds-space-4xl; // 40px - spacing('4xl')
// Animation (from Figma design spec)
$bds-card-offgrid-transition-duration: 200ms;
$bds-card-offgrid-transition-timing: cubic-bezier(0.98, 0.12, 0.12, 0.98);
// Animation - uses centralized tokens from _animations.scss
$bds-card-offgrid-transition-duration: $bds-transition-duration; // 200ms
$bds-card-offgrid-transition-timing: $bds-transition-timing;
// Typography - Title (now using .subhead-lg-l from _font.scss)
// Typography - Description (now using .body-l from _font.scss)

View File

@@ -17,6 +17,7 @@
// =============================================================================
// Design Tokens
// =============================================================================
// Note: Uses centralized spacing tokens from _spacing.scss where applicable.
// Color variant map: (variant-name: (light-mode-bg, dark-mode-bg))
// null for dark-mode-bg means no change in dark mode
@@ -30,12 +31,12 @@ $bds-card-stat-variants: (
// Text colors
$bds-card-stat-text: $black; // Neutral black
// Spacing
$bds-card-stat-gap: 8px;
// Spacing - uses centralized tokens
$bds-card-stat-gap: $bds-space-sm; // 8px - spacing('sm')
// Transitions
$bds-card-stat-transition-duration: 150ms;
$bds-card-stat-transition-timing: cubic-bezier(0.98, 0.12, 0.12, 0.98);
$bds-card-stat-transition-duration: 150ms; // Component-specific (faster than default)
$bds-card-stat-transition-timing: $bds-transition-timing;
// =============================================================================
// Base Card Styles
@@ -43,15 +44,15 @@ $bds-card-stat-transition-timing: cubic-bezier(0.98, 0.12, 0.12, 0.98);
.bds-card-stat {
// Layout
display: flex;
display: flex !important;
flex-direction: column;
width: 100%;
min-height: 200px;
height: 100%;
justify-content: space-between;
flex: 1;
padding: 8px;
gap: 4px;
padding: $bds-space-sm; // 8px - spacing('sm')
gap: $bds-space-xs; // 4px - spacing('xs')
// Visual - default to lilac
background-color: nth(map-get($bds-card-stat-variants, 'lilac'), 1);
@@ -64,19 +65,19 @@ $bds-card-stat-transition-timing: cubic-bezier(0.98, 0.12, 0.12, 0.98);
// Tablet (md) breakpoint
@include media-breakpoint-up(md) {
padding: 12px;
padding: $bds-space-md; // 12px - spacing('md')
}
// Desktop (lg+) breakpoint
@include media-breakpoint-up(lg) {
padding: 16px;
padding: $bds-space-lg; // 16px - spacing('lg')
}
// Text section
&__text {
display: flex;
flex-direction: column;
gap: 8px;
gap: $bds-space-sm; // 8px - spacing('sm')
}
// Statistic (large number)
@@ -102,7 +103,7 @@ $bds-card-stat-transition-timing: cubic-bezier(0.98, 0.12, 0.12, 0.98);
display: flex;
flex-wrap: wrap;
gap: $bds-card-stat-gap;
align-items: flex-start;
align-items: flex-end;
}
}

View File

@@ -87,43 +87,41 @@ export const CardStat: React.FC<CardStatProps> = ({
const isNumericSuperscript = superscript && /^[0-9]+$/.test(superscript);
return (
<PageGridCol span={span}>
<div className={classNames}>
{/* Text section */}
<div className="bds-card-stat__text">
<div className="bds-card-stat__statistic">
{statistic}{superscript && <sup className={isNumericSuperscript ? 'bds-card-stat__superscript--numeric' : ''}>{superscript}</sup>}</div>
<div className="body-r">{label}</div>
</div>
{/* Buttons section */}
{hasButtons && (
<div className="bds-card-stat__buttons">
{primaryButton && (
<Button
forceColor
variant="primary"
color="black"
href={primaryButton.href}
onClick={primaryButton.onClick}
>
{primaryButton.label}
</Button>
)}
{secondaryButton && (
<Button
forceColor
variant="secondary"
color="black"
href={secondaryButton.href}
onClick={secondaryButton.onClick}
>
{secondaryButton.label}
</Button>
)}
</div>
)}
<PageGridCol span={span} as="li" className={classNames}>
{/* Text section */}
<div className="bds-card-stat__text">
<div className="bds-card-stat__statistic">
{statistic}{superscript && <sup className={isNumericSuperscript ? 'bds-card-stat__superscript--numeric' : ''}>{superscript}</sup>}</div>
<div className="body-r">{label}</div>
</div>
{/* Buttons section */}
{hasButtons && (
<div className="bds-card-stat__buttons">
{primaryButton && (
<Button
forceColor
variant="primary"
color="black"
href={primaryButton.href}
onClick={primaryButton.onClick}
>
{primaryButton.label}
</Button>
)}
{secondaryButton && (
<Button
forceColor
variant="secondary"
color="black"
href={secondaryButton.href}
onClick={secondaryButton.onClick}
>
{secondaryButton.label}
</Button>
)}
</div>
)}
</PageGridCol>
);
};

View File

@@ -0,0 +1,130 @@
// BDS CardTextIconCard Pattern Styles
// Brand Design System - Card with icon, heading, and description
//
// Naming Convention: BEM with 'bds' namespace
// .bds-card-text-icon-card - Base card container
// .bds-card-text-icon-card__icon - Icon container
// .bds-card-text-icon-card__icon-img - Icon image
// .bds-card-text-icon-card__heading - Card heading
// .bds-card-text-icon-card__description - Card description (supports ReactNode/links)
//
// Built from Section Cards - Icon and Section Cards - Text Grid Figma designs
// =============================================================================
// Design Tokens
// =============================================================================
// Icon sizes (responsive)
$bds-card-text-icon-icon-size-sm: 32px;
$bds-card-text-icon-icon-size-md: 36px;
$bds-card-text-icon-icon-size-lg: 40px;
// Padding (uses BDS card presets from _spacing.scss)
$bds-card-text-icon-padding-sm: $bds-padding-card-sm;
$bds-card-text-icon-padding-md: $bds-padding-card-md;
$bds-card-text-icon-padding-lg: $bds-padding-card-lg;
// Gaps (between icon, heading, description)
$bds-card-text-icon-gap-sm: $bds-padding-card-sm;
$bds-card-text-icon-gap-md: $bds-padding-card-md;
$bds-card-text-icon-gap-lg: $bds-padding-card-lg;
// =============================================================================
// Card Component
// =============================================================================
.bds-card-text-icon-card {
display: flex !important;
flex-direction: column;
justify-content: space-between;
align-items: flex-start;
text-decoration: none;
box-sizing: border-box;
border-left: 1px solid $gray-300;
margin-bottom: $bds-space-2xl;
// When used standalone (not as grid column), take full width
&:not(.bds-card-text-icon-card--grid-col) {
width: 100%;
}
padding: $bds-card-text-icon-padding-sm;
gap: $bds-card-text-icon-gap-sm;
@include media-breakpoint-up(md) {
padding: $bds-card-text-icon-padding-md;
gap: $bds-card-text-icon-gap-md;
margin-bottom: $bds-space-3xl;
}
@include media-breakpoint-up(lg) {
padding: $bds-card-text-icon-padding-lg;
gap: $bds-card-text-icon-gap-lg;
margin-bottom: $bds-space-4xl;
}
// Aspect ratio foundation - when CSS variable is set via aspectRatio prop
&[style*="--bds-card-text-icon-aspect-ratio"] {
aspect-ratio: var(--bds-card-text-icon-aspect-ratio);
}
@include bds-theme-mode(dark) {
color: $white;
}
}
// =============================================================================
// Icon
// =============================================================================
.bds-card-text-icon-card__icon {
width: 100%;
display: flex;
flex-direction: column;
gap: $bds-space-lg;
&-img {
object-fit: contain;
display: block;
flex-shrink: 0;
width: $bds-card-text-icon-icon-size-sm;
height: $bds-card-text-icon-icon-size-sm;
@include media-breakpoint-up(md) {
width: $bds-card-text-icon-icon-size-md;
height: $bds-card-text-icon-icon-size-md;
}
@include media-breakpoint-up(lg) {
width: $bds-card-text-icon-icon-size-lg;
height: $bds-card-text-icon-icon-size-lg;
}
}
}
// =============================================================================
// Heading
// =============================================================================
.bds-card-text-icon-card__heading {
margin: 0;
color: inherit;
}
// =============================================================================
// Description (supports ReactNode - links, formatted text, etc.)
// =============================================================================
.bds-card-text-icon-card__description {
margin: 0;
color: inherit;
min-width: 0;
position: relative;
a {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
&:hover {
text-decoration-thickness: 2px;
}
}
}

View File

@@ -0,0 +1,121 @@
import React from 'react';
import clsx from 'clsx';
import { PageGrid } from '../PageGrid/page-grid';
import type { ResponsiveValue, PageGridSpanValue } from '../PageGrid/page-grid';
export interface CardTextIconCardProps {
/** Icon image URL */
icon?: string;
/** Alt text for the icon image */
iconAlt?: string;
/** Card heading */
heading: string;
/** Card description; accepts rich content (e.g., text with inline links) */
description: React.ReactNode | string;
/** Optional aspect ratio for future use; applied via CSS variable */
aspectRatio?: number;
/** When provided, renders as PageGrid.Col as="li" with this span—card becomes the grid column */
gridColSpan?: ResponsiveValue<PageGridSpanValue>;
/** Additional CSS classes */
className?: string;
/** Optional height and width for the icon image */
height?: number;
width?: number;
}
/**
* CardTextIconCard Component
*
* A card component featuring an icon, heading, and description.
* Built from Section Cards - Icon and Section Cards - Text Grid Figma designs.
*
* The description accepts ReactNode so it can include hyperlinks and other rich content.
*
* @example
* // Basic usage
* <CardTextIconCard
* icon="/icons/docs.svg"
* iconAlt="Documentation"
* heading="Documentation"
* description="Access everything you need to get started with the XRPL."
* />
*
* @example
* // With inline link in description
* <CardTextIconCard
* icon="/icons/docs.svg"
* heading="Documentation"
* description={
* <>
* Learn more in our{' '}
* <a href="/docs">documentation</a>.
* </>
* }
* />
*/
const cardContent = (
heading: string,
description: React.ReactNode | string,
icon?: string,
iconAlt?: string,
iconHeight?: number,
iconWidth?: number
) => (
<>
<div className="bds-card-text-icon-card__icon">
{icon && (
<img
src={icon}
alt={iconAlt}
{...(iconHeight != null && { height: iconHeight })}
{...(iconWidth != null && { width: iconWidth })}
className="bds-card-text-icon-card__icon-img"
/>
)}
<strong className="bds-card-text-icon-card__heading sh-md-r">{heading}</strong>
</div>
<p className="bds-card-text-icon-card__description body-l">
{description}
</p>
</>
);
export const CardTextIconCard: React.FC<CardTextIconCardProps> = ({
icon,
iconAlt = '',
heading,
description,
aspectRatio,
gridColSpan,
className,
height,
width
}) => {
const style = aspectRatio
? ({ '--bds-card-text-icon-aspect-ratio': aspectRatio } as React.CSSProperties)
: undefined;
if (gridColSpan) {
return (
<PageGrid.Col
as="li"
span={gridColSpan}
className={clsx('bds-card-text-icon-card', 'bds-card-text-icon-card--grid-col', className)}
style={style}
>
{cardContent(heading, description, icon, iconAlt, height, width)}
</PageGrid.Col>
);
}
return (
<div
className={clsx('bds-card-text-icon-card', className)}
style={style}
>
{cardContent(heading, description, icon, iconAlt, height, width)}
</div>
);
};
export default CardTextIconCard;

View File

@@ -0,0 +1,116 @@
# CardTextIconCard Component
A card component featuring an icon, heading, and description. Built from Section Cards - Icon and Section Cards - Text Grid Figma designs.
## Overview
CardTextIconCard displays an icon at the top, followed by a heading and description. The description accepts `ReactNode`, so it can include hyperlinks and other rich content. No buttons; links are inline within the description.
## Features
- **Icon + Text Layout**: Icon container, heading, and description in a vertical stack (optional)
- **Rich Description**: `description` accepts `ReactNode` for inline links and formatted content
- **Aspect Ratio Foundation**: Optional `aspectRatio` prop for future responsive sizing
- **Light/Dark Mode**: Full theming support
- **Responsive Design**: Adaptive icon size and spacing across breakpoints
## Usage
### Basic Usage
```tsx
<CardTextIconCard
icon="/icons/docs.svg"
iconAlt="Documentation"
heading="Documentation"
description="Access everything you need to get started with the XRPL."
/>
```
### With Inline Link in Description
```tsx
<CardTextIconCard
icon="/icons/docs.svg"
heading="Documentation"
description={
<>
Learn more in our{' '}
<a href="/docs">documentation</a>.
</>
}
/>
```
### With Aspect Ratio
```tsx
<CardTextIconCard
icon="/icons/docs.svg"
heading="Documentation"
description="Access everything you need."
aspectRatio={4 / 3}
/>
```
## Props
### CardTextIconCardProps
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `icon` | `string` | Optional | Icon image URL |
| `iconAlt` | `string` | `''` | Optional | Alt text for the icon image |
| `heading` | `string` | Required | Card heading |
| `description` | `React.ReactNode` | Required | Card description; accepts rich content (e.g., text with inline links) |
| `aspectRatio` | `number` | - | Optional ratio for future use; applied via CSS variable |
| `className` | `string` | - | Additional CSS classes |
## Component Structure
```tsx
<>
<div className="bds-card-text-icon-card__icon">
{icon && (
<img
src={icon}
alt={iconAlt}
{...(iconHeight != null && { height: iconHeight })}
{...(iconWidth != null && { width: iconWidth })}
className="bds-card-text-icon-card__icon-img"
/>
)}
<strong className="bds-card-text-icon-card__heading sh-md-r">{heading}</strong>
</div>
<p className="bds-card-text-icon-card__description body-l">
{description}
</p>
</>
```
## Responsive Sizing
| Breakpoint | Icon Size | Padding | Gap |
|------------|-----------|---------|-----|
| Base (< 576px) | 40px | 16px | 16px |
| MD (576px - 991px) | 36px | 20px | 20px |
| LG (≥ 992px) | 64px | 32px | 24px |
## Files
- `CardTextIconCard.tsx` - React component with TypeScript
- `CardTextIconCard.scss` - Styles with BEM naming
- `index.ts` - Barrel exports
- `README.md` - This file
## Import
```tsx
import { CardTextIconCard } from 'shared/components/CardTextIcon';
// or
import { CardTextIconCard, type CardTextIconCardProps } from 'shared/components/CardTextIcon';
```
## Design System
Part of the Brand Design System (BDS) with `bds-` namespace prefix.

View File

@@ -0,0 +1 @@
export { CardTextIconCard, type CardTextIconCardProps } from './CardTextIconCard';

View File

@@ -25,6 +25,101 @@ $bds-carousel-button-size-lg: 40px; // Desktop
// Transition
$bds-carousel-button-transition: 200ms cubic-bezier(0.98, 0.12, 0.12, 0.98);
// =============================================================================
// Color Variant Configuration Maps
// =============================================================================
// Dark Mode color variants
$bds-carousel-button-variants-dark: (
'green': (
'bg': $green-300,
'color': $black,
'hover': $green-400,
'active': $green-300,
'disabled-bg': $green-500,
'disabled-color': #F0F3F7,
'disabled-opacity': 0.5
),
'neutral': (
'bg': $gray-300,
'color': $black,
'hover': $gray-400,
'active': $gray-300,
'disabled-bg': $gray-500,
'disabled-color': $gray-300,
'disabled-opacity': 0.5
),
'black': (
'bg': $white,
'color': $black,
'hover': $gray-300,
'active': $white,
'disabled-bg': $gray-500,
'disabled-color': null,
'disabled-opacity': 0.5
)
);
// Light Mode color variants
$bds-carousel-button-variants-light: (
'green': (
'bg': $green-300,
'color': $black,
'hover': $green-200,
'active': $green-300,
'disabled-bg': $green-100,
'disabled-color': $gray-300,
'disabled-opacity': 1
),
'neutral': (
'bg': $gray-300,
'color': $black,
'hover': $gray-200,
'active': $gray-300,
'disabled-bg': $gray-100,
'disabled-color': $gray-300,
'disabled-opacity': 1
),
'black': (
'bg': $black,
'color': $white,
'hover': $gray-500,
'active': $black,
'disabled-bg': #F0F3F7,
'disabled-color': $gray-300,
'disabled-opacity': 1
)
);
// =============================================================================
// Mixin: Apply Color Variant Styles
// =============================================================================
@mixin carousel-button-variant($variant-name, $config) {
.bds-carousel-button--#{$variant-name} {
background-color: map-get($config, 'bg');
color: map-get($config, 'color');
&:hover:not(:disabled) {
background-color: map-get($config, 'hover');
}
&:active:not(:disabled) {
background-color: map-get($config, 'active');
}
&.bds-carousel-button--disabled,
&:disabled {
background-color: map-get($config, 'disabled-bg') !important;
@if map-get($config, 'disabled-color') {
color: map-get($config, 'disabled-color') !important;
}
opacity: map-get($config, 'disabled-opacity') !important;
cursor: not-allowed !important;
}
}
}
// =============================================================================
// Base Button Styles
// =============================================================================
@@ -88,72 +183,9 @@ $bds-carousel-button-transition: 200ms cubic-bezier(0.98, 0.12, 0.12, 0.98);
// DARK MODE (Default) - Color Variants
// =============================================================================
// Green button variant - Dark Mode
// Enabled: green-300, Hover: green-400, Active/Pressed: green-300, Disabled: green-500 @ 0.5 opacity
.bds-carousel-button--green {
background-color: $green-300;
color: $black;
&:hover:not(:disabled) {
background-color: $green-400;
}
&:active:not(:disabled) {
background-color: $green-300;
}
&.bds-carousel-button--disabled,
&:disabled {
background-color: $green-500 !important;
color: #F0F3F7 !important;
opacity: 0.5 !important;
cursor: not-allowed !important;
}
}
// Neutral/Grey button variant - Dark Mode
// Enabled: gray-300, Hover: gray-400, Active/Pressed: gray-300, Disabled: gray-500 @ 0.5 opacity
.bds-carousel-button--neutral {
background-color: $gray-300;
color: $black;
&:hover:not(:disabled) {
background-color: $gray-400;
}
&:active:not(:disabled) {
background-color: $gray-300;
}
&.bds-carousel-button--disabled,
&:disabled {
background-color: $gray-500 !important;
color: $gray-300 !important;
opacity: 0.5 !important;
cursor: not-allowed !important;
}
}
// Black variant becomes White in Dark Mode
// Enabled: white, Hover: gray-300, Active/Pressed: white, Disabled: gray-500 @ 0.5 opacity
.bds-carousel-button--black {
background-color: $white;
color: $black;
&:hover:not(:disabled) {
background-color: $gray-300;
}
&:active:not(:disabled) {
background-color: $white;
}
&.bds-carousel-button--disabled,
&:disabled {
background-color: $gray-500 !important;
opacity: 0.5 !important;
cursor: not-allowed !important;
}
// Generate all dark mode variant styles using the mixin
@each $variant-name, $config in $bds-carousel-button-variants-dark {
@include carousel-button-variant($variant-name, $config);
}
// =============================================================================
@@ -172,73 +204,9 @@ html.light {
}
}
// Green button variant - Light Mode
// Enabled: green-300, Hover: green-200, Active/Pressed: green-300, Disabled: green-100
.bds-carousel-button--green {
background-color: $green-300;
color: $black;
&:hover:not(:disabled) {
background-color: $green-200;
}
&:active:not(:disabled) {
background-color: $green-300;
}
&.bds-carousel-button--disabled,
&:disabled {
background-color: $green-100 !important;
color: $gray-300 !important;
opacity: 1 !important;
cursor: not-allowed !important;
}
}
// Neutral/Grey button variant - Light Mode
// Enabled: gray-300, Hover: gray-200, Active/Pressed: gray-300, Disabled: gray-100
.bds-carousel-button--neutral {
background-color: $gray-300;
color: $black;
&:hover:not(:disabled) {
background-color: $gray-200;
}
&:active:not(:disabled) {
background-color: $gray-300;
}
&.bds-carousel-button--disabled,
&:disabled {
background-color: $gray-100 !important;
color: $gray-300 !important;
opacity: 1 !important;
cursor: not-allowed !important;
}
}
// Black button variant - Light Mode
// Enabled: black, Hover: gray-500, Active/Pressed: black, Disabled: #F0F3F7
.bds-carousel-button--black {
background-color: $black;
color: $white;
&:hover:not(:disabled) {
background-color: $gray-500;
}
&:active:not(:disabled) {
background-color: $black;
}
&.bds-carousel-button--disabled,
&:disabled {
background-color: #F0F3F7 !important;
color: $gray-300 !important;
opacity: 1 !important;
cursor: not-allowed !important;
}
// Generate all light mode variant overrides using the mixin
@each $variant-name, $config in $bds-carousel-button-variants-light {
@include carousel-button-variant($variant-name, $config);
}
}

View File

@@ -3,8 +3,6 @@
// Styles for link arrow icons with hover animations that transform arrow to chevron
// Animation: 150ms with custom cubic-bezier per Figma specs
@import "../../../styles/_colors.scss";
// Animation timing per Figma
$bds-link-transition-duration: 150ms;
$bds-link-transition-timing: cubic-bezier(0.98, 0.12, 0.12, 0.98);

View File

@@ -6,8 +6,6 @@
// Dark mode colors per Figma: Enabled=green-300, Hover/Focus=green-200+underline,
// Active=green-300+underline, Visited=lilac-300, Disabled=gray-400 (#8A919A)
@import "../../../styles/_colors.scss";
// Base link styles
.bds-link {
display: inline-flex;

View File

@@ -2,8 +2,9 @@
// -----------------------------------------------------------------------------
// A namespaced grid layer that reuses Bootstrap's grid mixins while providing
// XRPL-specific gutters and container padding.
$bds-grid-gutter: 8px;
//
// Note: $bds-grid-gutter is now defined in _spacing.scss as the single source
// of truth for grid gutter spacing (8px).
// Custom mixin that accounts for gap spacing in width calculations
@mixin bds-make-col($size, $columns) {
@@ -17,6 +18,21 @@ $bds-grid-gutter: 8px;
width: calc(((100% - (#{$bds-grid-gutter} * (#{$columns} - 1))) / #{$columns}) * #{$size} + (#{$bds-grid-gutter} * (#{$size} - 1)));
}
// Custom mixin that accounts for gap spacing in offset calculations
@mixin bds-make-col-offset($size, $columns) {
// Calculate margin-left accounting for gap spacing
// Formula: (width per column * offset) + (gap * offset)
// This accounts for both the column widths AND the gaps between them
// Total available width: 100% - (gap * total gaps in grid)
// Width per column: available width / columns
// For offset of $size: (width per column * size) + (gap * size)
@if $size == 0 {
margin-left: 0;
} @else {
margin-left: calc(((100% - (#{$bds-grid-gutter} * (#{$columns} - 1))) / #{$columns}) * #{$size} + (#{$bds-grid-gutter} * #{$size}));
}
}
@mixin bds-grid-generate-cols($columns, $suffix: null) {
@for $i from 1 through $columns {
$selector: if($suffix, ".bds-grid__col-#{$suffix}-#{$i}", ".bds-grid__col-#{$i}");
@@ -49,7 +65,7 @@ $bds-grid-gutter: 8px;
$selector: if($suffix, ".bds-grid__offset-#{$suffix}-#{$i}", ".bds-grid__offset-#{$i}");
#{$selector} {
@include make-col-offset($i, $columns);
@include bds-make-col-offset($i, $columns);
}
}
}
@@ -170,4 +186,18 @@ $bds-grid-gutter: 8px;
@include bds-grid-generate-cols(12, 'xl');
@include bds-grid-generate-auto('xl');
@include bds-grid-generate-offsets(12, 'xl');
}
}
// Polymorphic as="li" — when Col renders as li, override list-item defaults
// so flex layout and width calculations apply correctly
li.bds-grid__col {
list-style: none;
display: block;
}
// Polymorphic as="ul" — when Row renders as ul, reset list styling
ul.bds-grid__row {
list-style: none;
padding-left: 0;
margin: 0;
}

View File

@@ -7,19 +7,24 @@ type PageGridElementProps = React.HTMLAttributes<HTMLDivElement>;
export type PageGridBreakpoint = "base" | "sm" | "md" | "lg" | "xl";
// Define the ResponsiveValue type using Partial<Record> for breakpoints
type ResponsiveValue<T> = T | Partial<Record<PageGridBreakpoint, T>>;
export type ResponsiveValue<T> = T | Partial<Record<PageGridBreakpoint, T>>;
export interface PageGridProps extends PageGridElementProps {
/** Container layout type - "standard" (default) or "wide" (1504px max-width, 144px padding at xl breakpoint) */
containerType?: "standard" | "wide";
}
export interface PageGridRowProps extends PageGridElementProps {}
export interface PageGridRowProps extends PageGridElementProps {
/** Polymorphic element - e.g. "ul" for semantic list markup */
as?: React.ElementType;
}
type PageGridSpanValue = number | "auto" | "fill";
export type PageGridSpanValue = number | "auto" | "fill";
type PageGridOffsetValue = number;
export interface PageGridColProps extends PageGridElementProps {
/** Polymorphic element - e.g. "li" for semantic list markup */
as?: React.ElementType;
span?: ResponsiveValue<PageGridSpanValue>;
offset?: ResponsiveValue<PageGridOffsetValue>;
}
@@ -85,17 +90,17 @@ PageGridRoot.displayName = "PageGrid";
// --- PageGrid.Row Component ---
const PageGridRow = React.forwardRef<HTMLDivElement, PageGridRowProps>(
({ className, ...rest }, ref) => (
<div ref={ref} className={clsx("bds-grid__row", className)} {...rest} />
const PageGridRow = React.forwardRef<HTMLElement, PageGridRowProps>(
({ as: Component = "div", className, ...rest }, ref) => (
<Component ref={ref} className={clsx("bds-grid__row", className)} {...rest} />
)
);
PageGridRow.displayName = "PageGridRow"; // Renamed display name for clarity
PageGridRow.displayName = "PageGridRow";
// --- PageGrid.Col Component ---
const PageGridCol = React.forwardRef<HTMLDivElement, PageGridColProps>((props, ref) => {
const { className, span, offset, ...rest } = props;
const PageGridCol = React.forwardRef<HTMLElement, PageGridColProps>((props, ref) => {
const { as: Component = "div", className, span, offset, ...rest } = props;
const spanClasses: string[] = [];
const offsetClasses: string[] = [];
@@ -151,10 +156,9 @@ const PageGridCol = React.forwardRef<HTMLDivElement, PageGridColProps>((props, r
// Ensure the base class is always applied for styling
return (
<div
<Component
ref={ref}
// Note: Added "bds-grid__col" base class for consistent column initialization
className={clsx("bds-grid__col", className, spanClasses, offsetClasses)}
className={clsx("bds-grid__col", className, spanClasses, offsetClasses)}
{...rest}
/>
);

View File

@@ -1,23 +1,28 @@
// =============================================================================
// BDS StandardCard Component Styles
// =============================================================================
// Note: Uses centralized spacing tokens from _spacing.scss.
.bds-standard-card {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 16px;
padding: $bds-space-lg; // 16px - spacing('lg')
width: 100%;
height: 100%; // Stretch to fill parent column (ensures equal heights in grid rows)
// aspect-ratio sets a preferred ratio but allows growth if content requires more space
// The card will be at least this tall, but can grow taller if needed
aspect-ratio: 4/3;
gap: 16px;
gap: $bds-space-lg; // 16px - spacing('lg')
@include media-breakpoint-up(md) {
aspect-ratio: 1;
padding: 20px;
padding: $bds-space-xl; // 20px - spacing('xl')
}
@include media-breakpoint-up(lg) {
padding: 24px;
padding: $bds-space-2xl; // 24px - spacing('2xl')
}
&#{&}--neutral {
@@ -39,13 +44,13 @@
&__content {
display: flex;
flex-direction: column;
gap: 8px;
gap: $bds-space-sm; // 8px - spacing('sm')
width: 100%;
color: $bds-btn-neutral-black;
@include media-breakpoint-up(lg) {
gap: 16px;
gap: $bds-space-lg; // 16px - spacing('lg')
}
}

View File

@@ -6,6 +6,7 @@ import {
isEnvironment,
isEmpty,
} from "../../utils";
import { DesignConstrainedCallToActionsProps } from "shared/utils/types";
/**
* Available background color variants for StandardCard:
@@ -16,11 +17,13 @@ import {
*/
export type StandardCardVariant = "neutral" | "green" | "yellow" | "blue";
export interface StandardCardProps extends React.ComponentPropsWithoutRef<"article"> {
export interface StandardCardProps
extends
React.ComponentPropsWithoutRef<"article">,
DesignConstrainedCallToActionsProps {
headline: React.ReactNode;
/** Background color variant */
variant: StandardCardVariant;
callsToAction: [DesignConstrainedButtonProps, DesignConstrainedButtonProps?];
children?: React.ReactNode;
}
@@ -46,7 +49,7 @@ const StandardCard = forwardRef<HTMLElement, StandardCardProps>(
const [primaryButton, secondaryButton] = callsToAction;
const hasButtons = callsToAction.some((button) => button !== undefined);
const hasButtons = callsToAction.some((button) => !isEmpty(button));
if (!headline) {
if (isEnvironment("development")) {

View File

@@ -24,18 +24,20 @@
// =============================================================================
// Design Tokens from Figma
// =============================================================================
// Note: Uses centralized spacing tokens from _spacing.scss where applicable.
// Component-specific values (heights, max-widths) remain local.
// Card internal padding
$bds-text-card-padding-mobile: 16px;
$bds-text-card-padding-tablet: 20px;
$bds-text-card-padding-desktop: 24px;
// Card internal padding - uses centralized spacing tokens
$bds-text-card-padding-mobile: $bds-space-lg; // 16px - spacing('lg')
$bds-text-card-padding-tablet: $bds-space-xl; // 20px - spacing('xl')
$bds-text-card-padding-desktop: $bds-space-2xl; // 24px - spacing('2xl')
// Card heights (fixed per breakpoint)
// Card heights (fixed per breakpoint) - component-specific
$bds-text-card-height-mobile: 274px;
$bds-text-card-height-tablet: 309px;
$bds-text-card-height-desktop: 340px;
// Card description max-width (from Figma)
// Card description max-width (from Figma) - component-specific
$bds-text-card-description-max-width: 478px;
// Colors - Light Mode (from Figma)

View File

@@ -11,9 +11,9 @@
// .bds-tile-logo--disabled - Disabled state modifier
// .bds-tile-logo__overlay - Hover gradient overlay (window shade animation)
// .bds-tile-logo__image - Logo image element
// Grid gutter (matching PageGrid)
$bds-grid-gutter: 8px;
//
// Note: Uses centralized spacing tokens from _spacing.scss.
// $bds-grid-gutter is now defined in _spacing.scss as the single source of truth.
// Focus border colors (component-specific, dark mode default)
$bds-tile-logo-focus-border-light: $black;

View File

@@ -0,0 +1,188 @@
// BDS Video Component Styles
// Brand Design System - Flexible video component supporting native video,
// YouTube/Vimeo/Wistia embeds, and cover image + modal playback.
//
// .bds-video - Container
// .bds-video__element - Native video element
// .bds-video__iframe - Embed iframe
// .bds-video__cover-button - Clickable cover (opens modal)
// .bds-video__cover-image - Cover image
// .bds-video__play-icon - Play overlay icon
// .bds-video-modal - Full-screen modal overlay
// .bds-video-modal__backdrop - Backdrop (click to close)
// .bds-video-modal__content - Modal content wrapper
// .bds-video-modal__close - Close button
// .bds-video-modal__video - Video/iframe container in modal
.bds-video {
width: 100%;
height: auto;
overflow: hidden;
position: relative;
&--aspect-16-9 {
aspect-ratio: 16 / 9;
}
&--aspect-4-3 {
aspect-ratio: 4 / 3;
}
&--aspect-1-1 {
aspect-ratio: 1 / 1;
}
&__inner {
width: 100%;
height: 100%;
position: relative;
}
&__element {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
display: block;
}
&__iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}
&__cover-button {
position: relative;
display: block;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
border: none;
background: none;
cursor: pointer;
overflow: hidden;
}
&__cover-image {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
display: block;
transition: transform 0.3s ease;
}
&__cover-button:hover &__cover-image {
transform: scale(1.02);
}
&__play-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
svg {
width: 64px;
height: 64px;
transition: transform 0.2s ease;
}
}
&__cover-button:hover &__play-icon svg {
transform: scale(1.1);
}
}
// =============================================================================
// Modal
// =============================================================================
.bds-video-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.bds-video-modal__backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
cursor: pointer;
}
.bds-video-modal__content {
position: relative;
top: 0;
left: 0;
width: 100%;
max-width: 90vw;
max-height: 90vh;
aspect-ratio: 16 / 9;
z-index: 2;
}
.bds-video-modal__close {
position: absolute;
top: -2.5rem;
right: 0;
width: 2rem;
height: 2rem;
padding: 0;
border: none;
background: transparent;
color: white;
font-size: 2rem;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.2s;
&:hover {
opacity: 0.8;
}
span {
line-height: 1;
}
}
.bds-video-modal__video {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
border-radius: 4px;
.bds-video__element,
.bds-video__iframe,
iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}
}

View File

@@ -0,0 +1,231 @@
import React, { useCallback, useEffect } from 'react';
import clsx from 'clsx';
import type { DesignConstrainedVideoProps } from 'shared/utils/types';
/** Native HTML video source */
export type VideoSourceNative = {
type: 'native';
props: DesignConstrainedVideoProps;
};
/** Embed source - raw HTML iframe code from YouTube/Vimeo/Wistia */
export type VideoSourceEmbedCode = {
type: 'embed';
embedCode: string;
embedUrl?: never;
};
/** Embed source - direct embed URL (safer, preferred) */
export type VideoSourceEmbedUrl = {
type: 'embed';
embedUrl: string;
embedCode?: never;
};
export type VideoSource =
| VideoSourceNative
| VideoSourceEmbedCode
| VideoSourceEmbedUrl;
export interface VideoProps {
/** Video source: native HTML video or embed (YouTube/Vimeo/Wistia) */
source: VideoSource;
/** Optional cover image - when provided, video shows in modal on click */
coverImage?: {
src: string;
alt: string;
};
/** Aspect ratio for container (default 16/9) */
aspectRatio?: '16/9' | '4/3' | '1/1';
/** Additional className for container */
className?: string;
}
/** Trusted embed origins for embedCode sanitization */
const TRUSTED_EMBED_ORIGINS = [
'youtube.com',
'www.youtube.com',
'youtube-nocookie.com',
'www.youtube-nocookie.com',
'vimeo.com',
'player.vimeo.com',
'fast.wistia.net',
'fast.wistia.com',
];
function isTrustedEmbedUrl(url: string): boolean {
try {
const parsed = new URL(url);
const host = parsed.hostname.toLowerCase();
return TRUSTED_EMBED_ORIGINS.some(
(origin) => host === origin || host.endsWith('.' + origin)
);
} catch {
return false;
}
}
/** Extract iframe src from embed code if from trusted origin */
function extractEmbedUrlFromCode(embedCode: string): string | null {
const iframeMatch = embedCode.match(/<iframe[^>]+src=["']([^"']+)["']/i);
if (!iframeMatch) return null;
const url = iframeMatch[1];
return isTrustedEmbedUrl(url) ? url : null;
}
export const Video = React.forwardRef<HTMLDivElement, VideoProps>(
(props, ref) => {
const {
source,
coverImage,
aspectRatio = '16/9',
className,
} = props;
const [isModalOpen, setIsModalOpen] = React.useState(false);
const closeModal = useCallback(() => {
setIsModalOpen(false);
}, []);
useEffect(() => {
if (!isModalOpen) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeModal();
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isModalOpen, closeModal]);
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) closeModal();
},
[closeModal]
);
const renderVideoContent = () => {
if (source.type === 'native') {
return (
<video
{...source.props}
className="bds-video__element"
/>
);
}
// Embed: prefer embedUrl, else extract from embedCode (trusted origins only)
let embedUrl: string | null = null;
if ('embedUrl' in source && source.embedUrl) {
embedUrl = isTrustedEmbedUrl(source.embedUrl)
? source.embedUrl
: null;
} else if ('embedCode' in source && source.embedCode) {
embedUrl = extractEmbedUrlFromCode(source.embedCode);
}
if (embedUrl) {
return (
<iframe
src={embedUrl}
title="Video"
className="bds-video__iframe"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
);
}
return null;
};
const videoContent = renderVideoContent();
if (!videoContent) return null;
const containerClass = clsx(
'bds-video',
`bds-video--aspect-${aspectRatio.replace('/', '-')}`,
className
);
if (coverImage) {
return (
<>
<div ref={ref} className={containerClass}>
<button
type="button"
className="bds-video__cover-button"
onClick={() => setIsModalOpen(true)}
aria-label="Play video"
>
<img
src={coverImage.src}
alt={coverImage.alt}
className="bds-video__cover-image"
/>
<span className="bds-video__play-icon" aria-hidden>
<svg
width="64"
height="64"
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="32"
cy="32"
r="32"
fill="rgba(0,0,0,0.5)"
/>
<path
d="M26 20v24l18-12-18-12z"
fill="white"
/>
</svg>
</span>
</button>
</div>
{isModalOpen && (
<div
className="bds-video-modal"
role="dialog"
aria-modal="true"
aria-label="Video"
>
<div
className="bds-video-modal__backdrop"
onClick={handleBackdropClick}
aria-hidden
/>
<div className="bds-video-modal__content">
<button
type="button"
className="bds-video-modal__close"
onClick={closeModal}
aria-label="Close video"
>
<span aria-hidden>&times;</span>
</button>
<div className="bds-video-modal__video">
{renderVideoContent()}
</div>
</div>
</div>
)}
</>
);
}
return (
<div ref={ref} className={containerClass}>
<div className="bds-video__inner">{videoContent}</div>
</div>
);
}
);
Video.displayName = 'Video';
export default Video;

View File

@@ -0,0 +1 @@
export { Video, type VideoProps, type VideoSource } from './Video';

View File

@@ -5,6 +5,9 @@
// .bds-button-group - Base component
// .bds-button-group--gap-none - No gap between buttons on tablet+ (0px)
// .bds-button-group--gap-small - Small gap between buttons on tablet+ (8px)
// .bds-button-group--block - Block layout for 3+ buttons (all tertiary)
//
// Note: Uses centralized spacing tokens from _spacing.scss.
// =============================================================================
// Base Component Styles
@@ -13,10 +16,10 @@
.bds-button-group {
@extend .d-flex;
@extend .flex-column;
@extend .align-items-start;
@extend .flex-wrap;
gap: 8px;
align-items: start;
gap: $bds-space-sm; // 8px - spacing('sm')
// Tablet breakpoint - horizontal layout
@include media-breakpoint-up(md) {
flex-direction: row !important;
@@ -31,13 +34,28 @@
.bds-button-group--gap-none {
// Tablet breakpoint - no gap
@include media-breakpoint-up(md) {
gap: 0px;
gap: $bds-space-none; // 0px - spacing('none')
}
}
.bds-button-group--gap-small {
// Tablet breakpoint - keep 8px gap
// Tablet breakpoint - 4px gap
@include media-breakpoint-up(md) {
gap: 4px;
gap: $bds-space-xs; // 4px - spacing('xs')
}
}
// =============================================================================
// Block Layout Modifier (3+ buttons)
// =============================================================================
.bds-button-group--block {
// Override default flex layout - force column layout on all screen sizes
flex-direction: column !important;
gap: $bds-space-lg !important; // 16px - spacing('lg')
// All buttons should be full width in block layout
.bds-btn {
width: 100%;
}
}

View File

@@ -13,84 +13,248 @@ export interface ButtonConfig {
onClick?: () => void;
}
export interface ButtonGroupValidationResult {
/** The validated and potentially trimmed list of buttons */
buttons: ButtonConfig[];
/** Whether the button list is valid and should render */
isValid: boolean;
/** True if there are valid buttons to render (convenience flag) */
hasButtons: boolean;
/** Any warnings generated during validation */
warnings: string[];
}
/**
* Validates and processes a ButtonConfig array for ButtonGroup.
*
* Performs the following validations:
* - Applies maxButtons limit if specified
* - Checks for empty button arrays
* - Validates individual button configs (label required, href or onClick recommended)
* - Automatically logs warnings in development mode
*
* @param buttons - Array of button configurations (can be undefined)
* @param maxButtons - Optional maximum number of buttons to render
* @param autoLogWarnings - Whether to automatically log warnings in development mode (default: true)
* @returns Validation result with processed buttons, validity flag, hasButtons flag, and warnings
*
* @example
* // Basic usage with auto-logging
* const validation = validateButtonGroup(buttons, 2);
* if (validation.hasButtons) {
* <ButtonGroup buttons={validation.buttons} />
* }
*
* @example
* // Disable auto-logging
* const validation = validateButtonGroup(buttons, 2, false);
* // Handle warnings manually
* validation.warnings.forEach(w => customLogger(w));
*/
export function validateButtonGroup(
buttons: ButtonConfig[] | undefined,
maxButtons?: number,
autoLogWarnings: boolean = true
): ButtonGroupValidationResult {
// Handle undefined/null buttons
if (!buttons || buttons.length === 0) {
return {
buttons: [],
isValid: false,
hasButtons: false,
warnings: []
};
}
const warnings: string[] = [];
let buttonList = [...buttons];
// Validate individual button configs
buttonList.forEach((button, index) => {
if (!button.label || button.label.trim() === '') {
warnings.push(
`[ButtonGroup] Button at index ${index} is missing a label. This button may not render correctly.`
);
}
if (!button.href && !button.onClick) {
warnings.push(
`[ButtonGroup] Button "${button.label || `at index ${index}`}" has no href or onClick. Consider adding an action.`
);
}
});
// Apply maxButtons limit if specified
if (maxButtons !== undefined && maxButtons > 0 && buttons.length > maxButtons) {
warnings.push(
`[ButtonGroup] ${buttons.length} buttons were passed but maxButtons is set to ${maxButtons}. ` +
`Only the first ${maxButtons} button(s) will be rendered.`
);
buttonList = buttonList.slice(0, maxButtons);
}
// Check for empty array
if (buttonList.length === 0) {
warnings.push(
`[ButtonGroup] No buttons to render. ` +
`Either an empty buttons array was passed or all buttons were removed by maxButtons limit.`
);
// Auto-log warnings in development mode
if (autoLogWarnings && process.env.NODE_ENV === 'development' && warnings.length > 0) {
warnings.forEach(warning => console.warn(warning));
}
return { buttons: [], isValid: false, hasButtons: false, warnings };
}
// Auto-log warnings in development mode
if (autoLogWarnings && process.env.NODE_ENV === 'development' && warnings.length > 0) {
warnings.forEach(warning => console.warn(warning));
}
const hasButtons = buttonList.length > 0;
return { buttons: buttonList, isValid: true, hasButtons, warnings };
}
export interface ButtonGroupProps {
/** Primary button configuration */
primaryButton?: ButtonConfig;
/** Tertiary button configuration */
tertiaryButton?: ButtonConfig;
/** Array of button configurations
* - 1 button: renders with singleButtonVariant (default: primary)
* - 2 buttons: first as primary, second as tertiary
* - 3+ buttons: all tertiary in block layout
*/
buttons: ButtonConfig[];
/** Button color theme */
color?: 'green' | 'black';
/** Whether to force the color to remain constant regardless of theme mode */
forceColor?: boolean;
forceColor?: boolean;
/** Gap between buttons on tablet+ (0px or 4px) */
gap?: 'none' | 'small';
/** Additional CSS classes */
className?: string;
/** Override variant for single button (default: 'primary', can be 'secondary') */
singleButtonVariant?: 'primary' | 'secondary';
/** Maximum number of buttons to render. If more buttons are passed, only the first N will be rendered. */
maxButtons?: number;
}
/**
* ButtonGroup Component
*
* A responsive button group container that displays primary and/or tertiary buttons.
* Stacks vertically on mobile and horizontally on tablet+.
*
*
* A responsive button group container that displays buttons with adaptive layout:
* - 1 button: Renders with singleButtonVariant (default: primary, can be secondary)
* - 2 buttons: First as primary, second as tertiary (responsive layout)
* - 3+ buttons: All tertiary in block layout
*
* @example
* // Basic usage with both buttons
* // Single button
* <ButtonGroup
* primaryButton={{ label: "Get Started", href: "/start" }}
* tertiaryButton={{ label: "Learn More", href: "/learn" }}
* buttons={[{ label: "Get Started", href: "/start" }]}
* color="green"
* />
*
*
* @example
* // With custom gap
* // Two buttons (primary + tertiary)
* <ButtonGroup
* primaryButton={{ label: "Action", onClick: handleClick }}
* color="black"
* gap="small"
* buttons={[
* { label: "Get Started", href: "/start" },
* { label: "Learn More", href: "/learn" }
* ]}
* color="green"
* />
*
* @example
* // Three or more buttons (all tertiary, block layout)
* <ButtonGroup
* buttons={[
* { label: "Option 1", href: "/option1" },
* { label: "Option 2", href: "/option2" },
* { label: "Option 3", href: "/option3" }
* ]}
* color="green"
* />
*/
export const ButtonGroup: React.FC<ButtonGroupProps> = ({
primaryButton,
tertiaryButton,
buttons,
color = 'green',
forceColor = false,
gap = 'small',
className = '',
singleButtonVariant = 'primary',
maxButtons,
}) => {
// Don't render if no buttons are provided
if (!primaryButton && !tertiaryButton) {
// Validate and process buttons
const validation = validateButtonGroup(buttons, maxButtons);
// Log warnings in development mode
if (process.env.NODE_ENV === 'development' && validation.warnings.length > 0) {
validation.warnings.forEach(warning => console.warn(warning));
}
// Don't render if validation failed
if (!validation.isValid) {
return null;
}
const buttonList = validation.buttons;
const isMultiButton = buttonList.length >= 3;
const classNames = clsx(
'bds-button-group',
`bds-button-group--gap-${gap}`,
{
'bds-button-group--block': isMultiButton,
},
className
);
// Render 3+ buttons: all tertiary in block layout
if (isMultiButton) {
return (
<div className={classNames}>
{buttonList.map((button, index) => (
<Button
key={index}
variant="tertiary"
color={color}
forceColor={forceColor}
href={button.href}
onClick={button.onClick}
forceNoPadding
>
{button.label}
</Button>
))}
</div>
);
}
// Render 1-2 buttons
// Single button: use singleButtonVariant (default: primary, can be secondary)
// Two buttons: first as primary, second as tertiary
const firstButtonVariant = buttonList.length === 1 ? singleButtonVariant : 'primary';
return (
<div className={classNames}>
{primaryButton && (
{buttonList[0] && (
<Button
variant="primary"
variant={firstButtonVariant}
color={color}
forceColor={forceColor}
href={primaryButton.href}
onClick={primaryButton.onClick}
href={buttonList[0].href}
onClick={buttonList[0].onClick}
>
{primaryButton.label}
{buttonList[0].label}
</Button>
)}
{tertiaryButton && (
{buttonList[1] && (
<Button
variant="tertiary"
color={color}
forceColor={forceColor}
href={tertiaryButton.href}
onClick={tertiaryButton.onClick}
href={buttonList[1].href}
onClick={buttonList[1].onClick}
>
{tertiaryButton.label}
{buttonList[1].label}
</Button>
)}
</div>

View File

@@ -1,38 +1,76 @@
# ButtonGroup Component
A responsive button group container that displays primary and/or tertiary buttons. Stacks vertically on mobile and horizontally on tablet+.
A responsive button group container that automatically assigns button variants based on the number of buttons passed. Stacks vertically on mobile and horizontally on tablet+.
## Features
- **Responsive Layout**: Vertical stack on mobile, horizontal row on tablet+
- **Flexible Configuration**: Support for primary, tertiary, or both buttons
- **Auto-Variant Assignment**: Automatically assigns Primary/Tertiary/Secondary variants based on button count
- **Responsive Layout**: Vertical stack on mobile, horizontal row on tablet+ (for 1-2 buttons)
- **Block Layout**: 3+ buttons render as all tertiary in a vertical block layout
- **Customizable Spacing**: Control gap between buttons on tablet+ (none or small)
- **Theme Support**: Green or black color themes
- **Max Buttons Limit**: Optionally limit the number of buttons rendered
## Button Behavior
The component automatically determines button variants based on count:
| Count | Behavior |
|-------|----------|
| 1 button | Renders as Primary (or Secondary with `singleButtonVariant="secondary"`) |
| 2 buttons | First as Primary, second as Tertiary (responsive layout) |
| 3+ buttons | All as Tertiary in block layout (vertical on all screen sizes) |
## Usage
```tsx
import { ButtonGroup } from 'shared/patterns/ButtonGroup';
// Basic usage with both buttons
// Single button (Primary by default)
<ButtonGroup
primaryButton={{ label: "Get Started", href: "/start" }}
tertiaryButton={{ label: "Learn More", href: "/learn" }}
buttons={[
{ label: "Get Started", href: "/start" }
]}
color="green"
/>
// With no gap on tablet+
// Single button as Secondary
<ButtonGroup
primaryButton={{ label: "Action", onClick: handleClick }}
color="black"
gap="none"
buttons={[
{ label: "Learn More", href: "/learn" }
]}
singleButtonVariant="secondary"
color="green"
/>
// With small gap on tablet+ (4px - default)
// Two buttons (auto: Primary + Tertiary)
<ButtonGroup
primaryButton={{ label: "Primary Action", href: "/action" }}
tertiaryButton={{ label: "Secondary", href: "/secondary" }}
gap="small"
buttons={[
{ label: "Get Started", href: "/start" },
{ label: "Learn More", href: "/learn" }
]}
color="green"
/>
// Three or more buttons (auto: all Tertiary, block layout)
<ButtonGroup
buttons={[
{ label: "Documentation", href: "/docs" },
{ label: "API Reference", href: "/api" },
{ label: "Tutorials", href: "/tutorials" }
]}
color="black"
/>
// Limit to 2 buttons even if more are passed
<ButtonGroup
buttons={[
{ label: "First", href: "/first" },
{ label: "Second", href: "/second" },
{ label: "Third (not rendered)", href: "/third" }
]}
maxButtons={2}
color="green"
/>
```
@@ -40,10 +78,12 @@ import { ButtonGroup } from 'shared/patterns/ButtonGroup';
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `primaryButton` | `ButtonConfig` | - | Primary button configuration |
| `tertiaryButton` | `ButtonConfig` | - | Tertiary button configuration |
| `buttons` | `ButtonConfig[]` | *required* | Array of button configurations |
| `color` | `'green' \| 'black'` | `'green'` | Button color theme |
| `forceColor` | `boolean` | `false` | Force color to remain constant across light/dark modes |
| `gap` | `'none' \| 'small'` | `'small'` | Gap between buttons on tablet+ (0px or 4px) |
| `singleButtonVariant` | `'primary' \| 'secondary'` | `'primary'` | Variant for single button |
| `maxButtons` | `number` | - | Maximum number of buttons to render |
| `className` | `string` | `''` | Additional CSS classes |
### ButtonConfig
@@ -53,6 +93,7 @@ interface ButtonConfig {
label: string;
href?: string;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
forceColor?: boolean;
}
```

View File

@@ -1,2 +1,2 @@
export { ButtonGroup } from './ButtonGroup';
export type { ButtonGroupProps, ButtonConfig } from './ButtonGroup';
export { ButtonGroup, validateButtonGroup } from './ButtonGroup';
export type { ButtonGroupProps, ButtonConfig, ButtonGroupValidationResult } from './ButtonGroup';

View File

@@ -1,103 +0,0 @@
// BDS CardStats Pattern Styles
// Brand Design System - Section with heading, description, and grid of CardStat components
//
// Naming Convention: BEM with 'bds' namespace
// .bds-card-stats - Base section container
// .bds-card-stats__header - Header wrapper for heading and description
// .bds-card-stats__heading - Section heading (uses .h-md)
// .bds-card-stats__description - Section description (uses .body-l)
// .bds-card-stats__cards - Cards grid container
// .bds-card-stats__card-wrapper - Individual card wrapper
//
// Design tokens from Figma:
// Light Mode:
// - Background: White (#FFFFFF)
// - Heading: Neutral Black (#141414) → $black
// - Description: Neutral Black (#141414) → $black
//
// Dark Mode:
// - Background: transparent (inherits page background)
// - Heading: Neutral White (#FFFFFF) → $white
// - Description: Neutral White (#FFFFFF) → $white
//
// - Header content max-width: 808px (approximately 8 columns at desktop)
// - Gap between heading and description: 16px
// - Gap between cards: 8px (matches $bds-grid-gutter)
// Color tokens - Light Mode (from Figma: node 32051-2839)
$bds-card-stats-heading-light: $black; // --neutral/black (#141414)
$bds-card-stats-description-light: $black; // --neutral/black (#141414)
// Color tokens - Dark Mode (from Figma: node 32051-2524)
$bds-card-stats-bg-dark: transparent; // Inherits page background
$bds-card-stats-heading-dark: $white; // --neutral/white (#FFFFFF)
$bds-card-stats-description-dark: $white; // --neutral/white (#FFFFFF)
// Spacing - Header gap (between heading and description)
$bds-card-stats-header-gap-base: 8px; // Base: 8px
$bds-card-stats-header-gap-lg: 16px; // Desktop: 16px
// Spacing - Section gap (between header and cards)
$bds-card-stats-section-gap-sm: 24px; // Mobile: 24px
$bds-card-stats-section-gap-md: 32px; // Tablet: 32px
$bds-card-stats-section-gap-lg: 40px; // Desktop: 40px
// Spacing - Section padding
$bds-card-stats-padding-y-sm: 24px; // Mobile: 24px
$bds-card-stats-padding-y-md: 32px; // Tablet: 32px
$bds-card-stats-padding-y-lg: 40px; // Desktop: 40px
// =============================================================================
// Base Section Styles
// =============================================================================
.bds-card-stats {
// Vertical padding
padding-top: $bds-card-stats-padding-y-sm;
padding-bottom: $bds-card-stats-padding-y-sm;
@include media-breakpoint-up(md) {
padding-top: $bds-card-stats-padding-y-md;
padding-bottom: $bds-card-stats-padding-y-md;
}
@include media-breakpoint-up(lg) {
padding-top: $bds-card-stats-padding-y-lg;
padding-bottom: $bds-card-stats-padding-y-lg;
}
}
// =============================================================================
// Header Styles
// =============================================================================
.bds-card-stats__header {
display: flex;
flex-direction: column;
gap: $bds-card-stats-header-gap-base;
margin-bottom: $bds-card-stats-section-gap-sm;
@include media-breakpoint-up(md) {
margin-bottom: $bds-card-stats-section-gap-md;
}
@include media-breakpoint-up(lg) {
gap: $bds-card-stats-header-gap-lg;
margin-bottom: $bds-card-stats-section-gap-lg;
}
}
// =============================================================================
// Dark Mode Styles
// =============================================================================
html.dark {
.bds-card-stats__heading,
.bds-card-stats__description {
color: $white;
}
}

View File

@@ -1,3 +0,0 @@
export { CardStats, type CardStatsProps, type CardStatsCardConfig } from './CardStats';
export { default } from './CardStats';

View File

@@ -7,21 +7,23 @@
//
// Naming Convention: BEM with 'bds' namespace
// .bds-carousel-featured - Base section container
// .bds-carousel-featured__container - Two-column grid container
// .bds-carousel-featured__media - Image/media column
// .bds-carousel-featured__media-col - Image/media column wrapper
// .bds-carousel-featured__content-col - Content column wrapper
// .bds-carousel-featured__content - Content column
// .bds-carousel-featured__header - Header row (heading + nav)
// .bds-carousel-featured__heading - Section heading
// .bds-carousel-featured__nav - Navigation buttons wrapper
// .bds-carousel-featured__bottom - Bottom section (features + CTA)
// .bds-carousel-featured__features - Feature list container
// .bds-carousel-featured__feature - Individual feature item
// .bds-carousel-featured__feature-title - Feature title
// .bds-carousel-featured__feature-description - Feature description
// .bds-carousel-featured__cta - CTA section (buttons + mobile nav)
// .bds-carousel-featured__buttons - Primary + tertiary buttons
// .bds-carousel-featured__buttons - Button group wrapper
// .bds-carousel-featured__slides - Slides container
// .bds-carousel-featured__slide-track - Sliding track
// .bds-carousel-featured__slide - Individual slide
// .bds-carousel-featured__slide--active - Active slide modifier
// .bds-carousel-featured__image - Slide image
//
// Note: This file is imported within xrpl.scss after Bootstrap and project
@@ -30,19 +32,131 @@
// =============================================================================
// Design Tokens (from Figma)
// =============================================================================
// Note: Uses centralized spacing tokens from _spacing.scss.
// Spacing
$bds-carousel-featured-padding-sm: 16px;
$bds-carousel-featured-padding-md: 24px;
$bds-carousel-featured-padding-lg: 32px;
$bds-carousel-featured-padding-y-lg: 40px;
$bds-carousel-featured-padding-sm: $bds-space-2xl $bds-space-lg; // 24px 16px
$bds-carousel-featured-padding-md: $bds-space-3xl $bds-space-2xl; // 32px 24px
$bds-carousel-featured-padding-lg: $bds-space-4xl $bds-space-3xl; // 40px 32px
// Content gap between image and content columns
$bds-carousel-featured-column-gap: 8px;
$bds-carousel-featured-column-gap: $bds-space-sm; // 8px - spacing('sm')
// Transition
$bds-carousel-featured-transition: 400ms cubic-bezier(0.4, 0, 0.2, 1);
// =============================================================================
// Color Variant Configuration Map
// =============================================================================
// Define all background variants with their color properties (Dark Mode)
$bds-carousel-featured-variants: (
'grey': (
'bg-color': $gray-300,
'text-color': $black,
'divider-color': $black,
'button-variant': 'black',
'button-bg': $black,
'button-color': $white,
'button-hover': $gray-500,
'button-active': $black
),
'neutral': (
'bg-color': $black,
'text-color': $white,
'divider-color': $white,
'button-variant': 'green',
'button-bg': $green-300,
'button-color': $black,
'button-hover': $green-200,
'button-active': $green-300
),
'yellow': (
'bg-color': $yellow-100,
'text-color': $black,
'divider-color': $black,
'button-variant': 'black',
'button-bg': $black,
'button-color': $white,
'button-hover': $gray-500,
'button-active': $black
)
);
// Define light mode variant overrides
$bds-carousel-featured-variants-light: (
'grey': (
'bg-color': $gray-200,
'text-color': $black,
'divider-color': $black
),
'neutral': (
'bg-color': $white,
'text-color': $black,
'divider-color': $black
),
'yellow': (
'bg-color': $yellow-100,
'text-color': $black,
'divider-color': $black
)
);
// =============================================================================
// Mixins: Apply Background Variant Styles
// =============================================================================
// Full variant mixin (for dark mode with button styles)
@mixin carousel-featured-variant($variant-name, $config) {
&--bg-#{$variant-name} {
background-color: map-get($config, 'bg-color');
// Text colors
.bds-carousel-featured__heading,
.bds-carousel-featured__feature-title,
.bds-carousel-featured__feature-description {
color: map-get($config, 'text-color');
}
// Divider color
.bds-divider {
background-color: map-get($config, 'divider-color');
}
// Carousel nav buttons - enabled states only
// Disabled states are handled by CarouselButton component styles
.bds-carousel-button--#{map-get($config, 'button-variant')} {
background-color: map-get($config, 'button-bg');
color: map-get($config, 'button-color');
&:hover:not(:disabled) {
background-color: map-get($config, 'button-hover');
}
&:active:not(:disabled) {
background-color: map-get($config, 'button-active');
}
}
}
}
// Light mode variant mixin (only colors, no button states)
@mixin carousel-featured-variant-light($variant-name, $config) {
.bds-carousel-featured--bg-#{$variant-name} {
background-color: map-get($config, 'bg-color');
.bds-carousel-featured__heading,
.bds-carousel-featured__feature-title,
.bds-carousel-featured__feature-description {
color: map-get($config, 'text-color');
}
.bds-divider {
background-color: map-get($config, 'divider-color');
}
}
}
// =============================================================================
// Base Container Styles
// =============================================================================
@@ -63,7 +177,7 @@ $bds-carousel-featured-transition: 400ms cubic-bezier(0.4, 0, 0.2, 1);
// Desktop
@include media-breakpoint-up(lg) {
padding: $bds-carousel-featured-padding-y-lg $bds-carousel-featured-padding-lg;
padding: $bds-carousel-featured-padding-lg;
}
// Max width constraint
@@ -76,147 +190,13 @@ $bds-carousel-featured-transition: 400ms cubic-bezier(0.4, 0, 0.2, 1);
// ---------------------------------------------------------------------------
// Background Color Variants
// ---------------------------------------------------------------------------
// Each variant has light/dark mode colors defined below in html.light/html.dark sections
// Default styles are for dark mode (site default)
// Grey variant - Light: gray-200, Dark: gray-300
// Both modes use black text (light backgrounds)
&--bg-grey {
background-color: $gray-300; // Dark mode default
// Black text on grey background (both modes)
.bds-carousel-featured__heading,
.bds-carousel-featured__feature-title,
.bds-carousel-featured__feature-description {
color: $black;
}
// Divider: dark on light background
.bds-divider {
background-color: $black;
}
// Carousel nav buttons: always black background (both modes) - enabled states only
// Disabled states are handled by CarouselButton component styles
.bds-carousel-button--black {
background-color: $black;
color: $white;
&:hover:not(:disabled) {
background-color: $gray-500;
}
&:active:not(:disabled) {
background-color: $black;
}
// Dark Mode (default) - Generate all variant styles using the mixin
@include bds-theme-mode(dark) {
@each $variant-name, $config in $bds-carousel-featured-variants {
@include carousel-featured-variant($variant-name, $config);
}
}
// Neutral variant - Light: white, Dark: black
&--bg-neutral {
background-color: $black; // Dark mode default
// Dark mode: light text on dark background
.bds-carousel-featured__heading,
.bds-carousel-featured__feature-title,
.bds-carousel-featured__feature-description {
color: $white;
}
// Divider: light on dark background
.bds-divider {
background-color: $white;
}
// Carousel nav buttons: always green background (both modes) - enabled states only
// Disabled states are handled by CarouselButton component styles
.bds-carousel-button--green {
background-color: $green-300;
color: $black;
&:hover:not(:disabled) {
background-color: $green-200;
}
&:active:not(:disabled) {
background-color: $green-300;
}
}
}
// Yellow variant - Light: yellow-100, Dark: yellow-100 (same for both)
&--bg-yellow {
background-color: $yellow-100;
// Yellow is a light background, so dark text
.bds-carousel-featured__heading,
.bds-carousel-featured__feature-title {
color: $black;
}
.bds-carousel-featured__feature-description {
color: $black;
}
// Divider: dark on light background
.bds-divider {
background-color: $black;
}
// Carousel nav buttons: always black background (both modes) - enabled states only
// Disabled states are handled by CarouselButton component styles
.bds-carousel-button--black {
background-color: $black;
color: $white;
&:hover:not(:disabled) {
background-color: $gray-500;
}
&:active:not(:disabled) {
background-color: $black;
}
}
}
}
// =============================================================================
// PageGrid Container
// =============================================================================
.bds-carousel-featured__container {
// Reset PageGrid container padding - section already has padding
&.bds-grid__container {
padding-left: 0;
padding-right: 0;
max-width: none;
}
// Override row to handle column ordering on mobile/tablet
.bds-grid__row {
// On mobile/tablet, reverse the order so content is above image
flex-direction: column-reverse;
gap: 24px; // Gap between content and image on mobile
@include media-breakpoint-up(md) {
gap: 32px; // Gap on tablet
}
@include media-breakpoint-up(lg) {
flex-direction: row;
align-items: stretch; // Stretch both columns to same height
gap: 8px; // Column gap on desktop
}
}
}
// =============================================================================
// Media Column (Image)
// =============================================================================
.bds-carousel-featured__media {
width: 100%;
height: 100%;
}
// =============================================================================
@@ -231,7 +211,7 @@ $bds-carousel-featured-transition: 400ms cubic-bezier(0.4, 0, 0.2, 1);
// Stretch to match image height
align-self: stretch;
// Add 8px padding-left to create 16px total gap (8px row gap + 8px padding)
padding-left: 8px;
padding-left: $bds-space-sm; // 8px - spacing('sm')
}
}
@@ -278,14 +258,14 @@ $bds-carousel-featured-transition: 400ms cubic-bezier(0.4, 0, 0.2, 1);
.bds-carousel-featured__bottom {
display: flex;
flex-direction: column;
gap: 24px; // Mobile
gap: $bds-space-2xl; // 24px - spacing('2xl')
@include media-breakpoint-up(md) {
gap: 32px; // Tablet
gap: $bds-space-3xl; // 32px - spacing('3xl')
}
@include media-breakpoint-up(lg) {
gap: 40px; // Desktop
gap: $bds-space-4xl; // 40px - spacing('4xl')
}
}
@@ -295,7 +275,7 @@ $bds-carousel-featured-transition: 400ms cubic-bezier(0.4, 0, 0.2, 1);
.bds-carousel-featured__nav {
display: flex;
gap: 8px;
gap: $bds-space-sm; // 8px - spacing('sm')
flex-shrink: 0;
// Desktop nav (in header row)
@@ -330,16 +310,16 @@ $bds-carousel-featured-transition: 400ms cubic-bezier(0.4, 0, 0.2, 1);
.bds-carousel-featured__feature {
display: flex;
flex-direction: column;
gap: 8px;
gap: $bds-space-sm; // 8px - spacing('sm')
width: 100%;
// Spacing between description and next divider
// Mobile/Tablet: 16px, Desktop: 24px
&:not(:first-child) {
padding-top: 16px;
padding-top: $bds-space-lg; // 16px - spacing('lg')
@include media-breakpoint-up(lg) {
padding-top: 24px;
padding-top: $bds-space-2xl; // 24px - spacing('2xl')
}
}
}
@@ -367,7 +347,7 @@ $bds-carousel-featured-transition: 400ms cubic-bezier(0.4, 0, 0.2, 1);
justify-content: space-between;
align-items: flex-end;
width: 100%;
gap: 16px;
gap: $bds-space-lg; // 16px - spacing('lg')
// Tablet+: no wrap needed
@include media-breakpoint-up(md) {
@@ -381,26 +361,13 @@ $bds-carousel-featured-transition: 400ms cubic-bezier(0.4, 0, 0.2, 1);
}
}
.bds-carousel-featured__buttons {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
// Tablet+: row layout
@include media-breakpoint-up(md) {
flex-direction: row;
gap: 0;
align-items: center;
}
}
// =============================================================================
// Slides Container
// =============================================================================
.bds-carousel-featured__slides {
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
}
@@ -453,10 +420,7 @@ html.light {
background-color: $gray-200;
.bds-carousel-featured__heading,
.bds-carousel-featured__feature-title {
color: $black;
}
.bds-carousel-featured__feature-title,
.bds-carousel-featured__feature-description {
color: $black;
}
@@ -466,64 +430,9 @@ html.light {
}
}
// Grey variant - Light mode: gray-200 background
.bds-carousel-featured--bg-grey {
background-color: $gray-200;
// Light mode: dark text on light background
.bds-carousel-featured__heading,
.bds-carousel-featured__feature-title {
color: $black;
}
.bds-carousel-featured__feature-description {
color: $black;
}
// Divider: dark on light background
.bds-divider {
background-color: $black;
}
}
// Neutral variant - Light mode: white background
.bds-carousel-featured--bg-neutral {
background-color: $white;
// Light mode: dark text on light background
.bds-carousel-featured__heading,
.bds-carousel-featured__feature-title {
color: $black;
}
.bds-carousel-featured__feature-description {
color: $black;
}
// Divider: dark on light background
.bds-divider {
background-color: $black;
}
}
// Yellow variant - Light mode: yellow-100 (same as dark mode)
.bds-carousel-featured--bg-yellow {
background-color: $yellow-100;
// Yellow is a light background, so dark text
.bds-carousel-featured__heading,
.bds-carousel-featured__feature-title {
color: $black;
}
.bds-carousel-featured__feature-description {
color: $black;
}
// Divider: dark on light background
.bds-divider {
background-color: $black;
}
// Generate all light mode variant overrides using the mixin
@each $variant-name, $config in $bds-carousel-featured-variants-light {
@include carousel-featured-variant-light($variant-name, $config);
}
}

View File

@@ -1,10 +1,9 @@
import React, { useState, useCallback } from 'react';
import clsx from 'clsx';
import { Button } from '../../components/Button';
import { CarouselButton } from '../../components/CarouselButton';
import { Divider } from '../../components/Divider';
import { PageGrid, PageGridRow, PageGridCol } from '../../components/PageGrid';
import { ButtonConfig } from '../ButtonGroup';
import { ButtonGroup, ButtonConfig, validateButtonGroup } from '../ButtonGroup/ButtonGroup';
/**
* Props for a single slide in the CarouselFeatured component
@@ -47,10 +46,8 @@ export interface CarouselFeaturedProps extends React.ComponentPropsWithoutRef<'s
heading: string;
/** Array of feature items to display in the list */
features: readonly CarouselFeatureItem[];
/** Primary button configuration (optional) */
primaryButton?: ButtonConfig;
/** Tertiary button configuration (optional) */
tertiaryButton?: ButtonConfig;
/** Button configurations (1-2 buttons supported) */
buttons?: ButtonConfig[];
/** Background color variant. Defaults to 'grey'. */
background?: CarouselFeaturedBackground;
}
@@ -70,8 +67,10 @@ export interface CarouselFeaturedProps extends React.ComponentPropsWithoutRef<'s
* { title: "Easy-to-Integrate APIs", description: "Build with common languages..." },
* { title: "Full Lifecycle Support", description: "From dev tools to deployment..." },
* ]}
* primaryButton={{ label: "Get Started", href: "/docs" }}
* tertiaryButton={{ label: "Learn More", href: "/about" }}
* buttons={[
* { label: "Get Started", href: "/docs" },
* { label: "Learn More", href: "/about" }
* ]}
* slides={[
* { id: 1, imageSrc: '/image1.jpg', imageAlt: 'Slide 1' },
* ]}
@@ -84,8 +83,7 @@ export const CarouselFeatured = React.forwardRef<HTMLElement, CarouselFeaturedPr
slides,
heading,
features,
primaryButton,
tertiaryButton,
buttons,
background = 'grey',
className,
children,
@@ -97,6 +95,10 @@ export const CarouselFeatured = React.forwardRef<HTMLElement, CarouselFeaturedPr
const canGoPrev = currentIndex > 0;
const canGoNext = currentIndex < slides.length - 1;
// Validate buttons if provided (max 2 buttons supported)
const buttonValidation = validateButtonGroup(buttons, 2);
const hasButtons = buttonValidation.hasButtons;
const goToPrev = useCallback(() => {
if (canGoPrev) {
setCurrentIndex((prev) => prev - 1);
@@ -120,8 +122,8 @@ export const CarouselFeatured = React.forwardRef<HTMLElement, CarouselFeaturedPr
const buttonVariant = background === 'neutral' ? 'green' : 'black';
return (
<section
ref={ref}
<PageGrid
ref={ref as React.Ref<HTMLDivElement>}
className={clsx(
'bds-carousel-featured',
`bds-carousel-featured--bg-${background}`,
@@ -129,155 +131,123 @@ export const CarouselFeatured = React.forwardRef<HTMLElement, CarouselFeaturedPr
)}
aria-roledescription="carousel"
aria-label={heading}
{...rest}
>
<PageGrid className="bds-carousel-featured__container">
{...rest}>
<PageGridRow>
{/* Image/Media Column - Left on desktop, bottom on mobile */}
{/* Content Column - Right on desktop, top on mobile */}
<PageGridCol
span={{ base: 4, md: 8, lg: 6 }}
className="bds-carousel-featured__media-col"
className="bds-carousel-featured__content-col order-1 order-lg-2"
>
<div className="bds-carousel-featured__media">
<div
className="bds-carousel-featured__slides"
role="group"
aria-roledescription="slide"
aria-label={`Slide ${currentIndex + 1} of ${slides.length}`}
>
<div
className="bds-carousel-featured__slide-track"
style={{ transform: `translateX(-${currentIndex * 100}%)` }}
>
{slides.map((slide, index) => (
<div
key={slide.id}
className={clsx(
'bds-carousel-featured__slide',
{ 'bds-carousel-featured__slide--active': index === currentIndex }
)}
aria-hidden={index !== currentIndex}
>
<img
src={slide.imageSrc}
alt={slide.imageAlt}
className="bds-carousel-featured__image"
loading={index === 0 ? 'eager' : 'lazy'}
/>
</div>
<div className="bds-carousel-featured__content">
{/* Header row with heading and nav buttons */}
<div className="bds-carousel-featured__header">
<h2 className="bds-carousel-featured__heading h-md">{heading}</h2>
<div className={clsx(
'bds-carousel-featured__nav',
'bds-carousel-featured__nav--desktop',
slides.length === 1 && 'd-none'
)}>
{(['prev', 'next'] as const).map((direction) => (
<CarouselButton
key={direction}
direction={direction}
variant={buttonVariant}
disabled={direction === 'prev' ? !canGoPrev : !canGoNext}
onClick={direction === 'prev' ? goToPrev : goToNext}
aria-label={direction === 'prev' ? 'Previous slide' : 'Next slide'}
/>
))}
</div>
</div>
{/* Bottom section: features + CTA grouped together */}
<div className="bds-carousel-featured__bottom">
{/* Feature list with dividers */}
<ul className="bds-carousel-featured__features">
{features.map((feature, index) => (
<li key={index} className="bds-carousel-featured__feature">
<Divider color="base" weight="regular" />
<p className="bds-carousel-featured__feature-title body-r">{feature.title}</p>
<p className="bds-carousel-featured__feature-description label-l">{feature.description}</p>
</li>
))}
</ul>
{/* CTA section with buttons and mobile nav */}
<div className="bds-carousel-featured__cta">
{/* Buttons wrapper - groups primary and tertiary together */}
{hasButtons && (
<ButtonGroup
buttons={buttonValidation.buttons}
color="black"
forceColor={background !== 'neutral'}
className="bds-carousel-featured__buttons"
/>
)}
{/* Mobile/Tablet nav buttons */}
<div className={clsx(
'bds-carousel-featured__nav',
'bds-carousel-featured__nav--mobile',
slides.length === 1 && 'd-none'
)}>
{(['prev', 'next'] as const).map((direction) => (
<CarouselButton
key={direction}
direction={direction}
variant={buttonVariant}
disabled={direction === 'prev' ? !canGoPrev : !canGoNext}
onClick={direction === 'prev' ? goToPrev : goToNext}
aria-label={direction === 'prev' ? 'Previous slide' : 'Next slide'}
/>
))}
</div>
</div>
</div>
</div>
</PageGridCol>
{/* Content Column - Right on desktop, top on mobile */}
{/* Image/Media Column - Left on desktop, bottom on mobile */}
<PageGridCol
span={{ base: 4, md: 8, lg: 6 }}
className="bds-carousel-featured__content-col"
className="bds-carousel-featured__media-col order-2 order-lg-1"
>
{/* Content Column - Right on desktop, top on mobile */}
<div className="bds-carousel-featured__content">
{/* Header row with heading and nav buttons */}
<div className="bds-carousel-featured__header">
<h2 className="bds-carousel-featured__heading h-md">{heading}</h2>
<div className="bds-carousel-featured__nav bds-carousel-featured__nav--desktop">
<CarouselButton
direction="prev"
variant={buttonVariant}
disabled={!canGoPrev}
onClick={goToPrev}
aria-label="Previous slide"
/>
<CarouselButton
direction="next"
variant={buttonVariant}
disabled={!canGoNext}
onClick={goToNext}
aria-label="Next slide"
/>
</div>
</div>
{/* Bottom section: features + CTA grouped together */}
<div className="bds-carousel-featured__bottom">
{/* Feature list with dividers */}
<div className="bds-carousel-featured__features">
{features.map((feature, index) => (
<div key={index} className="bds-carousel-featured__feature">
<Divider color="base" weight="regular" />
<p className="bds-carousel-featured__feature-title body-r">{feature.title}</p>
<p className="bds-carousel-featured__feature-description label-l">{feature.description}</p>
</div>
))}
</div>
{/* CTA section with buttons and mobile nav */}
<div className={clsx(
'bds-carousel-featured__cta',
// Only one button provided (not both)
((primaryButton && !tertiaryButton) || (!primaryButton && tertiaryButton)) && 'bds-carousel-featured__cta--single-button',
// Both buttons provided
primaryButton && tertiaryButton && 'bds-carousel-featured__cta--two-buttons'
)}>
{/* Buttons wrapper - groups primary and tertiary together */}
<div className="bds-carousel-featured__buttons">
{/* Primary button */}
{primaryButton && (
<Button
variant="primary"
color="black"
forceColor={background !== 'neutral'}
href={primaryButton.href}
onClick={primaryButton.onClick}
className="bds-carousel-featured__primary-btn"
<div
className="bds-carousel-featured__slides"
role="group"
aria-roledescription="slide"
aria-label={`Slide ${currentIndex + 1} of ${slides.length}`}
>
<div
className="bds-carousel-featured__slide-track"
style={{ transform: `translateX(-${currentIndex * 100}%)` }}
>
{slides.map((slide, index) => (
<div
key={slide.id}
className={clsx(
'bds-carousel-featured__slide',
{ 'bds-carousel-featured__slide--active': index === currentIndex }
)}
aria-hidden={index !== currentIndex}
>
{primaryButton.label}
</Button>
)}
{/* Tertiary button */}
{tertiaryButton && (
<Button
variant="tertiary"
color="black"
forceColor={background !== 'neutral'}
href={tertiaryButton.href}
onClick={tertiaryButton.onClick}
className="bds-carousel-featured__tertiary-btn"
>
{tertiaryButton.label}
</Button>
)}
</div>
{/* Mobile/Tablet nav buttons */}
<div className="bds-carousel-featured__nav bds-carousel-featured__nav--mobile">
<CarouselButton
direction="prev"
variant={buttonVariant}
disabled={!canGoPrev}
onClick={goToPrev}
aria-label="Previous slide"
/>
<CarouselButton
direction="next"
variant={buttonVariant}
disabled={!canGoNext}
onClick={goToNext}
aria-label="Next slide"
/>
<img
src={slide.imageSrc}
alt={slide.imageAlt}
className="bds-carousel-featured__image"
loading={index === 0 ? 'eager' : 'lazy'}
/>
</div>
))}
</div>
</div>
</div> {/* Close bottom */}
</div>
</PageGridCol>
</PageGridRow>
</PageGrid>
{/* Render any additional children */}
{children}
</section>
</PageGrid>
);
}
);

View File

@@ -0,0 +1,117 @@
# CarouselFeatured
A featured image carousel pattern with a two-column layout on desktop (image left, content right) and single-column layout on tablet/mobile (content top, image bottom). Features a heading, feature list with dividers, optional buttons, and navigation controls.
## Usage
```tsx
import { CarouselFeatured } from '@/shared/patterns/CarouselFeatured';
<CarouselFeatured
heading="Powered by Developers"
features={[
{ title: "Easy-to-Integrate APIs", description: "Build with common languages..." },
{ title: "Full Lifecycle Support", description: "From dev tools to deployment..." },
]}
buttons={[
{ label: "Get Started", href: "/docs" },
{ label: "Learn More", href: "/about" }
]}
slides={[
{ id: 1, imageSrc: '/image1.jpg', imageAlt: 'Slide 1' },
{ id: 2, imageSrc: '/image2.jpg', imageAlt: 'Slide 2' },
]}
background="grey"
/>
```
## Props
### Required Props
| Prop | Type | Description |
|------|------|-------------|
| `heading` | `string` | Heading text displayed at the top of the content area |
| `features` | `CarouselFeatureItem[]` | Array of feature items with title and description |
| `slides` | `CarouselSlide[]` | Array of slides to display in the carousel |
### Optional Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `buttons` | `ButtonConfig[]` | `undefined` | Array of button configurations (1-2 buttons supported, uses ButtonGroup) |
| `background` | `'grey' \| 'neutral' \| 'yellow'` | `'grey'` | Background color variant |
## Type Definitions
### CarouselSlide
```tsx
interface CarouselSlide {
id: string | number; // Unique identifier for the slide
imageSrc: string; // Image source URL
imageAlt: string; // Alt text for the image
}
```
### CarouselFeatureItem
```tsx
interface CarouselFeatureItem {
title: string; // Feature title
description: string; // Feature description
}
```
### ButtonConfig
```tsx
interface ButtonConfig {
label: string; // Button text
href?: string; // Optional link URL
onClick?: () => void; // Optional click handler
forceColor?: boolean; // Force button color override
}
```
## Background Variants
The component supports three background variants that adapt to light/dark mode:
- **`grey`** (default): Light mode: gray-200 (#E6EAF0), Dark mode: gray-300 (#CAD4DF)
- **`neutral`**: Light mode: white (#FFF), Dark mode: black (#141414)
- **`yellow`**: Light mode: yellow-100 (#F3F1EB), Dark mode: yellow-100 (#F3F1EB)
## Features
- **Responsive Layout**: Two-column on desktop (lg+), single-column on mobile/tablet
- **Image Carousel**: Navigate through multiple slides with prev/next buttons
- **Auto-hide Navigation**: Navigation buttons automatically hide when only one slide is present
- **Feature List**: Display multiple features with dividers
- **Button Group**: Supports 1-2 buttons with validation
- **Background Variants**: Three color options with light/dark mode support
- **Accessibility**: Proper ARIA labels for navigation buttons
## Layout Behavior
### Desktop (lg+)
- Image column on the left (6 columns)
- Content column on the right (6 columns)
- Navigation buttons in header (desktop variant)
### Tablet/Mobile
- Content section at the top
- Image section at the bottom
- Navigation buttons in CTA section (mobile variant)
## Examples
See the [showcase page](../../../about/carousel-featured-showcase.page.tsx) for live examples with different configurations.
## Notes
- Navigation buttons are automatically hidden when `slides.length === 1`
- Buttons are validated using `validateButtonGroup` with a maximum of 2 buttons
- Button colors are automatically adjusted based on the background variant
- The component uses `ButtonGroup` pattern for consistent button styling

View File

@@ -0,0 +1,132 @@
// BDS LinkTextCard Pattern Styles
// Brand Design System - Numbered card with heading, description, and CTA buttons
//
// Naming Convention: BEM with 'bds' namespace
// .bds-link-text-card - Base card container with border-top divider
// .bds-link-text-card__header - Header section (number + heading)
// .bds-link-text-card__content - Content section (description + buttons)
//
// Note: Uses centralized spacing tokens from _spacing.scss.
// =============================================================================
// Design Tokens (from _spacing.scss)
// =============================================================================
// Header gap (between number and heading)
$bds-link-text-card-header-gap-base: $bds-space-sm; // 8px - spacing('sm')
$bds-link-text-card-header-gap-md: $bds-space-md; // 12px - spacing('md')
$bds-link-text-card-header-gap-lg: $bds-space-lg; // 16px - spacing('lg')
// Content gap (between description and buttons)
$bds-link-text-card-content-gap-base: $bds-space-lg; // 16px - spacing('lg')
$bds-link-text-card-content-gap-md: $bds-space-2xl; // 24px - spacing('2xl')
$bds-link-text-card-content-gap-lg: $bds-space-3xl; // 32px - spacing('3xl')
// Card gap (between header and content sections)
$bds-link-text-card-gap-base: $bds-gap-section-sm; // 24px - spacing('2xl')
$bds-link-text-card-gap-md: $bds-gap-section-md; // 32px - spacing('3xl')
$bds-link-text-card-gap-lg: $bds-gap-section-lg; // 40px - spacing('4xl')
// Padding
$bds-link-text-card-padding-top-base: $bds-space-sm; // 8px - spacing('sm')
$bds-link-text-card-padding-top-md: $bds-space-md; // 12px - spacing('md')
$bds-link-text-card-padding-top-lg: $bds-space-lg; // 16px - spacing('lg')
$bds-link-text-card-padding-bottom-base: $bds-space-2xl; // 24px - spacing('2xl')
$bds-link-text-card-padding-bottom-md: $bds-space-3xl; // 32px - spacing('3xl')
$bds-link-text-card-padding-bottom-lg: $bds-space-4xl; // 40px - spacing('4xl')
// Border
$bds-link-text-card-border-width: 1px;
$bds-link-text-card-border-color: $gray-300;
// Text color
$bds-link-text-card-number-color: $gray-500;
// Content width at MD+
$bds-link-text-card-content-width-md: calc(75% - 2px);
// =============================================================================
// Card Component
// =============================================================================
.bds-link-text-card {
display: flex;
flex-direction: column;
border-top: $bds-link-text-card-border-width solid $bds-link-text-card-border-color;
padding-top: $bds-link-text-card-padding-top-base;
padding-bottom: $bds-link-text-card-padding-bottom-base;
gap: $bds-link-text-card-gap-base;
@include media-breakpoint-up(md) {
padding-top: $bds-link-text-card-padding-top-md;
padding-bottom: $bds-link-text-card-padding-bottom-md;
gap: $bds-link-text-card-gap-md;
}
@include media-breakpoint-up(lg) {
padding-top: $bds-link-text-card-padding-top-lg;
padding-bottom: $bds-link-text-card-padding-bottom-lg;
gap: $bds-link-text-card-gap-lg;
}
// Remove padding-bottom for the last card in the list
&:last-child {
padding-bottom: 0;
}
@include bds-theme-mode(dark) {
color: $white;
}
}
// =============================================================================
// Header Section (Number + Heading)
// =============================================================================
.bds-link-text-card__header {
display: flex;
flex-direction: column;
gap: $bds-link-text-card-header-gap-base;
@include media-breakpoint-up(md) {
gap: $bds-link-text-card-header-gap-md;
width: $bds-link-text-card-content-width-md;
}
@include media-breakpoint-up(lg) {
gap: $bds-link-text-card-header-gap-lg;
}
p {
color: $bds-link-text-card-number-color;
}
@include bds-theme-mode(dark) {
color: $white;
}
}
// =============================================================================
// Content Section (Description + Buttons)
// =============================================================================
.bds-link-text-card__content {
display: flex;
flex-direction: column;
gap: $bds-link-text-card-content-gap-base;
@include media-breakpoint-up(md) {
gap: $bds-link-text-card-content-gap-md;
width: $bds-link-text-card-content-width-md;
}
@include media-breakpoint-up(lg) {
gap: $bds-link-text-card-content-gap-lg;
}
p {
color: $bds-link-text-card-number-color;
}
@include bds-theme-mode(dark) {
color: $white;
}
}

View File

@@ -0,0 +1,80 @@
import React from 'react';
import clsx from 'clsx';
import { ButtonGroup, ButtonConfig } from '../ButtonGroup';
export interface LinkTextCardProps {
/** Card index for numbering (displays as index + 1) */
index: number;
/** Heading text (required) */
heading: string;
/** Description text (required) */
description: string;
/** Array of button configurations (max 2) */
buttons: ButtonConfig[];
/** Additional CSS classes */
className?: string;
}
/**
* LinkTextCard Component
*
* A card component that displays a numbered item with heading, description, and action buttons.
* Features a top divider, sequential numbering (01, 02, 03...), and up to 2 call-to-action buttons.
*
* Design:
* - Flat HTML structure for minimal DOM depth
* - Fixed green button color for brand consistency
* - Responsive typography using existing design tokens
* - Full light/dark mode support
*
* @example
* // Basic usage
* <LinkTextCard
* index={0}
* heading="Fast Settlement and Low Fees"
* description="Settle transactions in 3-5 seconds for a fraction of a cent"
* buttons={[
* { label: "Get Started", href: "/start" },
* { label: "Learn More", href: "/learn" }
* ]}
* />
*
* @example
* // Single button
* <LinkTextCard
* index={1}
* heading="Secure and Reliable"
* description="Built on proven blockchain technology"
* buttons={[{ label: "Read Docs", href: "/docs" }]}
* />
*/
export const LinkTextCard: React.FC<LinkTextCardProps> = ({
index,
heading,
description,
buttons,
className,
}) => {
// Format number with zero padding (01, 02, 03...)
const formattedNumber = String(index + 1).padStart(2, '0');
// Ensure max 2 buttons for proper layout
const maxButtons = buttons.slice(0, 2);
return (
<li className={clsx('bds-link-text-card', className)}>
<div className="bds-link-text-card__header">
<p className="mb-0 body-l">{formattedNumber}</p>
<h5 className="mb-0 sh-lg-l">{heading}</h5>
</div>
<div className="bds-link-text-card__content">
<p className="mb-0 body-l">{description}</p>
{maxButtons.length > 0 && (
<ButtonGroup buttons={maxButtons} color="green" />
)}
</div>
</li>
);
};
export default LinkTextCard;

View File

@@ -0,0 +1,209 @@
# LinkTextCard Component
A numbered card component displaying a heading, description, and call-to-action buttons.
## Overview
LinkTextCard is a pattern component designed for sequential content presentation. Each card displays a numbered label (01, 02, 03...), a heading, description text, and up to 2 action buttons. Perfect for feature lists, step-by-step guides, or numbered content sections.
## Features
- **Sequential Numbering**: Auto-increments based on index prop with zero-padding (01, 02, 03...)
- **Minimal HTML Structure**: Flat DOM hierarchy for optimal performance
- **ButtonGroup Integration**: Supports up to 2 buttons (Primary + Tertiary layout)
- **Fixed Button Color**: Green buttons for brand consistency
- **Light/Dark Mode**: Full theming support
- **Responsive Design**: Adaptive spacing and typography across breakpoints
- **Top Divider**: Border-top styling with theme-aware colors
## Usage
### Basic Usage (2 Buttons)
```tsx
<LinkTextCard
index={0}
heading="Fast Settlement and Low Fees"
description="Settle transactions in 3-5 seconds for a fraction of a cent, ideal for large-scale, high-volume RWA tokenization"
buttons={[
{ label: "Get Started", href: "/start" },
{ label: "Learn More", href: "/docs" }
]}
/>
```
### Single Button
```tsx
<LinkTextCard
index={1}
heading="Secure and Reliable"
description="Built on proven blockchain technology with enterprise-grade security"
buttons={[
{ label: "Read Documentation", href: "/docs" }
]}
/>
```
### With Click Handlers
```tsx
<LinkTextCard
index={2}
heading="Developer Friendly"
description="Comprehensive APIs and SDKs for seamless integration"
buttons={[
{ label: "View API", onClick: () => navigate('/api') },
{ label: "See Examples", href: "/examples" }
]}
/>
```
### In a List
```tsx
{features.map((feature, index) => (
<LinkTextCard
key={feature.id}
index={index}
heading={feature.heading}
description={feature.description}
buttons={feature.buttons}
/>
))}
```
## Props
### LinkTextCardProps
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `index` | `number` | Required | Card index for numbering (displays as index + 1) |
| `heading` | `string` | Required | Main heading text |
| `description` | `string` | Required | Description/body text |
| `buttons` | `ButtonConfig[]` | Required | Array of button configurations (max 2) |
| `className` | `string` | - | Additional CSS classes |
### ButtonConfig (from ButtonGroup)
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `label` | `string` | Required | Button text |
| `href` | `string` | - | Link destination (renders as anchor) |
| `onClick` | `() => void` | - | Click handler (renders as button) |
| `forceColor` | `boolean` | `false` | Force color regardless of theme |
**Note**: Only the first 2 buttons will be rendered. Button color is fixed to 'green'.
## Component Structure
```tsx
<li className="bds-link-text-card">
<div className="bds-link-text-card__header">
<p className="body-l">01</p>
<h5 className="sh-lg-l">Fast Settlement and Low Fees</h5>
</div>
<div className="bds-link-text-card__content">
<p className="body-l">Settle transactions in 3-5 seconds...</p>
<ButtonGroup buttons={[...]} color="green" />
</div>
</li>
```
**Key Design Decisions:**
- **List Item Element**: Renders as `<li>` for semantic list markup
- **Two-Section Layout**: Header (number + heading) and Content (description + buttons)
- **Border-Top Divider**: Applied directly to container (no separate element)
- **Typography Classes**: Uses existing `body-l` and `sh-lg-l` classes
- **Flexbox Gap**: Responsive spacing via gap property
- **75% Width at MD+**: Content is constrained to 75% width on tablet and desktop
## Responsive Spacing
| Breakpoint | Card Gap | Header Gap | Content Gap | Padding Top | Padding Bottom |
|------------|----------|------------|-------------|-------------|----------------|
| Base (< 576px) | 24px | 8px | 16px | 8px | 24px |
| MD (576px - 991px) | 32px | 12px | 24px | 12px | 32px |
| LG (≥ 992px) | 40px | 16px | 32px | 16px | 40px |
**Card Gap**: Space between header and content sections
**Header Gap**: Space between number and heading
**Content Gap**: Space between description and ButtonGroup
**Padding Top**: Space from top border to content
**Padding Bottom**: Space after content (removed on last card)
## Styling
### CSS Classes
- `.bds-link-text-card` - Main list item container with flexbox layout and border-top
- `.bds-link-text-card__header` - Header section (number + heading)
- `.bds-link-text-card__content` - Content section (description + buttons)
- Typography utilities: `body-l`, `sh-lg-l`
### Color Variables
- `$gray-300` - Border color (light mode)
- `$gray-500` - Number text color
- `$white` - Text color in dark mode (applied to entire card)
## Number Formatting
The component automatically formats numbers with zero-padding:
```typescript
index: 0 "01"
index: 1 "02"
index: 9 "10"
index: 99 "100"
```
## Button Behavior
### 1-2 Buttons
- **1 button**: Renders as primary button
- **2 buttons**: First as primary, second as tertiary
### 3+ Buttons
If more than 2 buttons are passed, only the first 2 will be rendered (automatically sliced).
## Files
- `LinkTextCard.tsx` - React component with TypeScript
- `LinkTextCard.scss` - Minimal SCSS with BEM naming
- `index.ts` - Barrel exports
- `README.md` - This file
## Related Components
- **ButtonGroup**: Used for rendering action buttons
- **Button**: Atomic button component used by ButtonGroup
## Import
```tsx
import { LinkTextCard } from 'shared/patterns/LinkTextCard';
// or
import { LinkTextCard, type LinkTextCardProps } from 'shared/patterns/LinkTextCard';
```
## Design System
Part of the Brand Design System (BDS) with `bds-` namespace prefix.
## Best Practices
1. **Use Sequential Indices**: Pass indices 0, 1, 2... for proper numbering
2. **Limit Buttons**: Design works best with 1-2 buttons
3. **Clear Descriptions**: Keep descriptions concise but informative
4. **Consistent Length**: Try to keep similar text lengths across cards in a group
## Accessibility
- Semantic HTML: Renders as `<li>` for use within `<ul>` lists
- Heading hierarchy: Uses `<h5>` for card headings (adjust based on parent context)
- Button labels should be descriptive
- Maintains focus order: number heading description buttons
**Note on Heading Semantics**: The component uses `<h5>` inside a list item, which is acceptable when the parent section has appropriate heading hierarchy. The heading level can be adjusted based on your specific use case.

View File

@@ -0,0 +1 @@
export { LinkTextCard, type LinkTextCardProps } from './LinkTextCard';

View File

@@ -1,115 +0,0 @@
// BDS LogoSquareGrid Component Styles
// Brand Design System - Logo grid pattern with optional header section
//
// Naming Convention: BEM with 'bds' namespace
// .bds-logo-square-grid - Base component
// .bds-logo-square-grid--gray - Gray variant (maps to TileLogo 'neutral')
// .bds-logo-square-grid--green - Green variant (maps to TileLogo 'green')
// .bds-logo-square-grid__header - Header section container
// .bds-logo-square-grid__text - Text content container
// .bds-logo-square-grid__heading - Heading element
// .bds-logo-square-grid__description - Description element
//
// Note: Individual logo tiles are rendered using the TileLogo component
// Note: Button layout is handled by the ButtonGroup component
// =============================================================================
// Design Tokens
// =============================================================================
// Note: Color variants are now handled by the TileLogo component
// LogoSquareGrid 'gray' maps to TileLogo 'neutral'
// LogoSquareGrid 'green' maps to TileLogo 'green'
// Spacing tokens - responsive
// Mobile (<768px)
$bds-lsg-header-gap-mobile: 24px;
$bds-lsg-text-gap-mobile: 8px;
// Tablet (768px-1023px)
$bds-lsg-header-gap-tablet: 32px;
// Desktop (≥1024px)
$bds-lsg-header-gap-desktop: 40px;
$bds-lsg-text-gap-desktop: 16px;
// =============================================================================
// Base Component Styles
// =============================================================================
.bds-logo-square-grid {
@extend .d-flex;
@extend .flex-column;
@extend .w-100;
// Mobile-first gap
gap: $bds-lsg-header-gap-mobile;
// Tablet breakpoint
@include media-breakpoint-up(md) {
gap: $bds-lsg-header-gap-tablet;
}
// Desktop breakpoint
@include media-breakpoint-up(lg) {
gap: $bds-lsg-header-gap-desktop;
}
}
// =============================================================================
// Header Section
// =============================================================================
.bds-logo-square-grid__header {
@extend .d-flex;
@extend .flex-column;
margin-top: 24px;
margin-bottom: 24px;
// Mobile-first gap
gap: $bds-lsg-header-gap-mobile;
// Tablet breakpoint
@include media-breakpoint-up(md) {
gap: $bds-lsg-header-gap-tablet;
margin-top: 32px;
margin-bottom: 32px;
}
// Desktop breakpoint
@include media-breakpoint-up(lg) {
gap: $bds-lsg-header-gap-desktop;
margin-top: 40px;
margin-bottom: 40px;
}
}
// =============================================================================
// Text Content
// =============================================================================
.bds-logo-square-grid__text {
@extend .d-flex;
@extend .flex-column;
// Mobile-first gap
gap: $bds-lsg-text-gap-mobile;
// Desktop breakpoint
@include media-breakpoint-up(lg) {
gap: $bds-lsg-text-gap-desktop;
}
}
// =============================================================================
// Action Buttons
// =============================================================================
// Note: Button layout is now handled by the ButtonGroup component
// =============================================================================
// Logo Grid Row
// =============================================================================
// Note: Grid layout is now handled by PageGridRow/PageGridCol
// Each tile uses PageGridCol with span={{ base: 2, lg: 3 }}
// This gives us 2 columns on mobile (2/4) and 4 columns on desktop (3/12)
// Tile rendering and styling is handled by the TileLogo component

View File

@@ -0,0 +1,70 @@
// BDS SectionHeader Pattern Styles
// Brand Design System - Consolidated section header (heading + description)
//
// Naming Convention: BEM with 'bds' namespace
// .bds-section-header - Header wrapper for heading and description
// .bds-section-header__heading - Section heading (uses .h-md)
// .bds-section-header__description - Section description (uses .body-l)
//
// Design tokens from _spacing.scss:
// - Gap between heading and description: $bds-gap-header-*
// - Light/Dark mode colors: $black / $white
// =============================================================================
// Header Section
// =============================================================================
.bds-section-header {
display: flex;
flex-direction: column;
gap: $bds-gap-header-sm;
margin-bottom: $bds-gap-section-sm;
@include media-breakpoint-up(md) {
gap: $bds-gap-header-md;
margin-bottom: $bds-gap-section-md;
}
@include media-breakpoint-up(lg) {
gap: $bds-gap-header-lg;
margin-bottom: $bds-gap-section-lg;
}
}
.bds-section-header__heading {
margin: 0;
// Typography handled by .h-md class from _font.scss
}
.bds-section-header__description {
margin: 0;
// Typography handled by .body-l class from _font.scss
}
// =============================================================================
// Light Mode Styles
// =============================================================================
html.light {
.bds-section-header__heading {
color: $black;
}
.bds-section-header__description {
color: $black;
}
}
// =============================================================================
// Dark Mode Styles
// =============================================================================
html.dark {
.bds-section-header__heading {
color: $white;
}
.bds-section-header__description {
color: $white;
}
}

View File

@@ -0,0 +1,80 @@
import React from 'react';
import clsx from 'clsx';
import { PageGrid } from '../../components/PageGrid/page-grid';
import type { ResponsiveValue, PageGridSpanValue } from '../../components/PageGrid/page-grid';
const DEFAULT_SPAN = {
base: 'fill' as const,
md: 6,
lg: 8,
};
export interface SectionHeaderProps {
/** Section heading text */
heading?: React.ReactNode;
/** Section description text */
description?: React.ReactNode;
/** Polymorphic heading element - h1 through h6 */
as?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
/** PageGrid.Col span - defaults to { base: 'fill', md: 6, lg: 8 } */
span?: ResponsiveValue<PageGridSpanValue>;
/** Optional slot for trailing content (e.g. ButtonGroup) */
children?: React.ReactNode;
/** Additional CSS classes for the header wrapper */
className?: string;
}
/**
* SectionHeader - Consolidated section header pattern
*
* Renders a PageGrid.Row + Col with heading (polymorphic h1-h6) and optional description.
* Used across CardsFeatured, StandardCardGroupSection, CardsIconGrid, and other sections.
*
* Returns null if both heading and description are falsy and no children provided.
*/
export const SectionHeader = React.forwardRef<HTMLDivElement, SectionHeaderProps>(
(props, ref) => {
const {
heading,
description,
as = 'h2',
span = DEFAULT_SPAN,
children,
className,
} = props;
const hasContent = heading || description || children;
if (!hasContent) {
return null;
}
const HeadingTag = as;
return (
<PageGrid.Row>
<PageGrid.Col span={span}>
<div
ref={ref}
className={clsx('bds-section-header', className)}
>
{heading != null && heading !== '' && (
<HeadingTag className="bds-section-header__heading h-md">
{heading}
</HeadingTag>
)}
{description != null && description !== '' && (
<p className="bds-section-header__description body-l">
{description}
</p>
)}
{children}
</div>
</PageGrid.Col>
</PageGrid.Row>
);
}
);
SectionHeader.displayName = 'SectionHeader';
export default SectionHeader;

View File

@@ -0,0 +1 @@
export { SectionHeader, type SectionHeaderProps } from './SectionHeader';

View File

@@ -1,110 +0,0 @@
// BDS StandardCardGroupSection Pattern Styles
// Brand Design System - Section with headline, description, and grid of StandardCard components
//
// Naming Convention: BEM with 'bds' namespace
// .bds-standard-card-group-section - Base section container
// .bds-standard-card-group-section__header - Header wrapper for headline and description
// .bds-standard-card-group-section__headline - Section headline (uses .h-md)
// .bds-standard-card-group-section__description - Section description (uses .body-l)
//
// Design tokens from Figma:
// Light Mode:
// - Headline: Neutral Black (#141414) → $black
// - Description: Neutral Black (#141414) → $black
//
// Dark Mode:
// - Headline: Neutral White (#FFFFFF) → $white
// - Description: Neutral White (#FFFFFF) → $white
//
// - Header content max-width: 808px (approximately 8 columns at desktop)
// - Gap between headline and description: 16px
// - Gap between cards: handled by PageGrid gutter
// =============================================================================
// Design Tokens (from Figma)
// =============================================================================
// Spacing - Header gap (between headline and description)
$bds-standard-card-group-section-header-gap-sm: 8px; // Mobile: 8px
$bds-standard-card-group-section-header-gap-md: 8px; // Tablet: 8px
$bds-standard-card-group-section-header-gap-lg: 16px; // Desktop: 16px
// Spacing - Section gap (between header and cards)
$bds-standard-card-group-section-section-gap-sm: 24px; // Mobile
$bds-standard-card-group-section-section-gap-md: 32px; // Tablet
$bds-standard-card-group-section-section-gap-lg: 40px; // Desktop
// Spacing - Section padding (vertical)
$bds-standard-card-group-section-padding-y-sm: 48px; // Mobile
$bds-standard-card-group-section-padding-y-md: 64px; // Tablet
$bds-standard-card-group-section-padding-y-lg: 80px; // Desktop
// =============================================================================
// Section Container
// =============================================================================
.bds-standard-card-group-section {
width: 100%;
padding-top: $bds-standard-card-group-section-padding-y-sm;
padding-bottom: $bds-standard-card-group-section-padding-y-sm;
@include media-breakpoint-up(md) {
padding-top: $bds-standard-card-group-section-padding-y-md;
padding-bottom: $bds-standard-card-group-section-padding-y-md;
}
@include media-breakpoint-up(lg) {
padding-top: $bds-standard-card-group-section-padding-y-lg;
padding-bottom: $bds-standard-card-group-section-padding-y-lg;
}
@include bds-theme-mode(light) {
background-color: $white;
}
@include bds-theme-mode(dark) {
background-color: $black;
}
}
// =============================================================================
// Header Section
// =============================================================================
.bds-standard-card-group-section__header {
display: flex;
flex-direction: column;
gap: $bds-standard-card-group-section-header-gap-sm;
@include media-breakpoint-up(md) {
gap: $bds-standard-card-group-section-header-gap-md;
}
@include media-breakpoint-up(lg) {
gap: $bds-standard-card-group-section-header-gap-lg;
}
}
.bds-standard-card-group-section__headline {
margin: 0;
// Typography handled by .h-md class from _font.scss
}
.bds-standard-card-group-section__description {
margin: 0;
// Typography handled by .body-l class from _font.scss
}
.bds-standard-card-group-section__cards {
margin-top: $bds-standard-card-group-section-section-gap-sm;
@include media-breakpoint-up(md) {
margin-top: $bds-standard-card-group-section-section-gap-md;
}
@include media-breakpoint-up(lg) {
margin-top: $bds-standard-card-group-section-section-gap-lg;
}
}

View File

@@ -0,0 +1,110 @@
# TileLink Component
A clickable tile component for link grids, featuring text content with an arrow icon.
## Overview
TileLink is an atomic component designed for use in grid layouts. It provides a consistent, interactive tile with support for both links (`<a>`) and buttons (`<button>`), featuring smooth animations and full accessibility support.
## Features
- **Two Color Variants**: Gray and Lilac
- **Light/Dark Mode**: Full theming support
- **Five Interaction States**: Default, Hover, Focused, Pressed, Disabled
- **Window Shade Animation**: Hover color wipes from bottom to top on enter, top to bottom on exit
- **Arrow Icon**: Animated arrow that moves on hover
- **Responsive**: Adapts padding and font size across breakpoints
- **Accessible**: Proper ARIA labels, focus states, and semantic HTML
## Responsive Sizing
| Breakpoint | Height | Padding | Font Size | Line Height |
|------------|--------|---------|-----------|-------------|
| Base (< 576px) | 64px | 12px | 16px | 26.1px |
| MD (576px - 991px) | 64px | 16px | 16px | 26.1px |
| LG (≥ 992px) | 64px | 20px | 18px | 26.1px |
## Color Variants
### Gray Variant
- **Light Mode**: gray-200 background (#E6EAF0), black text
- **Dark Mode**: gray-500 background (#5A5A5E), white text
### Lilac Variant
- **Both Modes**: lilac-100 background, black text (consistent across light/dark)
## Usage
### Basic Link (Gray Variant - Default)
```tsx
<TileLink
variant="gray"
label="Documentation"
href="/docs"
/>
```
### Lilac Variant with Click Handler
```tsx
<TileLink
variant="lilac"
label="Get Started"
onClick={() => navigate('/start')}
/>
```
### Disabled State
```tsx
<TileLink
variant="gray"
label="Coming Soon"
disabled
/>
```
## Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `variant` | `'gray' \| 'lilac'` | `'gray'` | Color variant |
| `label` | `string` | Required | Link text/label |
| `href` | `string` | - | Link destination (renders as `<a>`) |
| `onClick` | `() => void` | - | Click handler (renders as `<button>`) |
| `disabled` | `boolean` | `false` | Disabled state |
| `className` | `string` | - | Additional CSS classes |
## Animation Details
- **Transition Duration**: 200ms
- **Timing Function**: cubic-bezier(0.98, 0.12, 0.12, 0.98)
- **Hover Effect**: Window shade animation using `clip-path`
- **Arrow Animation**: Translates 4px to the right on hover
## Accessibility
- Renders as semantic `<a>` when `href` is provided
- Renders as semantic `<button>` when `onClick` is provided
- Includes `aria-label` for screen readers
- Includes `aria-disabled` for disabled states
- 2px focus outline with proper offset
- Keyboard navigable
## Files
- `TileLink.tsx` - React component
- `TileLink.scss` - Styles with BEM naming convention
- `README.md` - This file
## Related Components
- **LinkSmallGrid**: Pattern component that uses TileLink in a responsive grid layout
- **LinkArrow**: Arrow icon component used within TileLink
## Design System
Part of the Brand Design System (BDS) with `bds-` namespace prefix.
## Showcase
See `about/tile-link-showcase.page.tsx` for comprehensive examples of all variants and states.

View File

@@ -0,0 +1,302 @@
// BDS TileLink Component Styles
// Brand Design System - Tile link component with responsive sizing
//
// Naming Convention: BEM with 'bds' namespace
// .bds-tile-link - Base tile (responsive layout)
// .bds-tile-link--gray - Gray color variant
// .bds-tile-link--lilac - Lilac color variant
// .bds-tile-link--hovered - Hovered state (triggers overlay animation)
// .bds-tile-link--disabled - Disabled state modifier
// .bds-tile-link__overlay - Hover gradient overlay (window shade animation)
// .bds-tile-link__content - Content wrapper (label + arrow)
// .bds-tile-link__label - Text label
// .bds-tile-link__arrow - Arrow icon wrapper
// =============================================================================
// Design Tokens
// =============================================================================
// Note: Uses centralized spacing tokens from _spacing.scss.
// Focus border
$bds-tile-link-focus-border-color-light: $black;
$bds-tile-link-focus-border-color-dark: $white;
$bds-tile-link-focus-border-width: $bds-focus-border-width; // 2px - from _spacing.scss
// Animation (matching TileLogo/CardIcon)
$bds-tile-link-transition-duration: 200ms;
$bds-tile-link-transition-timing: cubic-bezier(0.98, 0.12, 0.12, 0.98);
// -----------------------------------------------------------------------------
// Responsive Size Tokens
// -----------------------------------------------------------------------------
// SM breakpoint (mobile - default)
$bds-tile-link-height-base: $bds-space-6xl; // 64px - spacing('6xl')
$bds-tile-link-padding-sm: $bds-space-md; // 12px - spacing('md')
$bds-tile-link-font-size-sm: 16px;
$bds-tile-link-line-height-sm: 26.1px;
// MD breakpoint (tablet)
$bds-tile-link-padding-md: $bds-space-lg; // 16px - spacing('lg')
$bds-tile-link-font-size-md: 16px;
$bds-tile-link-line-height-md: 26.1px;
// LG breakpoint (desktop)
$bds-tile-link-padding-lg: $bds-space-xl; // 20px - spacing('xl')
$bds-tile-link-font-size-lg: 18px;
$bds-tile-link-line-height-lg: 26.1px;
// =============================================================================
// Color Variants Map
// =============================================================================
// Structure: variant-name: (light-bg, light-hover, light-pressed, light-text, dark-bg, dark-hover, dark-pressed, dark-text)
$bds-tile-link-variants: (
'gray': (
light-bg: $gray-200, // #E6EAF0
light-hover: $gray-300, // #CAD4DF
light-pressed: $gray-400, // #A8B4C4
light-text: $black, // #141414
dark-bg: $gray-500, // #5A5A5E
dark-hover: $gray-400, // #A8B4C4
dark-pressed: rgba($gray-500, 0.7), // 70% opacity
dark-text: $white // #FFFFFF
),
'lilac': (
light-bg: $lilac-100, // Light mode background
light-hover: $lilac-200, // Light mode hover
light-pressed: $lilac-300, // Light mode pressed
light-text: $black, // Light mode text
dark-bg: $lilac-100, // Dark mode background (same as light)
dark-hover: $lilac-200, // Dark mode hover (same as light)
dark-pressed: $lilac-300, // Dark mode pressed (same as light)
dark-text: $black // Dark mode text (same as light)
)
);
// =============================================================================
// Base Tile Styles
// =============================================================================
.bds-tile-link {
// Reset button/anchor styles
appearance: none;
border: none;
background: none;
margin: 0;
font: inherit;
color: inherit;
text-decoration: none;
text-align: left;
// Layout
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
box-sizing: border-box;
width: 100%;
// Responsive sizing - SM (mobile-first)
height: $bds-tile-link-height-base;
padding: $bds-tile-link-padding-sm;
@media (min-width: map-get($grid-breakpoints, md)) {
padding: $bds-tile-link-padding-md;
}
@media (min-width: map-get($grid-breakpoints, lg)) {
padding: $bds-tile-link-padding-lg;
}
// Interaction
cursor: pointer;
// Transitions
transition:
background-color $bds-tile-link-transition-duration $bds-tile-link-transition-timing,
opacity $bds-tile-link-transition-duration $bds-tile-link-transition-timing;
// Hover styles - prevent text underline
&:hover {
text-decoration: none;
}
// Focus styles (light mode default)
&:focus {
outline: $bds-tile-link-focus-border-width solid $bds-tile-link-focus-border-color-light;
outline-offset: 1px;
}
&:focus:not(:focus-visible) {
outline: none;
}
&:focus-visible {
outline: $bds-tile-link-focus-border-width solid $bds-tile-link-focus-border-color-light;
outline-offset: 2px;
}
// Dark mode focus styles
@include bds-theme-mode(dark) {
&:focus {
outline-color: $bds-tile-link-focus-border-color-dark;
}
&:focus-visible {
outline-color: $bds-tile-link-focus-border-color-dark;
}
}
}
// =============================================================================
// Overlay (Color wipe animation - "Window Shade" effect)
// =============================================================================
.bds-tile-link__overlay {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
// Default: hidden (shade is "rolled up" at bottom)
clip-path: inset(100% 0 0 0);
transition: clip-path $bds-tile-link-transition-duration $bds-tile-link-transition-timing;
}
// Hovered state: shade fully raised (visible)
.bds-tile-link--hovered .bds-tile-link__overlay {
clip-path: inset(0 0 0 0);
}
// =============================================================================
// Content Wrapper
// =============================================================================
.bds-tile-link__content {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.bds-tile-link__arrow {
flex-shrink: 0;
}
// Arrow animation on hover - works for both <a> and <button> elements
.bds-tile-link:hover .bds-tile-link__arrow .bds-link-icon--internal:not(.bds-link-icon--disabled) svg .arrow-horizontal,
.bds-tile-link:focus .bds-tile-link__arrow .bds-link-icon--internal:not(.bds-link-icon--disabled) svg .arrow-horizontal,
.bds-tile-link--hovered .bds-tile-link__arrow .bds-link-icon--internal:not(.bds-link-icon--disabled) svg .arrow-horizontal {
transform: scaleX(0);
}
// =============================================================================
// Color Variants (Light Mode - Default)
// =============================================================================
@each $variant-name, $variant-colors in $bds-tile-link-variants {
.bds-tile-link--#{$variant-name} {
background-color: map-get($variant-colors, light-bg);
.bds-tile-link__label,
.bds-tile-link__arrow {
color: map-get($variant-colors, light-text);
}
// Overlay color for hover wipe
.bds-tile-link__overlay {
background-color: map-get($variant-colors, light-hover);
}
// Pressed state
&:active:not(.bds-tile-link--disabled) {
.bds-tile-link__overlay {
background-color: map-get($variant-colors, light-pressed);
clip-path: inset(0 0 0 0);
}
}
// Dark mode overrides
@include bds-theme-mode(dark) {
background-color: map-get($variant-colors, dark-bg);
.bds-tile-link__label,
.bds-tile-link__arrow {
color: map-get($variant-colors, dark-text);
}
// Overlay color for hover wipe
.bds-tile-link__overlay {
background-color: map-get($variant-colors, dark-hover);
}
// Pressed state
&:active:not(.bds-tile-link--disabled) {
.bds-tile-link__overlay {
background-color: map-get($variant-colors, dark-pressed);
clip-path: inset(0 0 0 0);
}
}
}
}
}
// =============================================================================
// Disabled State
// =============================================================================
.bds-tile-link--disabled {
cursor: not-allowed;
pointer-events: none;
&:focus,
&:focus-visible {
outline: none;
}
// Light mode disabled (default)
&.bds-tile-link--gray {
background-color: $gray-100;
.bds-tile-link__label,
.bds-tile-link__arrow {
color: $gray-400;
}
}
&.bds-tile-link--lilac {
background-color: $lilac-100;
.bds-tile-link__label,
.bds-tile-link__arrow {
color: $gray-400;
}
}
// Dark mode disabled - 30% opacity
@include bds-theme-mode(dark) {
opacity: 0.3;
&.bds-tile-link--gray {
background-color: $gray-500;
.bds-tile-link__label,
.bds-tile-link__arrow {
color: $white;
}
}
&.bds-tile-link--lilac {
background-color: $lilac-400;
.bds-tile-link__label,
.bds-tile-link__arrow {
color: $white;
}
}
}
}

View File

@@ -0,0 +1,138 @@
import React, { useState } from 'react';
import clsx from 'clsx';
import { LinkArrow } from '../../components/Link/LinkArrow';
export interface TileLinkProps {
/** Color variant: 'gray' (default) or 'lilac' */
variant?: 'gray' | 'lilac';
/** Link text/label */
label: string;
/** Link destination - renders as <a> */
href?: string;
/** Click handler - renders as <button> */
onClick?: () => void;
/** Disabled state - prevents interaction */
disabled?: boolean;
/** Additional CSS classes */
className?: string;
}
/**
* TileLink Component
*
* A clickable tile component for link grids, featuring text content with an arrow icon.
* Supports two color variants (Gray and Lilac) with full light/dark mode theming and
* five interaction states (Default, Hover, Focused, Pressed, Disabled).
*
* Features a "window shade" hover animation where the hover color wipes from
* bottom to top on mouse enter, and top to bottom on mouse leave.
*
* Responsive Sizing:
* - Base (< 576px): 64px height, 12px padding, 16px font, 26.1px line-height
* - MD (576px - 991px): 64px height, 16px padding, 16px font, 26.1px line-height
* - LG (≥ 992px): 64px height, 20px padding, 18px font, 26.1px line-height
*
* Color Variants:
* - Gray: Light mode (gray-200 bg), Dark mode (gray-500 bg)
* - Lilac: Same in both light and dark modes (lilac-100 bg)
*
* @example
* // Basic usage with link (gray variant - default)
* <TileLink
* variant="gray"
* label="Documentation"
* href="/docs"
* />
*
* @example
* // Lilac variant with click handler
* <TileLink
* variant="lilac"
* label="Get Started"
* onClick={() => navigate('/start')}
* />
*
* @example
* // Disabled state
* <TileLink
* variant="gray"
* label="Coming Soon"
* disabled
* />
*/
export const TileLink: React.FC<TileLinkProps> = ({
variant = 'gray',
label,
href,
onClick,
disabled = false,
className,
}) => {
// Track hover state for animation
const [isHovered, setIsHovered] = useState(false);
// Build class names using BEM convention
const classNames = clsx(
'bds-tile-link',
`bds-tile-link--${variant}`,
{
'bds-tile-link--disabled': disabled,
'bds-tile-link--hovered': isHovered && !disabled,
},
className
);
// Hover handlers
const handleMouseEnter = () => !disabled && setIsHovered(true);
const handleMouseLeave = () => setIsHovered(false);
// Common content (overlay + label + arrow)
const content = (
<>
{/* Hover overlay for window shade animation */}
<div className="bds-tile-link__overlay" aria-hidden="true" />
{/* Content wrapper */}
<div className="bds-tile-link__content">
<span className="bds-tile-link__label mb-0 body-r">{label}</span>
<span className="bds-tile-link__arrow">
<LinkArrow variant="internal" size="medium" />
</span>
</div>
</>
);
// Render as anchor tag when href is provided
if (href && !disabled) {
return (
<a
href={href}
className={classNames}
aria-label={label}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{content}
</a>
);
}
// Render as button (for onClick or disabled state)
return (
<button
type="button"
className={classNames}
onClick={onClick}
disabled={disabled}
aria-disabled={disabled}
aria-label={label}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{content}
</button>
);
};
export default TileLink;

View File

@@ -0,0 +1,2 @@
export { TileLink, type TileLinkProps } from './TileLink';

View File

@@ -58,20 +58,21 @@ $bds-cmb-variants: (
);
// Spacing tokens - responsive
// Note: Uses centralized spacing tokens from _spacing.scss where applicable.
// Mobile (<768px)
$bds-cmb-gap-mobile: 48px;
$bds-cmb-padding-mobile: 16px;
$bds-cmb-gap-mobile: $bds-space-5xl; // 48px - spacing('5xl')
$bds-cmb-padding-mobile: $bds-space-lg; // 16px - spacing('lg')
// Tablet (768px-1023px)
$bds-cmb-gap-tablet: 64px;
$bds-cmb-padding-tablet: 24px;
$bds-cmb-gap-tablet: $bds-space-6xl; // 64px - spacing('6xl')
$bds-cmb-padding-tablet: $bds-space-2xl; // 24px - spacing('2xl')
// Desktop (1024px)
$bds-cmb-gap-desktop: 80px;
$bds-cmb-padding-desktop: 40px 32px;
$bds-cmb-gap-desktop: $bds-space-8xl; // 80px - spacing('8xl')
$bds-cmb-padding-desktop: $bds-space-4xl $bds-space-3xl; // 40px 32px
// Button spacing
$bds-cmb-button-gap: 24px;
$bds-cmb-button-gap: $bds-space-2xl; // 24px - spacing('2xl')
// Image overlay for readability
$bds-cmb-image-overlay: rgba(0, 0, 0, 0.3);
@@ -161,18 +162,18 @@ $bds-cmb-image-overlay: rgba(0, 0, 0, 0.3);
@extend .flex-column;
flex-direction: column;
color: var(--bds-cmb-text-color, #232021);
// Mobile-first gap
gap: 16px;
gap: $bds-space-lg; // 16px - spacing('lg')
// Tablet breakpoint
@include media-breakpoint-up(md) {
gap: 24px;
gap: $bds-space-2xl; // 24px - spacing('2xl')
}
// Desktop breakpoint
@include media-breakpoint-up(xl) {
gap: 32px;
gap: $bds-space-3xl; // 32px - spacing('3xl')
}
}

View File

@@ -1,7 +1,7 @@
import React from 'react';
import clsx from 'clsx';
import { PageGrid, PageGridCol, PageGridRow } from 'shared/components/PageGrid/page-grid';
import { ButtonGroup } from '../ButtonGroup/ButtonGroup';
import { ButtonGroup, ButtonConfig, validateButtonGroup } from 'shared/patterns/ButtonGroup/ButtonGroup';
export interface CalloutMediaBannerProps {
/** Color variant - determines background color (ignored if backgroundImage is provided) */
@@ -12,57 +12,62 @@ export interface CalloutMediaBannerProps {
textColor?: 'white' | 'black';
/** Main heading text */
heading?: string;
/** Heading element type - h1 through h6 (defaults to h6) */
headingAs?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
/** Subheading/description text */
subheading: string;
/** Primary button configuration */
primaryButton?: {
label: string;
href?: string;
onClick?: () => void;
};
/** Tertiary button configuration */
tertiaryButton?: {
label: string;
href?: string;
onClick?: () => void;
};
/** Button configurations (1-2 buttons supported) */
buttons?: ButtonConfig[];
/** Additional CSS classes */
className?: string;
}
/**
* CalloutMediaBanner Component
*
*
* A full-width banner component featuring a heading, subheading, and optional action buttons.
* Supports 5 color variants or a custom background image. Responsive across mobile, tablet, and desktop.
*
*
* @example
* // Color variant
* // Color variant with default h6 heading
* <CalloutMediaBanner
* variant="green"
* heading="The Compliant Ledger Protocol"
* subheading="A decentralized public Layer 1 blockchain..."
* primaryButton={{ label: "Get Started", href: "/docs" }}
* tertiaryButton={{ label: "Learn More", href: "/about" }}
* buttons={[
* { label: "Get Started", href: "/docs" },
* { label: "Learn More", href: "/about" }
* ]}
* />
*
*
* @example
* // With custom heading level (h1)
* <CalloutMediaBanner
* variant="green"
* heading="The Compliant Ledger Protocol"
* headingAs="h1"
* subheading="A decentralized public Layer 1 blockchain..."
* buttons={[{ label: "Get Started", href: "/docs" }]}
* />
*
* @example
* // With background image (white text - default)
* <CalloutMediaBanner
* backgroundImage="/images/hero-bg.jpg"
* heading="Build on XRPL"
* subheading="Start building your next project"
* primaryButton={{ label: "Start Building", onClick: handleClick }}
* buttons={[{ label: "Start Building", onClick: handleClick }]}
* />
*
*
* @example
* // With background image and black text (fixed across light/dark modes)
* <CalloutMediaBanner
* backgroundImage="/images/light-hero-bg.jpg"
* textColor="black"
* heading="Build on XRPL"
* headingAs="h2"
* subheading="Start building your next project"
* primaryButton={{ label: "Start Building", onClick: handleClick }}
* buttons={[{ label: "Start Building", onClick: handleClick }]}
* />
*/
export const CalloutMediaBanner: React.FC<CalloutMediaBannerProps> = ({
@@ -70,14 +75,15 @@ export const CalloutMediaBanner: React.FC<CalloutMediaBannerProps> = ({
backgroundImage,
textColor = 'white',
heading,
headingAs = 'h4',
subheading,
primaryButton,
tertiaryButton,
buttons,
className = '',
}) => {
// Check if there are any buttons
const hasButtons = !!(primaryButton || tertiaryButton);
// Validate buttons if provided (max 2 buttons supported)
const buttonValidation = validateButtonGroup(buttons, 2);
const hasButtons = buttonValidation.hasButtons;
// Check if we should center content: no buttons OR (no heading but has buttons)
const shouldCenter = !hasButtons || (!heading && hasButtons);
@@ -104,6 +110,9 @@ export const CalloutMediaBanner: React.FC<CalloutMediaBannerProps> = ({
? { backgroundImage: `url(${backgroundImage})` }
: {};
// Create the heading element dynamically
const HeadingElement = headingAs;
return (
<PageGrid containerType="wide">
<PageGridRow className={classNames} style={inlineStyle}>
@@ -111,17 +120,18 @@ export const CalloutMediaBanner: React.FC<CalloutMediaBannerProps> = ({
<div className="bds-callout-media-banner__content">
{/* Text Content */}
<div className="bds-callout-media-banner__text">
{heading && <h2 className="bds-callout-media-banner__heading">{heading}</h2>}
{heading && <HeadingElement className="bds-callout-media-banner__heading">{heading}</HeadingElement>}
<p className="bds-callout-media-banner__subheading">{subheading}</p>
</div>
{/* Buttons */}
<ButtonGroup
primaryButton={primaryButton}
tertiaryButton={tertiaryButton}
color={buttonColor}
gap="none"
/>
{hasButtons && (
<ButtonGroup
buttons={buttonValidation.buttons}
color={buttonColor}
gap="none"
/>
)}
</div>
</PageGridCol>
</PageGridRow>

View File

@@ -14,7 +14,7 @@ A section pattern that displays a heading, optional description, and a responsiv
## Usage
```tsx
import { CardStats } from 'shared/patterns/CardStats';
import { CardStats } from 'shared/sections/CardStatsList';
<CardStats
heading="Blockchain Trusted at Scale"
@@ -136,7 +136,7 @@ Uses breakpoints from `styles/_breakpoints.scss`:
- **Figma Design**: [Section Cards - Stats](https://www.figma.com/design/drnQQXnK9Q67MTPPKQsY9l/Section-Cards---Stats?node-id=32051-2839&m=dev)
- **Showcase Page**: `/about/card-stats-showcase`
- **Pattern Location**: `shared/patterns/CardStats/`
- **Pattern Location**: `shared/sections/CardStatsList/`
- **Component Used**: `shared/components/CardStat/`
## Accessibility

View File

@@ -0,0 +1,48 @@
// BDS CardStats Pattern Styles
// Brand Design System - Section with heading, description, and grid of CardStat components
//
// Naming Convention: BEM with 'bds' namespace
// .bds-card-stats - Base section container
//
// Design tokens from Figma:
// Light Mode:
// - Background: White (#FFFFFF)
// - Heading: Neutral Black (#141414) → $black
// - Description: Neutral Black (#141414) → $black
//
// Dark Mode:
// - Background: transparent (inherits page background)
// - Heading: Neutral White (#FFFFFF) → $white
// - Description: Neutral White (#FFFFFF) → $white
//
// - Header content max-width: 808px (approximately 8 columns at desktop)
// - Gap between heading and description: 16px
// - Gap between cards: 8px (matches $bds-grid-gutter)
// Spacing - Section padding
$bds-card-stats-padding-y-sm: $bds-space-2xl; // 24px - spacing('2xl')
$bds-card-stats-padding-y-md: $bds-space-3xl; // 32px - spacing('3xl')
$bds-card-stats-padding-y-lg: $bds-space-4xl; // 40px - spacing('4xl')
// =============================================================================
// Base Section Styles
// =============================================================================
.bds-card-stats {
// Vertical padding
padding-top: $bds-card-stats-padding-y-sm;
padding-bottom: $bds-card-stats-padding-y-sm;
@include media-breakpoint-up(md) {
padding-top: $bds-card-stats-padding-y-md;
padding-bottom: $bds-card-stats-padding-y-md;
}
@include media-breakpoint-up(lg) {
padding-top: $bds-card-stats-padding-y-lg;
padding-bottom: $bds-card-stats-padding-y-lg;
}
}
// Header section uses SectionHeader component

View File

@@ -2,6 +2,7 @@ import React from 'react';
import clsx from 'clsx';
import { CardStat, CardStatProps } from '../../components/CardStat';
import { PageGrid } from '../../components/PageGrid/page-grid';
import { SectionHeader } from 'shared/patterns/SectionHeader';
/**
* Configuration for a single stat card in the CardStats pattern
@@ -72,20 +73,8 @@ export const CardStats = React.forwardRef<HTMLElement, CardStatsProps>(
className={clsx('bds-card-stats', className)}
{...rest}
>
<PageGrid.Row>
<PageGrid.Col span={{ base: 4, md: 6, lg: 8 }}>
{/* Header section */}
<div className="bds-card-stats__header">
<h2 className="mb-0 h-md">{heading}</h2>
{description && (
<p className="bmb-0 body-l">
{description}
</p>
)}
</div>
</PageGrid.Col>
</PageGrid.Row>
<PageGrid.Row>
<SectionHeader heading={heading} description={description} span={{ base: 4, md: 6, lg: 8 }} />
<PageGrid.Row as="ul">
{cards.map((cardConfig, index) => (
<CardStat
key={index}

View File

@@ -0,0 +1,2 @@
export { CardStats, type CardStatsProps, type CardStatsCardConfig } from './CardStatsList';
export { default } from './CardStatsList';

View File

@@ -13,7 +13,7 @@ A section pattern that displays a heading, description, and a responsive grid of
## Usage
```tsx
import { CardsFeatured } from 'shared/patterns/CardsFeatured';
import { CardsFeatured } from 'shared/sections/CardsFeatured';
<CardsFeatured
heading="Trusted by Leaders in Real-World Asset Tokenization"

View File

@@ -3,9 +3,6 @@
//
// Naming Convention: BEM with 'bds' namespace
// .bds-cards-featured - Base section container
// .bds-cards-featured__header - Header wrapper for heading and description
// .bds-cards-featured__heading - Section heading (uses .h-md)
// .bds-cards-featured__description - Section description (uses .body-l)
// .bds-cards-featured__cards - Cards grid container
// .bds-cards-featured__card-wrapper - Individual card wrapper
//
@@ -18,46 +15,29 @@
// - Heading: Neutral White (#FFFFFF) $white
// - Description: Neutral White (#FFFFFF) $white
//
// - Header content max-width: 808px (approximately 8 columns at desktop)
// - Gap between heading and description: 16px
// - Header: uses SectionHeader component
// - Gap between cards: 8px (matches $bds-grid-gutter)
// =============================================================================
// Design Tokens (from Figma)
// =============================================================================
// Note: Uses centralized spacing tokens from _spacing.scss.
// $bds-grid-gutter is defined in _spacing.scss (8px).
$bds-grid-gutter: 8px;
// Spacing - Header gap (between heading and description)
$bds-cards-featured-header-gap-sm: 8px; // Mobile: 8px
$bds-cards-featured-header-gap-md: 8px; // Tablet: 8px
$bds-cards-featured-header-gap-lg: 16px; // Desktop: 16px
// Spacing - Section gap (between header and cards)
$bds-cards-featured-section-gap-sm: 24px; // Mobile
$bds-cards-featured-section-gap-md: 32px; // Tablet
$bds-cards-featured-section-gap-lg: 40px; // Desktop
// Spacing - Section gap handled by SectionHeader margin-bottom
// Spacing - Cards gap
$bds-cards-featured-cards-gap-sm: 48px; // Mobile: 48px vertical
$bds-cards-featured-cards-gap-md: 8px; // Tablet: 8px
$bds-cards-featured-cards-gap-lg: 8px; // Desktop: 8px
$bds-cards-featured-cards-gap-sm: $bds-space-5xl; // 48px - spacing('5xl')
$bds-cards-featured-cards-gap-md: $bds-space-sm; // 8px - spacing('sm')
$bds-cards-featured-cards-gap-lg: $bds-space-sm; // 8px - spacing('sm')
// Spacing - Section padding (vertical)
$bds-cards-featured-padding-y-sm: 48px; // Mobile
$bds-cards-featured-padding-y-md: 64px; // Tablet
$bds-cards-featured-padding-y-lg: 80px; // Desktop
$bds-cards-featured-padding-y-sm: $bds-space-5xl; // 48px - spacing('5xl')
$bds-cards-featured-padding-y-md: $bds-space-6xl; // 64px - spacing('6xl')
$bds-cards-featured-padding-y-lg: $bds-space-8xl; // 80px - spacing('8xl')
// Spacing - Section padding (horizontal) - handled by PageGrid
// Colors - Light Mode (default)
$bds-cards-featured-heading-color: $black; // #141414 - Neutral black
$bds-cards-featured-description-color: $black; // #141414 - Neutral black
// Colors - Dark Mode (from Figma node 27020-3045)
$bds-cards-featured-heading-color-dark: $white; // #FFFFFF - Neutral white
$bds-cards-featured-description-color-dark: $white; // #FFFFFF - Neutral white
// =============================================================================
// Section Container
// =============================================================================
@@ -78,34 +58,6 @@ $bds-cards-featured-description-color-dark: $white; // #FFFFFF - Neutral white
}
}
// =============================================================================
// Header Section
// =============================================================================
.bds-cards-featured__header {
display: flex;
flex-direction: column;
gap: $bds-cards-featured-header-gap-sm;
@include media-breakpoint-up(md) {
gap: $bds-cards-featured-header-gap-md;
}
@include media-breakpoint-up(lg) {
gap: $bds-cards-featured-header-gap-lg;
}
}
.bds-cards-featured__heading {
margin: 0;
// Typography handled by .h-md class from _font.scss
}
.bds-cards-featured__description {
margin: 0;
// Typography handled by .body-l class from _font.scss
}
// =============================================================================
// Cards Grid
// =============================================================================
@@ -114,22 +66,19 @@ $bds-cards-featured-description-color-dark: $white; // #FFFFFF - Neutral white
display: grid;
grid-template-columns: 1fr;
column-gap: $bds-cards-featured-cards-gap-sm;
row-gap: 48px;
row-gap: $bds-space-5xl; // 48px - spacing('5xl')
width: 100%;
margin-top: $bds-cards-featured-section-gap-sm;
@include media-breakpoint-up(md) {
grid-template-columns: repeat(2, 1fr);
column-gap: $bds-cards-featured-cards-gap-md;
row-gap: 52px;
margin-top: $bds-cards-featured-section-gap-md;
row-gap: 52px; // Non-standard value, kept as-is
}
@include media-breakpoint-up(lg) {
grid-template-columns: repeat(3, 1fr);
column-gap: $bds-cards-featured-cards-gap-lg;
row-gap: 56px;
margin-top: $bds-cards-featured-section-gap-lg;
row-gap: 56px; // Non-standard value, kept as-is
}
}
@@ -152,27 +101,10 @@ html.light {
.bds-cards-featured {
background-color: $white;
}
.bds-cards-featured__heading {
color: $bds-cards-featured-heading-color;
}
.bds-cards-featured__description {
color: $bds-cards-featured-description-color;
}
}
// =============================================================================
// Dark Mode Styles (from Figma node 27020-3045)
// =============================================================================
html.dark {
.bds-cards-featured__heading {
color: $bds-cards-featured-heading-color-dark;
}
.bds-cards-featured__description {
color: $bds-cards-featured-description-color-dark;
}
}
// Header colors handled by SectionHeader component

View File

@@ -2,6 +2,7 @@ import React from 'react';
import clsx from 'clsx';
import { CardImage, CardImageProps } from '../../components/CardImage';
import { PageGrid } from '../../components/PageGrid/page-grid';
import { SectionHeader } from 'shared/patterns/SectionHeader';
import { getCardKey, isEnvironment } from '../../utils';
/**
@@ -72,21 +73,7 @@ export const CardsFeatured = React.forwardRef<HTMLElement, CardsFeaturedProps>(
{...rest}
>
<PageGrid>
{/* Header content row */}
<PageGrid.Row>
<PageGrid.Col
span={{
base: 'fill',
md: 6,
lg: 8,
}}
>
<div className="bds-cards-featured__header">
<h2 className="bds-cards-featured__heading h-md">{heading}</h2>
<p className="bds-cards-featured__description body-l">{description}</p>
</div>
</PageGrid.Col>
</PageGrid.Row>
<SectionHeader heading={heading} description={description} />
{/* Cards grid row */}
<PageGrid.Row>

View File

@@ -0,0 +1,53 @@
// BDS CardsIconGrid Pattern Styles
// Brand Design System - Section with heading, description, and grid of CardTextIconCard
//
// Naming Convention: BEM with 'bds' namespace
// .bds-cards-icon-grid - Base section container
// .bds-cards-icon-grid__list - Grid list (ul as PageGrid.Row)
// =============================================================================
// Design Tokens (uses BDS spacing from _spacing.scss)
// =============================================================================
$bds-cards-icon-grid-padding-base: $bds-space-2xl;
$bds-cards-icon-grid-padding-md: $bds-space-3xl;
$bds-cards-icon-grid-padding-lg: $bds-space-4xl;
// =============================================================================
// Section Container
// =============================================================================
.bds-cards-icon-grid {
padding-top: $bds-cards-icon-grid-padding-base;
@include media-breakpoint-up(md) {
padding-top: $bds-cards-icon-grid-padding-md;
}
@include media-breakpoint-up(lg) {
padding-top: $bds-cards-icon-grid-padding-lg;
}
ul {
padding-left: 0;
list-style: none;
}
@include bds-theme-mode(dark) {
color: $white;
}
}
// =============================================================================
// List Grid - Aspect Ratios
// =============================================================================
.bds-cards-icon-grid__list > li {
aspect-ratio: 3 / 2;
@include media-breakpoint-up(md) {
aspect-ratio: 4 / 3;
}
@include media-breakpoint-up(lg) {
aspect-ratio: 4 / 3;
}
}

View File

@@ -0,0 +1,62 @@
import React from 'react';
import clsx from 'clsx';
import { PageGrid, PageGridRow, PageGridCol } from 'shared/components/PageGrid/page-grid';
import { SectionHeader } from 'shared/patterns/SectionHeader';
import { CardTextIconCard, CardTextIconCardProps } from 'shared/components/CardTextIcon';
export interface CardsIconGridProps {
/** Section heading (required) */
heading: string;
/** Optional description text */
description?: string;
/** Array of card data to display */
cards: CardTextIconCardProps[];
/** Additional CSS classes */
className?: string;
}
/**
* CardsIconGrid Component
*
* A section pattern with a header (heading + description) and a grid of
* CardTextIconCard components. Uses PageGrid.Row as="ul" with cards as
* PageGrid.Col as="li". Aspect ratios: sm 3:2, md/lg 4:3.
*
* @example
* <CardsIconGrid
* heading="Explore Tools"
* description="Choose a tool to get started"
* cards={[
* { icon: "/icons/docs.svg", heading: "Documentation", description: "..." },
* ]}
* />
*/
export const CardsIconGrid: React.FC<CardsIconGridProps> = ({
heading,
description,
cards,
className,
}) => {
return (
<PageGrid className={clsx('bds-cards-icon-grid', className)}>
<SectionHeader heading={heading} description={description} span={{ base: 12, md: 6, lg: 8 }} />
<PageGridRow as="ul" className="bds-cards-icon-grid__list list-none pl-0">
{cards.map((card, index) => (
<CardTextIconCard
key={card.heading || index}
icon={card.icon}
iconAlt={card.iconAlt}
heading={card.heading}
description={card.description}
height={card.height}
width={card.width}
gridColSpan={{ base: 4, md: 4, lg: 4 }}
/>
))}
</PageGridRow>
</PageGrid>
);
};
export default CardsIconGrid;

View File

@@ -0,0 +1,53 @@
# CardsIconGrid Pattern
A section pattern with a header (heading + description) and a grid of CardTextIconCard components.
## Overview
CardsIconGrid mirrors the LinkTextDirectory header structure and renders cards in a responsive grid using PageGrid.Row as="ul" and CardTextIconCard with gridColSpan. Each card is a PageGrid.Col as="li"—no div wrapper.
## Features
- Header with heading and optional description
- Responsive grid: 3 cards/row at all breakpoints (base, md, lg)
- Aspect ratios: sm 3:2, md/lg 4:3
- Light/dark mode support
## Usage
```tsx
import { CardsIconGrid } from 'shared/patterns/CardsIconGrid';
<CardsIconGrid
heading="Explore Tools"
description="Choose a tool to get started"
cards={[
{
icon: "/icons/docs.svg",
iconAlt: "Documentation",
heading: "Documentation",
description: "Access everything you need.",
},
]}
/>
```
## Props
| Prop | Type | Description |
|------|------|-------------|
| `heading` | `string` | Section heading |
| `description` | `string` | Optional description |
| `cards` | `CardTextIconCardData[]` | Array of card configs |
| `className` | `string` | Additional CSS classes |
## Grid Column Spans
- base: 4, md: 4, lg: 4 (3 cards per row)
## Files
- `CardsIconGrid.tsx` - React component
- `CardsIconGrid.scss` - Styles
- `index.ts` - Barrel exports
- `README.md` - This file

View File

@@ -0,0 +1,4 @@
export {
CardsIconGrid,
type CardsIconGridProps,
} from './CardsIconGrid';

View File

@@ -0,0 +1,53 @@
// BDS CardsTextGrid Pattern Styles
// Brand Design System - Section with heading, description, and grid of CardTextIconCard
//
// Naming Convention: BEM with 'bds' namespace
// .bds-cards-text-grid - Base section container
// .bds-cards-text-grid__list - Grid list (ul as PageGrid.Row)
// =============================================================================
// Design Tokens (uses BDS spacing from _spacing.scss)
// =============================================================================
$bds-cards-text-grid-padding-base: $bds-space-2xl;
$bds-cards-text-grid-padding-md: $bds-space-3xl;
$bds-cards-text-grid-padding-lg: $bds-space-4xl;
// =============================================================================
// Section Container
// =============================================================================
.bds-cards-text-grid {
padding-top: $bds-cards-text-grid-padding-base;
@include media-breakpoint-up(md) {
padding-top: $bds-cards-text-grid-padding-md;
}
@include media-breakpoint-up(lg) {
padding-top: $bds-cards-text-grid-padding-lg;
}
ul {
padding-left: 0;
list-style: none;
}
@include bds-theme-mode(dark) {
color: $white;
}
}
// =============================================================================
// List Grid - Aspect Ratios
// =============================================================================
.bds-cards-text-grid__list > li {
aspect-ratio: 16 / 9;
@include media-breakpoint-up(md) {
aspect-ratio: 3 / 2;
}
@include media-breakpoint-up(lg) {
aspect-ratio: 3 / 1;
}
}

View File

@@ -0,0 +1,58 @@
import React from 'react';
import clsx from 'clsx';
import { PageGrid, PageGridRow, PageGridCol } from 'shared/components/PageGrid/page-grid';
import { SectionHeader } from 'shared/patterns/SectionHeader';
import { CardTextIconCard, CardTextIconCardProps } from 'shared/components/CardTextIcon';
export interface CardsTextGridProps {
/** Section heading (required) */
heading: string;
/** Optional description text */
description?: string;
/** Array of card data to display */
cards: CardTextIconCardProps[];
/** Additional CSS classes */
className?: string;
}
/**
* CardsTextGrid Component
*
* A section pattern with a header (heading + description) and a grid of
* CardTextIconCard components. Uses PageGrid.Row as="ul" with cards as
* PageGrid.Col as="li". Aspect ratios: sm 16:9, md 3:2, lg 3:1.
*
* @example
* <CardsTextGrid
* heading="Explore Tools"
* description="Choose a tool to get started"
* cards={[
* { icon: "/icons/docs.svg", heading: "Documentation", description: "..." },
* ]}
* />
*/
export const CardsTextGrid: React.FC<CardsTextGridProps> = ({
heading,
description,
cards,
className,
}) => {
return (
<PageGrid className={clsx('bds-cards-text-grid', className)}>
<SectionHeader heading={heading} description={description} span={{ base: 12, md: 6, lg: 8 }} />
<PageGridRow as="ul" className="bds-cards-text-grid__list list-none pl-0">
{cards.map((card, index) => (
<CardTextIconCard
key={card.heading || index}
heading={card.heading}
description={card.description}
gridColSpan={{ base: 4, md: 4, lg: 6 }}
/>
))}
</PageGridRow>
</PageGrid>
);
};
export default CardsTextGrid;

View File

@@ -0,0 +1,53 @@
# CardsTextGrid Pattern
A section pattern with a header (heading + description) and a grid of CardTextIconCard components.
## Overview
CardsTextGrid mirrors the LinkTextDirectory header structure and renders cards in a responsive grid using PageGrid.Row as="ul" and CardTextIconCard with gridColSpan. Each card is a PageGrid.Col as="li"—no div wrapper.
## Features
- Header with heading and optional description
- Responsive grid: 3 cards/row at base+md, 2 cards/row at lg
- Aspect ratios: sm 16:9, md 3:2, lg 3:1
- Light/dark mode support
## Usage
```tsx
import { CardsTextGrid } from 'shared/patterns/CardsTextGrid';
<CardsTextGrid
heading="Explore Tools"
description="Choose a tool to get started"
cards={[
{
icon: "/icons/docs.svg",
iconAlt: "Documentation",
heading: "Documentation",
description: "Access everything you need.",
},
]}
/>
```
## Props
| Prop | Type | Description |
|------|------|-------------|
| `heading` | `string` | Section heading |
| `description` | `string` | Optional description |
| `cards` | `CardTextIconCardData[]` | Array of card configs |
| `className` | `string` | Additional CSS classes |
## Grid Column Spans
- base: 4, md: 4, lg: 6 (3 cards/row at base+md, 2 at lg)
## Files
- `CardsTextGrid.tsx` - React component
- `CardsTextGrid.scss` - Styles
- `index.ts` - Barrel exports
- `README.md` - This file

View File

@@ -0,0 +1,4 @@
export {
CardsTextGrid,
type CardsTextGridProps,
} from './CardsTextGrid';

View File

@@ -25,16 +25,17 @@
// =============================================================================
// Design Tokens from Figma
// =============================================================================
// Note: Uses centralized spacing tokens from _spacing.scss.
// Section vertical padding (horizontal handled by PageGrid)
$bds-section-padding-y-mobile: 24px;
$bds-section-padding-y-tablet: 32px;
$bds-section-padding-y-desktop: 40px;
$bds-section-padding-y-mobile: $bds-space-2xl; // 24px - spacing('2xl')
$bds-section-padding-y-tablet: $bds-space-3xl; // 32px - spacing('3xl')
$bds-section-padding-y-desktop: $bds-space-4xl; // 40px - spacing('4xl')
// Gap between header row and cards row
$bds-section-row-gap-mobile: 24px;
$bds-section-row-gap-tablet: 32px;
$bds-section-row-gap-desktop: 40px;
$bds-section-row-gap-mobile: $bds-space-2xl; // 24px - spacing('2xl')
$bds-section-row-gap-tablet: $bds-space-3xl; // 32px - spacing('3xl')
$bds-section-row-gap-desktop: $bds-space-4xl; // 40px - spacing('4xl')
// Card height for header alignment (desktop only)
$bds-text-card-height-desktop: 340px;
@@ -124,7 +125,7 @@ $bds-text-color-muted: $gray-500; // #72777E - Neutral/500 for description
margin: 0;
& + p {
margin-top: 16px; // Paragraph spacing from Figma
margin-top: $bds-space-lg; // 16px - spacing('lg')
}
}
}

View File

@@ -16,7 +16,7 @@ A horizontal scrolling carousel pattern that displays `CardOffgrid` components w
## Usage
```tsx
import { CarouselCardList } from 'shared/patterns/CarouselCardList';
import { CarouselCardList } from 'shared/sections/CarouselCardList';
<CarouselCardList
variant="neutral"

View File

@@ -19,32 +19,32 @@
// =============================================================================
// Design Tokens (from Figma)
// =============================================================================
$bds-grid-gutter: 8px;
// Note: Uses centralized spacing tokens from _spacing.scss.
// $bds-grid-gutter is defined in _spacing.scss (8px).
// Grid padding (matches PageGrid container padding)
$bds-carousel-grid-padding-sm: 18px; // Mobile
$bds-carousel-grid-padding-md: 24px; // Tablet
$bds-carousel-grid-padding-lg: 32px; // Desktop (lg+)
$bds-carousel-grid-padding-sm: 18px; // Mobile (non-standard)
$bds-carousel-grid-padding-md: $bds-space-2xl; // 24px - spacing('2xl')
$bds-carousel-grid-padding-lg: $bds-space-3xl; // 32px - spacing('3xl')
$bds-carousel-grid-max-width: 1280px; // Max container width (per _breakpoints.scss $xl)
// Spacing - Header gap (between heading and description)
$bds-carousel-header-gap-sm: 8px; // Mobile
$bds-carousel-header-gap-md: 8px; // Tablet
$bds-carousel-header-gap-lg: 16px; // Desktop
$bds-carousel-header-gap-sm: $bds-space-sm; // 8px - spacing('sm')
$bds-carousel-header-gap-md: $bds-space-sm; // 8px - spacing('sm')
$bds-carousel-header-gap-lg: $bds-space-lg; // 16px - spacing('lg')
// Spacing - Section gap (between header content and buttons row on mobile)
$bds-carousel-section-gap-sm: 24px; // Mobile
$bds-carousel-section-gap-md: 32px; // Tablet
$bds-carousel-section-gap-lg: 40px; // Desktop
$bds-carousel-section-gap-sm: $bds-space-2xl; // 24px - spacing('2xl')
$bds-carousel-section-gap-md: $bds-space-3xl; // 32px - spacing('3xl')
$bds-carousel-section-gap-lg: $bds-space-4xl; // 40px - spacing('4xl')
// Spacing - Gap between header and cards
$bds-carousel-cards-gap-sm: 24px; // Mobile
$bds-carousel-cards-gap-md: 32px; // Tablet
$bds-carousel-cards-gap-lg: 40px; // Desktop
$bds-carousel-cards-gap-sm: $bds-space-2xl; // 24px - spacing('2xl')
$bds-carousel-cards-gap-md: $bds-space-3xl; // 32px - spacing('3xl')
$bds-carousel-cards-gap-lg: $bds-space-4xl; // 40px - spacing('4xl')
// Button gap (button styles are in shared/components/CarouselButton)
$bds-carousel-button-gap: 8px;
$bds-carousel-button-gap: $bds-space-sm; // 8px - spacing('sm')
// Card dimensions per breakpoint
$bds-carousel-card-width-sm: 343px; // Mobile
@@ -55,9 +55,9 @@ $bds-carousel-card-width-lg: 400px; // Desktop
$bds-carousel-card-height-lg: 480px;
// Card padding per breakpoint
$bds-carousel-card-padding-sm: 16px;
$bds-carousel-card-padding-md: 20px;
$bds-carousel-card-padding-lg: 24px;
$bds-carousel-card-padding-sm: $bds-space-lg; // 16px - spacing('lg')
$bds-carousel-card-padding-md: $bds-space-xl; // 20px - spacing('xl')
$bds-carousel-card-padding-lg: $bds-space-2xl; // 24px - spacing('2xl')
// Transition
$bds-carousel-transition: 200ms cubic-bezier(0.98, 0.12, 0.12, 0.98);

View File

@@ -131,20 +131,16 @@ export const CarouselCardList = React.forwardRef<HTMLElement, CarouselCardListPr
<p className="bds-carousel-card-list__description body-l">{description}</p>
</div>
<div className="bds-carousel-card-list__nav">
<CarouselButton
direction="prev"
variant={buttonVariant}
disabled={!canScrollPrev}
onClick={() => scroll('prev')}
aria-label="Previous cards"
/>
<CarouselButton
direction="next"
variant={buttonVariant}
disabled={!canScrollNext}
onClick={() => scroll('next')}
aria-label="Next cards"
/>
{(['prev', 'next'] as const).map((direction) => (
<CarouselButton
key={direction}
direction={direction}
variant={buttonVariant}
disabled={direction === 'prev' ? !canScrollPrev : !canScrollNext}
onClick={() => scroll(direction)}
aria-label={direction === 'prev' ? 'Previous cards' : 'Next cards'}
/>
))}
</div>
</div>

View File

@@ -0,0 +1,261 @@
// =============================================================================
// FeatureSingleTopic Pattern
// =============================================================================
// A feature section pattern with single topic layout for title and media.
// Supports variants (default, accentSurface).
// Orientation (left, right) is handled via Bootstrap utility classes in TSX.
// Based on Figma: 1280px desktop design with 706px image + content area
//
// Note: Buttons are rendered using the ButtonGroup component.
// =============================================================================
// =============================================================================
// Design Tokens
// =============================================================================
// Background colors from _colors.scss
$bds-single-topic-bg: $white; // #FFFFFF (Neutral-white)
$bds-single-topic-title-bg: $gray-200; // #E6EAF0 (Neutral-200) for accentSurface variant
// Text colors from _colors.scss
$bds-single-topic-title-color: $black; // #141414 (Neutral-black)
$bds-single-topic-description-color: $gray-500; // #72777E (Neutral-500)
// Spacing - Desktop (≥992px) - based on Figma 1280px design
// Note: Uses centralized spacing tokens from _spacing.scss.
$bds-single-topic-desktop-py: $bds-space-4xl; // 40px - spacing('4xl')
$bds-single-topic-desktop-content-pl: $bds-space-sm; // 8px - spacing('sm')
$bds-single-topic-desktop-description-gap: $bds-space-4xl; // 40px - spacing('4xl')
$bds-single-topic-desktop-title-padding: $bds-space-lg; // 16px - spacing('lg')
$bds-single-topic-desktop-height: 565px; // Fixed height from Figma design
// Spacing - Tablet (576px - 991px)
$bds-single-topic-tablet-py: $bds-space-3xl; // 32px - spacing('3xl')
$bds-single-topic-tablet-content-gap: $bds-space-3xl; // 32px - spacing('3xl')
$bds-single-topic-tablet-content-min-height: 320px; // Min height for content on tablet
$bds-single-topic-tablet-title-description-gap: $bds-space-8xl; // 80px - spacing('8xl')
// Spacing - Mobile (<576px)
$bds-single-topic-mobile-py: $bds-space-2xl; // 24px - spacing('2xl')
$bds-single-topic-mobile-content-gap: $bds-space-2xl; // 24px - spacing('2xl')
$bds-single-topic-mobile-content-min-height: 280px; // Min height for content on mobile
$bds-single-topic-mobile-title-description-gap: $bds-space-4xl; // 40px - spacing('4xl')
// =============================================================================
// Base Styles
// =============================================================================
.bds-feature-single-topic {
width: 100%;
background-color: $bds-single-topic-bg;
// Container - uses PageGrid with vertical padding
&__container {
padding-top: $bds-single-topic-mobile-py;
padding-bottom: $bds-single-topic-mobile-py;
@include media-breakpoint-up(md) {
padding-top: $bds-single-topic-tablet-py;
padding-bottom: $bds-single-topic-tablet-py;
}
@include media-breakpoint-up(lg) {
padding-top: $bds-single-topic-desktop-py;
padding-bottom: $bds-single-topic-desktop-py;
}
}
// Row - align items stretch so columns match height
// Use row-gap for spacing between image and content on mobile/tablet
&__row {
align-items: stretch;
row-gap: $bds-single-topic-mobile-content-gap;
@include media-breakpoint-up(md) {
row-gap: $bds-single-topic-tablet-content-gap;
}
@include media-breakpoint-up(lg) {
row-gap: 0;
// Fixed height from Figma design
height: $bds-single-topic-desktop-height;
}
}
// Media column
&__media-col {
@include media-breakpoint-up(lg) {
height: 100%;
}
}
// Content column - flex container with left padding on desktop
&__content-col {
display: flex;
flex-direction: column;
@include media-breakpoint-up(lg) {
padding-left: $bds-single-topic-desktop-content-pl;
height: 100%;
}
}
// Media container
&__media {
width: 100%;
overflow: hidden;
}
// Media image - responsive aspect ratios per Figma
&__media-img {
width: 100%;
object-fit: cover;
object-position: center;
// Mobile: 343/193 aspect ratio
aspect-ratio: 343 / 193;
@include media-breakpoint-up(md) {
// Tablet: 16/9 aspect ratio
aspect-ratio: 16 / 9;
}
@include media-breakpoint-up(lg) {
// Desktop: 701/561 aspect ratio (fills the 565px height)
aspect-ratio: 701 / 561;
height: $bds-single-topic-desktop-height;
}
}
// Content wrapper - uses space-between to push title to top, description/CTA to bottom
&__content {
display: flex;
flex-direction: column;
height: 100%;
// Gap between accent/title section and description section
gap: $bds-single-topic-mobile-title-description-gap; // 40px on mobile
// Min height on mobile to prevent squished content
min-height: $bds-single-topic-mobile-content-min-height;
justify-content: space-between;
@include media-breakpoint-up(md) {
gap: $bds-single-topic-tablet-title-description-gap; // 80px on tablet
min-height: $bds-single-topic-tablet-content-min-height;
}
@include media-breakpoint-up(lg) {
min-height: auto; // Desktop uses fixed height from row
gap: 0; // space-between handles the gap on desktop
}
}
// Title section - at the top
&__title-section {
flex-shrink: 0;
}
// Title - Heading MD from styles/_font.scss
// Font: Tobias (secondary/monospace), Size: 40px, Weight: 300, Line-height: 46px, Letter-spacing: -1px
&__title {
@include type(heading-md);
color: $bds-single-topic-title-color;
margin: 0;
}
// Description section - at the bottom, contains description + ButtonGroup
&__description-section {
display: flex;
flex-direction: column;
gap: $bds-single-topic-mobile-content-gap;
@include media-breakpoint-up(lg) {
gap: $bds-single-topic-desktop-description-gap;
}
}
// Description - Label L from styles/_font.scss
// Font: Booton (primary/sans-serif), Size: 16px, Weight: 300 (light), Line-height: 23.2px
&__description {
@include type(label-l);
color: $bds-single-topic-description-color;
margin: 0;
}
}
// =============================================================================
// Variant Modifiers
// =============================================================================
// Default variant - no background on title section
.bds-feature-single-topic--default {
.bds-feature-single-topic__title-section {
background-color: transparent;
padding: 0;
}
}
// AccentSurface variant - gray background on title section
.bds-feature-single-topic--accentSurface {
.bds-feature-single-topic__title-section {
background-color: $bds-single-topic-title-bg;
padding: $bds-single-topic-desktop-title-padding;
// Mobile min-height
min-height: 160px;
@include media-breakpoint-up(md) {
// Tablet min-height
min-height: 200px;
}
@include media-breakpoint-up(lg) {
// Desktop min-height
min-height: 200px;
}
}
}
// =============================================================================
// Dark Mode Theme Overrides
// =============================================================================
// Dark mode design tokens from Figma
$bds-single-topic-dark-bg: $black; // #141414 (Neutral/black)
$bds-single-topic-dark-title-bg: $gray-300; // #CAD4DF (Neutral/300default) for accentSurface variant
$bds-single-topic-dark-title-color: $black; // #141414 - title stays black on light background
$bds-single-topic-dark-description-color: $white; // #FFFFFF - description is white in dark mode
html.dark {
.bds-feature-single-topic {
background-color: $bds-single-topic-dark-bg;
&__title {
color: $white; // White title on dark background for default variant
}
&__description {
color: $bds-single-topic-dark-description-color;
}
}
// Default variant in dark mode - title is white on dark background
.bds-feature-single-topic--default {
.bds-feature-single-topic__title-section {
background-color: transparent;
}
.bds-feature-single-topic__title {
color: $white;
}
}
// AccentSurface variant in dark mode - title section has light background
.bds-feature-single-topic--accentSurface {
.bds-feature-single-topic__title-section {
background-color: $bds-single-topic-dark-title-bg;
}
// Title stays black on the light gray background
.bds-feature-single-topic__title {
color: $bds-single-topic-dark-title-color;
}
}
}

View File

@@ -0,0 +1,146 @@
import React from 'react';
import clsx from 'clsx';
import { PageGrid } from '../../components/PageGrid/page-grid';
import { ButtonGroup, ButtonConfig, validateButtonGroup } from 'shared/patterns/ButtonGroup/ButtonGroup';
export interface FeatureSingleTopicProps {
/** Background variant for the title section
* - 'default': No background on title section
* - 'accentSurface': Gray background (#E6EAF0) on title section
*/
variant?: 'default' | 'accentSurface';
/** Content arrangement - controls position of image relative to content
* - 'left': Image on left, content on right
* - 'right': Image on right, content on left
*/
orientation?: 'left' | 'right';
/** Feature title text (heading-md typography) */
title: string;
/** Feature description text (label-l typography) */
description?: string;
/** Array of links (1-5 links supported)
* - 1 link: renders as primary or secondary button (based on singleButtonVariant)
* - 2 links: renders as primary + tertiary buttons side by side
* - 3+ links: all tertiary buttons stacked
*/
buttons?: ButtonConfig[];
/** Button variant for single button configuration
* - 'primary': Primary button (default)
* - 'secondary': Secondary button
*/
singleButtonVariant?: 'primary' | 'secondary';
/** Feature media (image) configuration */
media: {
src: string;
alt: string;
};
/** Additional CSS classes */
className?: string;
}
/**
* FeatureSingleTopic Pattern
*
* A feature section pattern that pairs a title/description with a media element
* in a two-column layout. Supports two variants: default (no title background)
* and accentSurface (gray background on title section).
*
* Layout based on Figma 1280px design:
* - Desktop: Side-by-side with image 7 columns, content 5 columns
* - Mobile/Tablet: Stacked layout (full width)
*/
export const FeatureSingleTopic: React.FC<FeatureSingleTopicProps> = ({
variant = 'default',
orientation = 'left',
title,
description,
buttons = [],
singleButtonVariant = 'primary',
media,
className,
}) => {
// Validate buttons if provided (max 5 buttons supported)
const buttonValidation = validateButtonGroup(buttons, 5);
const hasButtons = buttonValidation.hasButtons;
// Button color is always green for this component
const buttonColor = 'green';
const forceColor = false;
// Build root class names
const rootClasses = clsx(
'bds-feature-single-topic',
`bds-feature-single-topic--${variant}`,
className
);
// Build row class names - column-reverse on mobile/tablet for both orientations
const rowClasses = clsx(
'bds-feature-single-topic__row',
'flex-column-reverse flex-lg-row' // Content above image on mobile, side-by-side on desktop
);
// Render content section (title at top, description/CTA at bottom)
const renderContent = () => (
<div className="bds-feature-single-topic__content">
<div className="bds-feature-single-topic__title-section">
<h2 className="bds-feature-single-topic__title">{title}</h2>
</div>
<div className="bds-feature-single-topic__description-section">
{description && (
<p className="bds-feature-single-topic__description">{description}</p>
)}
{hasButtons && (
<ButtonGroup
buttons={buttonValidation.buttons}
color={buttonColor}
forceColor={forceColor}
singleButtonVariant={singleButtonVariant}
/>
)}
</div>
</div>
);
// Render media section
const renderMedia = () => (
<div className="bds-feature-single-topic__media">
<img
src={media.src}
alt={media.alt}
className="bds-feature-single-topic__media-img"
/>
</div>
);
return (
<section className={rootClasses}>
<PageGrid className="bds-feature-single-topic__container" containerType="standard">
<PageGrid.Row className={rowClasses}>
<PageGrid.Col
span={{ base: 4, md: 8, lg: 7 }}
className={clsx(
'bds-feature-single-topic__media-col',
orientation === 'left' ? 'order-lg-1' : 'order-lg-2'
)}
>
{renderMedia()}
</PageGrid.Col>
<PageGrid.Col
span={{ base: 4, md: 8, lg: 5 }}
className={clsx(
'bds-feature-single-topic__content-col',
orientation === 'left' ? 'order-lg-2' : 'order-lg-1'
)}
>
{renderContent()}
</PageGrid.Col>
</PageGrid.Row>
</PageGrid>
</section>
);
};
export default FeatureSingleTopic;

View File

@@ -0,0 +1,185 @@
# FeatureSingleTopic Pattern
A feature section pattern that pairs a title/description with a media element in a two-column layout. Supports two variants (default, accentSurface) and two orientations (left, right).
## Features
- Responsive two-column layout (image + content) that stacks on smaller screens
- Two background variants: default (no background) and accentSurface (gray title background)
- Two orientations: left (image left) and right (image right)
- Flexible button layout supporting 1-5 links with automatic variant assignment
- Responsive image aspect ratios per Figma design
- Full dark mode support
- Uses PageGrid for consistent spacing
## Basic Usage
```tsx
import { FeatureSingleTopic } from 'shared/patterns/FeatureSingleTopic';
<FeatureSingleTopic
variant="default"
orientation="left"
title="Developer Spotlight"
description="Are you building a peer-to-peer payments solution?"
media={{
src: "/img/feature-image.png",
alt: "Feature image"
}}
links={[
{ label: "Get Started", href: "/start" },
{ label: "Learn More", href: "/learn" }
]}
/>
```
## Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `variant` | `'default' \| 'accentSurface'` | `'default'` | Background variant for title section |
| `orientation` | `'left' \| 'right'` | `'left'` | Image position relative to content |
| `title` | `string` | *required* | Feature title (heading-md typography) |
| `description` | `string` | - | Feature description (label-l typography) |
| `buttons` | `ButtonConfig[]` | `[]` | Array of button configurations (1-5 supported) |
| `singleButtonVariant` | `'primary' \| 'secondary'` | `'primary'` | Button variant for single button configuration |
| `media` | `{ src: string; alt: string }` | *required* | Image configuration |
| `className` | `string` | - | Additional CSS classes |
### ButtonConfig
```tsx
interface ButtonConfig {
label: string;
href?: string;
onClick?: () => void;
forceColor?: boolean;
}
```
**Note:** Button configurations are handled by the `ButtonGroup` component. See [ButtonGroup documentation](../ButtonGroup/README.md) for more details.
## Button Behavior
The component automatically determines button variants based on count:
| Count | Layout |
|-------|--------|
| 1 button | Primary or Secondary button (configurable via `singleButtonVariant` prop) |
| 2 buttons | Primary + Tertiary side by side |
| 3-5 buttons | All Tertiary buttons stacked |
**Note:** The component supports a maximum of 5 buttons. Additional buttons beyond 5 will trigger a validation warning in development mode and will be ignored. On mobile, the first two buttons (Primary + Tertiary) remain side by side.
## Variants
### Default
No background on the title section. Clean, minimal look.
```tsx
<FeatureSingleTopic variant="default" ... />
```
### AccentSurface
Gray background (#E6EAF0 light / #CAD4DF dark) on the title section.
```tsx
<FeatureSingleTopic variant="accentSurface" ... />
```
## Orientation
### Left (default)
Image on left, content on right on desktop.
### Right
Image on right, content on left on desktop.
**Note:** On mobile/tablet, content always appears above image regardless of orientation.
## Responsive Behavior
### Desktop (≥992px)
- Side-by-side layout: 7-column image, 5-column content
- Fixed height: 565px
- Image aspect ratio: 701/561
### Tablet (768px - 991px)
- Stacked layout with 32px gap between sections
- Image aspect ratio: 16/9
- Content min-height: 320px
### Mobile (<768px)
- Stacked layout with 24px gap between sections
- Image aspect ratio: 343/193
- Content min-height: 280px
## CSS Classes
```
.bds-feature-single-topic // Section container
.bds-feature-single-topic--default // Default variant modifier
.bds-feature-single-topic--accentSurface // AccentSurface variant modifier
.bds-feature-single-topic__container // PageGrid container
.bds-feature-single-topic__row // PageGrid row (uses flex-column-reverse flex-lg-row)
.bds-feature-single-topic__media-col // Media column (uses order-lg-1 or order-lg-2)
.bds-feature-single-topic__content-col // Content column (uses order-lg-1 or order-lg-2)
.bds-feature-single-topic__media // Media wrapper
.bds-feature-single-topic__media-img // Image element
.bds-feature-single-topic__content // Content wrapper
.bds-feature-single-topic__title-section // Title section
.bds-feature-single-topic__title // Title element
.bds-feature-single-topic__description-section // Description + buttons wrapper
.bds-feature-single-topic__description // Description element
```
**Note:**
- Orientation logic is handled via Bootstrap utility classes (`order-lg-1`, `order-lg-2`) applied dynamically in TSX
- Buttons are rendered by the `ButtonGroup` component with its own class structure
- Mobile/tablet layout uses `flex-column-reverse` to show content above image
- Desktop layout uses `flex-lg-row` for side-by-side display
## Typography Tokens
- **Title**: Uses `heading-md` type token (Tobias Light font)
- Desktop: 40px / 46px line-height / -1px letter-spacing
- **Description**: Uses `label-l` type token (Booton Light font)
- Desktop: 16px / 23.2px line-height
## Dark Mode
Full dark mode support with `html.dark` selector:
- **Section background**: #141414 (black)
- **Title (default variant)**: #FFFFFF (white)
- **Title (accentSurface)**: #141414 (black) on #CAD4DF background
- **Description**: #FFFFFF (white)
## Files
- `FeatureSingleTopic.tsx` - Main pattern component
- `FeatureSingleTopic.scss` - Styles with responsive breakpoints
- `index.ts` - Barrel exports
- `README.md` - This documentation
## Design References
- **Figma Design**: [Section Feature - Single Topic](https://www.figma.com/design/sg6T5EptbN0V2olfCSHzcx/Section-Feature---Single-Topic?node-id=18030-2250&m=dev)
- **Showcase Page**: `/about/feature-single-topic-showcase`
- **Component Location**: `shared/patterns/FeatureSingleTopic/`
## Related Components
- **Button**: Used for CTA buttons
- **PageGrid**: Used for responsive grid layout
## Version History
- **February 2026**: Initial implementation
- Two variants (default, accentSurface)
- Two orientations (left, right)
- Responsive image aspect ratios
- 1-5 link support with automatic button variant assignment
- Full dark mode support

View File

@@ -0,0 +1,3 @@
export { FeatureSingleTopic, type FeatureSingleTopicProps } from './FeatureSingleTopic';
export { default } from './FeatureSingleTopic';

View File

@@ -36,48 +36,32 @@ $bds-feature-description-color: $black; // #141414 (Neutral-black)
$bds-feature-description-color-dark: $black; // #141414 (same in dark mode)
// Spacing - Desktop (992px) - based on Figma 1280px design
$bds-feature-desktop-py: 96px;
$bds-feature-desktop-text-gap: 16px;
// Note: Uses centralized spacing tokens from _spacing.scss.
// $bds-grid-gutter is defined in _spacing.scss (8px).
$bds-feature-desktop-py: 96px; // Non-standard value, kept as-is
$bds-feature-desktop-text-gap: $bds-space-lg; // 16px - spacing('lg')
$bds-feature-desktop-cta-gap-col: 0; // Gap between button rows
$bds-feature-desktop-content-gap: 0; // Gap between text-group and cta (space-between handles this)
$bds-feature-desktop-button-gap: 16px; // Gap between buttons in ButtonGroup (consistent across all sizes)
// Spacing - Tablet (576px - 991px) - based on Figma 768px design
$bds-feature-tablet-py: 80px;
$bds-feature-tablet-pl: 115px; // Left padding from Figma
$bds-feature-tablet-pr: 107px; // Right padding from Figma
$bds-feature-tablet-text-gap: 8px;
$bds-feature-tablet-cta-gap-row: 16px; // Gap between buttons in row from Figma
$bds-feature-tablet-py: $bds-space-8xl; // 80px - spacing('8xl')
$bds-feature-tablet-pl: 115px; // Left padding from Figma (non-standard)
$bds-feature-tablet-pr: 107px; // Right padding from Figma (non-standard)
$bds-feature-tablet-text-gap: $bds-space-sm; // 8px - spacing('sm')
$bds-feature-tablet-cta-gap-row: $bds-space-lg; // 16px - spacing('lg')
$bds-feature-tablet-cta-gap-col: 0;
$bds-feature-tablet-content-gap: 32px;
$bds-feature-tablet-button-gap: 16px; // Gap between buttons in ButtonGroup (consistent across all sizes)
$bds-feature-tablet-content-gap: $bds-space-3xl; // 32px - spacing('3xl')
// Spacing - Mobile (<576px) - based on Figma 375px design
$bds-feature-mobile-py: 64px;
$bds-feature-mobile-px: 16px;
$bds-feature-mobile-text-gap: 8px;
$bds-feature-mobile-cta-gap: 16px; // Gap between stacked buttons from Figma
$bds-feature-mobile-content-gap: 24px;
$bds-feature-mobile-button-gap: 16px; // Gap between buttons in ButtonGroup (consistent across all sizes)
// Grid gutter - consistent with PageGrid
$bds-grid-gutter: 8px;
$bds-feature-mobile-py: $bds-space-6xl; // 64px - spacing('6xl')
$bds-feature-mobile-px: $bds-space-lg; // 16px - spacing('lg')
$bds-feature-mobile-text-gap: $bds-space-sm; // 8px - spacing('sm')
$bds-feature-mobile-cta-gap: $bds-space-lg; // 16px - spacing('lg')
$bds-feature-mobile-content-gap: $bds-space-2xl; // 24px - spacing('2xl')
// =============================================================================
// Base Styles
// =============================================================================
.bds-feature-two-column__button-group {
.bds-btn--tertiary {
padding-top: 0px !important;
padding-bottom: 0px !important;
}
}
.bds-feature-two-column__cta-row{
.bds-btn--tertiary {
padding-top: 16px !important;
}
}
.bds-feature-two-column {
width: 100%;
@@ -153,18 +137,18 @@ $bds-grid-gutter: 8px;
// Mobile: 4 columns, content full width
grid-template-columns: repeat(4, 1fr);
gap: $bds-grid-gutter;
padding: 0 16px; // Mobile edge padding
padding: 0 $bds-space-lg; // 16px - spacing('lg')
// Tablet: 8 columns
@include media-breakpoint-up(md) {
grid-template-columns: repeat(8, 1fr);
padding: 0 24px; // Tablet edge padding
padding: 0 $bds-space-2xl; // 24px - spacing('2xl')
}
// Desktop: 6 columns (within the 50% content half)
@include media-breakpoint-up(lg) {
grid-template-columns: repeat(6, 1fr);
padding: 0 32px; // Desktop edge padding
padding: 0 $bds-space-3xl; // 32px - spacing('3xl')
}
}
@@ -230,11 +214,14 @@ $bds-grid-gutter: 8px;
&__content--multiple {
// Mobile: no gap since we only have 2 items (text-group and button-group)
// The 24px gap is handled via button-group margin or flex gap
gap: 0;
gap: $bds-space-2xl; // 24px - spacing('2xl')
justify-content: flex-start;
@include media-breakpoint-up(md) {
gap: 0;
}
@include media-breakpoint-up(lg) {
gap: 0; // Desktop uses space-between for auto distribution
justify-content: space-between;
}
@@ -244,39 +231,6 @@ $bds-grid-gutter: 8px;
}
}
// Button group - contains all buttons in the multiple links layout
// 16px gap between buttons on ALL screen sizes
&__button-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: $bds-feature-mobile-button-gap; // 16px on mobile
margin-top: $bds-feature-mobile-content-gap; // 24px spacing from text-group on mobile
@include media-breakpoint-up(md) {
gap: $bds-feature-tablet-button-gap; // 16px on tablet
margin-top: $bds-feature-tablet-content-gap; // 32px spacing from text-group on tablet
}
@include media-breakpoint-up(lg) {
gap: $bds-feature-desktop-button-gap; // 16px on desktop
margin-top: 0; // Desktop uses space-between, no explicit margin needed
}
// Tertiary links need left padding removed to align text with title
>.bds-btn--tertiary {
padding-left: 0 !important;
margin-left: 0;
&:hover:not(:disabled):not(.bds-btn--disabled),
&:focus:not(:disabled):not(.bds-btn--disabled),
&:focus-visible:not(:disabled):not(.bds-btn--disabled),
&:active:not(:disabled):not(.bds-btn--disabled) {
padding-left: 0 !important;
}
}
}
// Text group - title + description
&__text-group {
display: flex;
@@ -308,69 +262,7 @@ $bds-grid-gutter: 8px;
margin: 0;
}
// CTA container - base styles
&__cta {
display: flex;
flex-direction: column;
align-items: flex-start;
@include media-breakpoint-up(md) {
flex-direction: row;
flex-wrap: wrap;
}
}
// CTA variant: Single secondary button - maintains vertical layout
&__cta--single {
flex-direction: column;
}
// CTA variant: Primary + tertiary - horizontal on tablet+
&__cta--double {
@include media-breakpoint-up(md) {
flex-direction: row;
align-items: center;
}
}
// CTA row - for first two buttons in multiple links layout (Primary + Tertiary)
// Horizontal layout on tablet+ (md and lg share same styles)
&__cta-row {
display: flex;
flex-direction: column;
align-items: flex-start;
@include media-breakpoint-up(md) {
flex-direction: row;
align-items: center;
}
@include media-breakpoint-up(lg) {
flex-direction: row;
align-items: center;
}
}
// Tertiary group - groups remaining tertiary links together as a single unit
// No gap between tertiary links - they stack tightly as a group
&__tertiary-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0; // No spacing between tertiary links in the group
// Tertiary links need left padding removed to align text with title
.bds-btn--tertiary {
padding-left: 0 !important;
margin-left: 0;
&:hover:not(:disabled):not(.bds-btn--disabled),
&:focus:not(:disabled):not(.bds-btn--disabled),
&:focus-visible:not(:disabled):not(.bds-btn--disabled),
&:active:not(:disabled):not(.bds-btn--disabled) {
padding-left: 0 !important;
}
}
}
// Media container
&__media {

View File

@@ -1,7 +1,7 @@
import React from 'react';
import clsx from 'clsx';
import { Button } from '../../components/Button/Button';
import { PageGrid } from '../../components/PageGrid/page-grid';
import { ButtonGroup, ButtonConfig, validateButtonGroup } from 'shared/patterns/ButtonGroup/ButtonGroup';
export interface FeatureTwoColumnLink {
/** Link label text */
@@ -59,6 +59,22 @@ export const FeatureTwoColumn: React.FC<FeatureTwoColumnProps> = ({
media,
className,
}) => {
// Determine button color based on background
// Rule: Black buttons must be used for all backgrounds (including neutral)
const buttonColor = 'black';
const forceColor = true;
// Convert links to ButtonConfig format
const buttonConfigs: ButtonConfig[] = links.map(link => ({
label: link.label,
href: link.href,
forceColor: forceColor,
}));
// Validate buttons (FeatureTwoColumn supports 1-5 links per design spec)
const buttonValidation = validateButtonGroup(buttonConfigs, 5);
const hasButtons = buttonValidation.hasButtons;
// Build root class names
const rootClasses = clsx(
'bds-feature-two-column',
@@ -67,94 +83,30 @@ export const FeatureTwoColumn: React.FC<FeatureTwoColumnProps> = ({
className
);
// Determine button color based on background
// Rule: Black buttons must be used for all backgrounds (including neutral)
const buttonColor = 'black';
const forceColor = true;
// Render content section with appropriate CTA layout based on link count
// For 3-5 links, items are direct children for space-between distribution
// Render content section with ButtonGroup
const renderContent = () => {
const linkCount = links.length;
// Determine content class based on validated button count
const contentClass = clsx(
'bds-feature-two-column__content',
{
'bds-feature-two-column__content--multiple': hasButtons && buttonValidation.buttons.length >= 3,
}
);
// 1 link: Secondary button
if (linkCount === 1) {
return (
<div className="bds-feature-two-column__content">
<div className="bds-feature-two-column__text-group">
<h2 className="bds-feature-two-column__title">{title}</h2>
<p className="bds-feature-two-column__description">{description}</p>
</div>
<div className="bds-feature-two-column__cta bds-feature-two-column__cta--single">
<Button variant="secondary" color={buttonColor} forceColor={forceColor} href={links[0].href}>
{links[0].label}
</Button>
</div>
</div>
);
}
// 2 links: Primary + Tertiary in a row
if (linkCount === 2) {
return (
<div className="bds-feature-two-column__content">
<div className="bds-feature-two-column__text-group">
<h2 className="bds-feature-two-column__title">{title}</h2>
<p className="bds-feature-two-column__description">{description}</p>
</div>
<div className="bds-feature-two-column__cta bds-feature-two-column__cta--double">
<Button variant="primary" color={buttonColor} forceColor={forceColor} href={links[0].href}>
{links[0].label}
</Button>
<Button variant="tertiary" color={buttonColor} forceColor={forceColor} href={links[1].href}>
{links[1].label}
</Button>
</div>
</div>
);
}
// 3-5 links: Text group + Button group (contains all buttons with consistent 16px spacing)
// Desktop: space-between distribution between text-group and button-group
// Tablet: 32px gap, Mobile: 24px gap
return (
<div className="bds-feature-two-column__content bds-feature-two-column__content--multiple">
<div className={contentClass}>
<div className="bds-feature-two-column__text-group">
<h2 className="bds-feature-two-column__title">{title}</h2>
<p className="bds-feature-two-column__description">{description}</p>
</div>
{/* Button group - all buttons grouped with 16px spacing between them */}
<div className="bds-feature-two-column__button-group">
{/* First two links in a row: Primary + Tertiary */}
<div className="bds-feature-two-column__cta-row">
<Button variant="primary" color={buttonColor} forceColor={forceColor} href={links[0].href}>
{links[0].label}
</Button>
{links[1] && (
<Button variant="tertiary" color={buttonColor} forceColor={forceColor} href={links[1].href}>
{links[1].label}
</Button>
)}
</div>
{/* Secondary button */}
{links[2] && (
<Button variant="secondary" color={buttonColor} forceColor={forceColor} href={links[2].href}>
{links[2].label}
</Button>
)}
{/* Remaining tertiary links */}
{links.slice(3).map((link) => (
<Button
key={`${link.href}-${link.label}`}
variant="tertiary"
color={buttonColor}
forceColor={forceColor}
href={link.href}
>
{link.label}
</Button>
))}
</div>
{hasButtons && (
<ButtonGroup
buttons={buttonValidation.buttons}
color={buttonColor}
forceColor={forceColor}
singleButtonVariant="secondary"
/>
)}
</div>
);
};

View File

@@ -0,0 +1,147 @@
import React, { forwardRef, useCallback } from "react";
import clsx from "clsx";
import { PageGrid } from "shared/components/PageGrid/page-grid";
import { Video, type VideoSource } from "shared/components/Video";
import { ButtonGroup, ButtonConfig, validateButtonGroup } from "shared/patterns/ButtonGroup/ButtonGroup";
import { isEmpty, isEnvironment } from "shared/utils";
import {
DesignConstrainedLinksProps,
DesignConstrainedVideoProps,
} from "shared/utils/types";
/** Video config for embed (YouTube/Vimeo/Wistia) with optional cover image */
export interface FeaturedVideoHeroVideoConfig {
source: VideoSource;
coverImage?: {
src: string;
alt: string;
};
}
export interface FeaturedVideoHeroProps
extends
React.ComponentPropsWithoutRef<"header">,
DesignConstrainedLinksProps {
headline: React.ReactNode;
subtitle?: React.ReactNode;
/** Native HTML video props (use when video is not provided) */
videoElement?: DesignConstrainedVideoProps;
/** Video config for embed + optional cover modal (use when videoElement is not provided) */
video?: FeaturedVideoHeroVideoConfig;
}
const FeaturedVideoHero = forwardRef<HTMLElement, FeaturedVideoHeroProps>(
(props, ref) => {
const {
headline,
subtitle,
videoElement,
video,
links,
className,
...rest
} = props;
const hasVideo = !isEmpty(video) || !isEmpty(videoElement);
const validateProps = useCallback<() => boolean>(() => {
if (isEmpty(headline)) {
if (isEnvironment(["development", "test"])) {
console.warn("headline is required for FeaturedVideoHero");
}
return false;
}
if (!hasVideo) {
if (isEnvironment(["development", "test"])) {
console.warn("videoElement or video is required for FeaturedVideoHero");
}
return false;
}
return true;
}, [headline, hasVideo]);
if (!validateProps()) {
return null;
}
// Map links to ButtonConfig format for ButtonGroup
const buttonConfigs: ButtonConfig[] = (links ?? [])
.filter((link) => !isEmpty(link) && link.label && link.href)
.map((link) => ({
label: link.label,
href: link.href,
forceColor: true,
}));
// Validate buttons (max 2 CTAs supported)
const buttonValidation = validateButtonGroup(
buttonConfigs,
2,
isEnvironment(["development", "test"]) // Only log warnings in dev/test
);
const hasLinks = buttonValidation.hasButtons;
return (
<header
ref={ref}
className={clsx("bds-featured-video-hero", className)}
{...rest}
>
<PageGrid>
<PageGrid.Row>
<PageGrid.Col span={{ base: 4, md: 8, lg: 6 }}>
<div className="bds-featured-video-hero__content">
<h1 className="mb-0 h-md">
{headline}
</h1>
<div className="bds-featured-video-hero__bottom-group">
{subtitle && (
<PageGrid.Row className="bds-featured-video-hero__subtitle body-l">
<PageGrid.Col
span={{ base: "fill", md: 6, lg: 10 }}
className="bds-featured-video-hero__subtitle-col"
as="p"
>
{subtitle}
</PageGrid.Col>
</PageGrid.Row>
)}
{hasLinks && (
<ButtonGroup
buttons={buttonValidation.buttons}
color="green"
forceColor
gap="small"
/>
)}
</div>
</div>
</PageGrid.Col>
<PageGrid.Col
span={{ base: 4, md: 8, lg: 6 }}
>
<div className="bds-featured-video-hero__video-container">
{video ? (
<Video
source={video.source}
coverImage={video.coverImage}
className="bds-featured-video-hero__video"
/>
) : (
<Video
source={{ type: "native", props: videoElement! }}
className="bds-featured-video-hero__video"
/>
)}
</div>
</PageGrid.Col>
</PageGrid.Row>
</PageGrid>
</header>
);
},
);
FeaturedVideoHero.displayName = "FeaturedVideoHero";
export default FeaturedVideoHero;

View File

@@ -0,0 +1,161 @@
# FeaturedVideoHero Pattern
A page-level hero pattern featuring a headline, optional subtitle, call-to-action buttons, and a featured video. The video uses native HTML `<video>` props and is displayed in a responsive two-column layout with content on the left and video on the right.
## Overview
The FeaturedVideoHero component provides a structured hero section with:
- Responsive two-column layout (content left, video right) that stacks on smaller screens
- Required headline and video; optional subtitle and call-to-action buttons
- Design-constrained CTAs: primary and optional secondary, with variant and color set by the component
- Development-time validation: returns `null` when required props are missing and logs warnings in development/test
## Basic Usage
```tsx
import { FeaturedVideoHero } from "shared/patterns/FeaturedVideoHero";
function MyPage() {
return (
<FeaturedVideoHero
headline="Build on XRPL"
subtitle={
<p>
Issue, manage, and trade real-world assets without needing to build
smart contracts.
</p>
}
links={[{ label: "Get Started", href: "/docs" }]}
videoElement={{
src: "/video/intro.mp4",
autoPlay: true,
loop: true,
muted: true,
playsInline: true,
}}
/>
);
}
```
## Props
| Prop | Type | Required | Description |
| --------------- | --------------------------------- | -------- | ---------------------------------------------------------------------------- |
| `headline` | `React.ReactNode` | Yes | Hero headline text (h-md typography) |
| `subtitle` | `React.ReactNode` | No | Hero subtitle content |
| `links` | `DesignConstrainedLink[]` | No | Array of `{ label, href }` for ButtonGroup. Omit to hide button section. |
| `videoElement` | `DesignConstrainedVideoProps` | Yes | Native `<video>` element props (e.g. `src`, `autoPlay`, `loop`, `muted`) |
| `className` | `string` | No | Additional CSS classes for the header element |
| `...rest` | `HTMLHeaderElement` attributes | No | Any other HTML header attributes |
### Links (ButtonGroup)
The `links` prop is optional. When provided, at least one non-empty link (`label` and `href`) is required to show the button section. Uses `{ label, href }` format for consistent ButtonGroup rendering; `variant` and `color` are set by the component:
- **First link**: `variant="primary"`, `color="green"`, `forceColor={true}`
- **Second link**: `variant="tertiary"`, `color="green"`, `forceColor={true}`
- Max 2 links supported (ButtonGroup validation)
### Video Element
`videoElement` accepts native HTML video element props. Required and commonly used props:
- `src` (required) Video URL
- `autoPlay`, `loop`, `muted`, `playsInline` Typical for background/hero autoplay
- `controls`, `preload`, `poster` Optional; use for user-controlled playback
The video is rendered with `object-fit: cover` and a 16:9 aspect ratio container.
## Examples
### With primary and secondary CTAs
```tsx
<FeaturedVideoHero
headline="Real-world asset tokenization"
subtitle="Learn how to issue crypto tokens and build tokenization solutions."
links={[
{ label: "Get Started", href: "/docs" },
{ label: "Learn More", href: "/about" },
]}
videoElement={{
src: "/video/tokenization.mp4",
autoPlay: true,
loop: true,
muted: true,
playsInline: true,
}}
/>
```
### Without subtitle
```tsx
<FeaturedVideoHero
headline="Headline Only"
callsToAction={[{ children: "Get Started", href: "/docs" }]}
videoElement={{
src: "/video/intro.mp4",
autoPlay: true,
loop: true,
muted: true,
playsInline: true,
}}
/>
```
### With video controls
```tsx
<FeaturedVideoHero
headline="Watch and Learn"
subtitle="Explore our video tutorials and guides."
links={[{ label: "Watch Tutorials", href: "/tutorials" }]}
videoElement={{
src: "/video/intro.mp4",
autoPlay: false,
loop: true,
muted: true,
playsInline: true,
controls: true,
preload: "metadata",
}}
/>
```
## Validation
- **Required props**: `headline`, `videoElement`. If either is missing or empty, the component returns `null` and (in development/test) logs a console warning.
- **Optional props**: `subtitle`, `links`. Omit `links` or pass an empty array to hide the button section.
## Responsive Behavior
- **Mobile / small screens**: Content and video stack vertically; video appears below the content block with top margin.
- **Large (lg+)**: Two-column layout: content (5 cols) on the left, video (6 cols, offset 1) on the right. Video container uses 16:9 aspect ratio and `object-fit: cover`.
## CSS Classes
- `bds-featured-video-hero` Root header element
- `bds-featured-video-hero__content` Content column (headline, subtitle, CTAs)
- `bds-featured-video-hero__title` Headline (`h1`)
- `bds-featured-video-hero__subtitle` Subtitle row
- `bds-featured-video-hero__subtitle-col` Subtitle column
- `bds-featured-video-hero__cta-buttons` CTA buttons wrapper
- `bds-featured-video-hero__video-container` Video wrapper (16:9)
- `bds-featured-video-hero__video` Video element
## Best Practices
1. **Video format**: Use MP4 with H.264 for broad compatibility; keep file sizes reasonable for fast loading.
2. **Autoplay**: Use `muted` and `playsInline` with `autoPlay` for reliable autoplay on mobile.
3. **CTAs**: Keep CTA text concise and action-oriented; primary CTA should be the main action.
4. **Headlines**: Keep headlines concise; use the subtitle for additional context.
5. **Accessibility**: Provide an `aria-label` (or other accessible name) on the video when it conveys meaningful content.
## Showcase
An interactive showcase with more examples and prop documentation is available at:
- **Showcase page**: `/about/featured-video-hero-showcase.page.tsx`

View File

@@ -0,0 +1,91 @@
// BDS FeaturedVideoHero Pattern Styles
// Note: Uses centralized spacing tokens from _spacing.scss.
.bds-featured-video-hero{
padding: $bds-space-2xl 0; // 24px - spacing('2xl')
@include media-breakpoint-up(md) {
padding: $bds-space-3xl 0; // 32px - spacing('3xl')
}
@include media-breakpoint-up(lg) {
padding: $bds-space-4xl 0; // 40px - spacing('4xl')
}
@include bds-theme-mode(light) {
background-color: $white;
}
@include bds-theme-mode(dark) {
background-color: $black;
}
&__content {
display: flex;
flex-direction: column;
gap: $bds-space-2xl; // 24px - spacing('2xl')
height: 100%;
@include media-breakpoint-up(md) {
gap: $bds-space-3xl; // 32px - spacing('3xl')
}
@include media-breakpoint-up(lg) {
gap: $bds-space-4xl; // 40px - spacing('4xl')
}
}
&__subtitle {
width: 100%;
margin: 0;
padding: 0;
}
&__video-container {
width: 100%;
height: auto;
aspect-ratio: 16 / 9;
overflow: hidden;
margin-top: $bds-space-lg; // 16px - spacing('lg')
@include media-breakpoint-up(md) {
margin-top: $bds-space-2xl; // 24px - spacing('2xl')
}
@include media-breakpoint-up(lg) {
margin-top: $bds-space-none; // 0px - spacing('none')
}
}
&__video {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
display: inline-block;
}
&__bottom-group {
display: flex;
flex-direction: column;
align-items: flex-start;
flex-wrap: wrap;
gap: $bds-space-2xl; // 24px - spacing('2xl')
margin-top: auto;
width: 100%;
@include media-breakpoint-up(md) {
flex-direction: row !important;
align-items: center;
gap: $bds-space-3xl; // 32px - spacing('3xl')
}
@include media-breakpoint-up(lg) {
gap: $bds-space-4xl; // 40px - spacing('4xl')
}
p:last-child {
margin-bottom: 0;
}
}
}

View File

@@ -1,48 +1,27 @@
import React, { forwardRef, memo, useEffect } from "react";
import clsx from "clsx";
import { PageGrid } from "shared/components/PageGrid/page-grid";
import { Button, ButtonProps } from "shared/components/Button/Button";
import { ButtonGroup, validateButtonGroup } from "shared/patterns/ButtonGroup/ButtonGroup";
import { isEmpty, isEnvironment } from "shared/utils";
import {
isEmpty,
DesignConstrainedButtonProps,
isEnvironment,
} from "shared/utils";
/**
* Base props that all media elements must have to ensure proper styling.
* These props are automatically applied to maintain the 9:16 aspect ratio
* and object-fit: cover behavior.
*/
type MediaStyleProps = {
className?: string;
style?: React.CSSProperties;
};
DesignConstrainedImageProps,
DesignConstrainedVideoProps,
DesignConstrainedLinksProps,
} from "shared/utils/types";
/**
* Image media type - extends native img element props
*/
type ImageMediaProps = {
type: "image";
} & Omit<
React.ComponentPropsWithoutRef<"img">,
keyof MediaStyleProps | "src" | "alt"
> & {
src: string; // Required for image media
alt: string; // Required for image media
};
} & DesignConstrainedImageProps;
/**
* Video media type - extends native video element props
*/
type VideoMediaProps = {
type: "video";
} & Omit<
React.ComponentPropsWithoutRef<"video">,
keyof MediaStyleProps | "src"
> & {
src: string; // Required for video media
alt?: string; // Optional for video, but recommended for accessibility
};
} & DesignConstrainedVideoProps;
/**
* Custom element media type - allows passing any React element
@@ -63,12 +42,11 @@ export type HeaderHeroMedia =
| VideoMediaProps
| CustomMediaProps;
export interface HeaderHeroPrimaryMediaProps extends React.ComponentPropsWithoutRef<"header"> {
export interface HeaderHeroPrimaryMediaProps extends React.ComponentPropsWithoutRef<"header">, DesignConstrainedLinksProps {
/** Hero title text (display-md typography) */
headline: React.ReactNode;
/** Hero subtitle text (subhead-sm-l typography) */
subtitle: React.ReactNode;
callsToAction: [DesignConstrainedButtonProps, DesignConstrainedButtonProps?];
/** Media element - supports image, video, or custom React element */
media: HeaderHeroMedia;
}
@@ -95,6 +73,7 @@ const MediaRenderer: React.FC<{ media: HeaderHeroMedia }> = memo(
}
case "video": {
// alt here is being used as a aria label value
const { type, alt, ...videoProps } = media;
return (
<div className={mediaContainerClassName}>
@@ -127,10 +106,14 @@ const HeaderHeroPrimaryMedia = forwardRef<
HTMLElement,
HeaderHeroPrimaryMediaProps
>((props, ref) => {
const { headline, subtitle, callsToAction, media, className, ...restProps } =
const { headline, subtitle, links, media, className, ...restProps } =
props;
const [primaryCta, secondaryCta] = callsToAction;
const buttonValidation = validateButtonGroup(
(links ?? []).map((l) => ({ label: l.label, href: l.href, forceColor: true })),
2,
isEnvironment(["development", "test"])
);
// Headline is critical - exit early if missing
if (!headline) {
@@ -149,7 +132,7 @@ const HeaderHeroPrimaryMedia = forwardRef<
const propsToValidate = {
subtitle,
callsToAction,
links,
media,
};
@@ -158,7 +141,7 @@ const HeaderHeroPrimaryMedia = forwardRef<
console.warn(`${key} is required for HeaderHeroPrimaryMedia`);
}
});
}, [subtitle, callsToAction, media]);
}, [subtitle, links, media]);
return (
<header
@@ -183,28 +166,14 @@ const HeaderHeroPrimaryMedia = forwardRef<
{subtitle}
</div>
)}
{(!isEmpty(primaryCta) || !isEmpty(secondaryCta)) && (
{buttonValidation.hasButtons && (
<div className="bds-header-hero-primary-media__cta-buttons">
{!isEmpty(primaryCta) && (
<Button
{...primaryCta!}
variant="primary"
color="green"
showIcon={true}
/>
)}
{!isEmpty(secondaryCta) && (
<Button
{...secondaryCta!}
className={clsx(
"bds-header-hero-primary-media__cta-button-tertiary",
secondaryCta?.className,
)}
variant="tertiary"
color="green"
showIcon={true}
/>
)}
<ButtonGroup
buttons={buttonValidation.buttons}
color="green"
forceColor
gap="small"
/>
</div>
)}
</div>

View File

@@ -21,7 +21,7 @@ function MyPage() {
<HeaderHeroPrimaryMedia
headline="Build on XRPL"
subtitle="Start developing today with our comprehensive developer tools."
callsToAction={[{ children: "Get Started", href: "/docs" }]}
links={[{ label: "Get Started", href: "/docs" }]}
media={{
type: "image",
src: "/img/hero.png",
@@ -38,19 +38,18 @@ function MyPage() {
| --------------- | ------------------------------ | -------- | ------------------------------------------------------------ |
| `headline` | `React.ReactNode` | Yes | Hero headline text (display-md typography) |
| `subtitle` | `React.ReactNode` | Yes | Hero subtitle text (label-l typography) |
| `callsToAction` | `[ButtonProps, ButtonProps?]` | Yes | Array with primary CTA (required) and optional secondary CTA |
| `links` | `DesignConstrainedLink[]` | No | Array of `{ label, href }` for ButtonGroup |
| `media` | `HeaderHeroMedia` | Yes | Media element (image, video, or custom) |
| `className` | `string` | No | Additional CSS classes for the header element |
| `...rest` | `HTMLHeaderElement attributes` | No | Any other HTML header attributes |
### Calls to Action
### Links (ButtonGroup)
The `callsToAction` prop accepts Button component props, but `variant` and `color` are automatically set:
The `links` prop accepts an array of `{ label, href }` objects for consistent ButtonGroup rendering; `variant` and `color` are set by the component:
- **Primary CTA**: `variant="primary"`, `color="green"`
- **Secondary CTA**: `variant="tertiary"`, `color="green"`
All other Button props are supported (e.g., `children`, `href`, `onClick`, etc.).
- **First link**: `variant="primary"`, `color="green"`
- **Second link**: `variant="tertiary"`, `color="green"`
- Max 2 links supported (ButtonGroup validation)
## Media Types
@@ -130,9 +129,9 @@ media={{
<HeaderHeroPrimaryMedia
headline="Real-world asset tokenization"
subtitle="Learn how to issue crypto tokens and build solutions."
callsToAction={[
{ children: "Get Started", href: "/docs" },
{ children: "Learn More", href: "/about" },
links={[
{ label: "Get Started", href: "/docs" },
{ label: "Learn More", href: "/about" },
]}
media={{
type: "image",
@@ -148,7 +147,7 @@ media={{
<HeaderHeroPrimaryMedia
headline="Watch and Learn"
subtitle="Explore our video tutorials."
callsToAction={[{ children: "Watch Tutorials", href: "/tutorials" }]}
links={[{ label: "Watch Tutorials", href: "/tutorials" }]}
media={{
type: "video",
src: "/video/intro.mp4",
@@ -166,7 +165,7 @@ media={{
<HeaderHeroPrimaryMedia
headline="Interactive Experience"
subtitle="Engage with custom media."
callsToAction={[{ children: "Explore", href: "/interactive" }]}
links={[{ label: "Explore", href: "/interactive" }]}
media={{
type: "custom",
element: <MyAnimationComponent />,
@@ -190,7 +189,7 @@ The component enforces specific design requirements:
The component includes development-time validation that logs warnings to the console when required props are missing:
- Missing `headline`: Component returns `null` (error logged)
- Missing `subtitle`, `callsToAction`, or `media`: Warning logged, component still renders
- Missing `subtitle`, `links`, or `media`: Warning logged, component still renders
## CSS Classes

View File

@@ -1,16 +1,19 @@
// BDS HeaderHeroPrimaryMedia Pattern Styles
// Note: Uses centralized spacing tokens from _spacing.scss.
.bds-header-hero-primary-media {
padding-top: 24px;
padding-bottom: 24px;
padding-top: $bds-space-2xl; // 24px - spacing('2xl')
padding-bottom: $bds-space-2xl; // 24px - spacing('2xl')
@include media-breakpoint-up(md) {
padding-top: 32px;
padding-bottom: 32px;
padding-top: $bds-space-3xl; // 32px - spacing('3xl')
padding-bottom: $bds-space-3xl; // 32px - spacing('3xl')
}
@include media-breakpoint-up(lg) {
padding-top: 170px;
padding-bottom: 40px;
padding-top: 170px; // Non-standard value, kept as-is
padding-bottom: $bds-space-4xl; // 40px - spacing('4xl')
}
@include bds-theme-mode(light) {
@@ -22,10 +25,10 @@
}
&__headline-container {
margin-bottom: 32px; // this margin is also default with the class - however, to avoid regressive changes we are reinforcing here
margin-bottom: $bds-space-3xl; // 32px - spacing('3xl')
@include media-breakpoint-up(lg) {
margin-bottom: 0px;
margin-bottom: $bds-space-none; // 0px - spacing('none')
}
}
@@ -56,12 +59,12 @@
&__cta-container {
display: flex;
flex-direction: column;
gap: 24px;
gap: $bds-space-2xl; // 24px - spacing('2xl')
justify-content: flex-end;
min-height: 100%;
@include media-breakpoint-up(lg) {
gap: 40px;
gap: $bds-space-4xl; // 40px - spacing('4xl')
}
}
@@ -74,7 +77,7 @@
align-items: flex-start;
justify-content: center;
flex-wrap: wrap;
gap: 16px;
gap: $bds-space-lg; // 16px - spacing('lg')
@include media-breakpoint-up(md) {
flex-direction: row;
@@ -83,7 +86,7 @@
}
@include media-breakpoint-up(lg) {
gap: 24px;
gap: $bds-space-2xl; // 24px - spacing('2xl')
}
& .bds-btn--tertiary {
@@ -101,19 +104,19 @@
&__media-container {
width: 100%;
aspect-ratio: 16 / 9; // Design req uirement: 16/9 aspect ratio
aspect-ratio: 16 / 9; // Design requirement: 16/9 aspect ratio
overflow: hidden;
position: relative;
margin-top: 24px;
margin-top: $bds-space-2xl; // 24px - spacing('2xl')
height: auto;
@include media-breakpoint-up(md) {
margin-top: 32px;
margin-top: $bds-space-3xl; // 32px - spacing('3xl')
aspect-ratio: 2 / 1;
}
@include media-breakpoint-up(lg) {
margin-top: 40px;
margin-top: $bds-space-4xl; // 40px - spacing('4xl')
aspect-ratio: 3 / 1;
}
}

View File

@@ -73,39 +73,40 @@ $bds-hero-description-light: $gray-500; // #72777E - Description text in ligh
$bds-hero-description-dark: $gray-200; // #E6EAF0 - Description text in dark mode (neutral/200)
// Spacing - Desktop (992px)
$bds-hero-desktop-container-py: 40px;
$bds-hero-desktop-title-surface-pt: 16px;
$bds-hero-desktop-title-surface-px: 16px;
$bds-hero-desktop-title-surface-pb: 24px;
$bds-hero-desktop-title-gap: 16px;
$bds-hero-desktop-description-gap: 40px;
$bds-hero-desktop-cta-gap: 24px;
$bds-hero-desktop-content-gap: 8px;
// Note: Uses centralized spacing tokens from _spacing.scss.
$bds-hero-desktop-container-py: $bds-space-4xl; // 40px - spacing('4xl')
$bds-hero-desktop-title-surface-pt: $bds-space-lg; // 16px - spacing('lg')
$bds-hero-desktop-title-surface-px: $bds-space-lg; // 16px - spacing('lg')
$bds-hero-desktop-title-surface-pb: $bds-space-2xl; // 24px - spacing('2xl')
$bds-hero-desktop-title-gap: $bds-space-lg; // 16px - spacing('lg')
$bds-hero-desktop-description-gap: $bds-space-4xl; // 40px - spacing('4xl')
$bds-hero-desktop-cta-gap: $bds-space-2xl; // 24px - spacing('2xl')
$bds-hero-desktop-content-gap: $bds-space-sm; // 8px - spacing('sm')
// Spacing - Tablet (576px - 991px)
// Base values for DEFAULT surface
$bds-hero-tablet-container-py: 32px;
$bds-hero-tablet-title-surface-pt: 16px;
$bds-hero-tablet-title-surface-px: 16px;
$bds-hero-tablet-title-surface-pb: 24px;
$bds-hero-tablet-title-gap: 8px; // Default: 8px, Accent: 16px
$bds-hero-tablet-description-gap: 32px; // Default: 32px, Accent: 24px
$bds-hero-tablet-cta-gap: 16px; // Both: 16px
$bds-hero-tablet-content-gap: 32px; // Default: 32px, Accent: 40px
$bds-hero-tablet-container-py: $bds-space-3xl; // 32px - spacing('3xl')
$bds-hero-tablet-title-surface-pt: $bds-space-lg; // 16px - spacing('lg')
$bds-hero-tablet-title-surface-px: $bds-space-lg; // 16px - spacing('lg')
$bds-hero-tablet-title-surface-pb: $bds-space-2xl; // 24px - spacing('2xl')
$bds-hero-tablet-title-gap: $bds-space-sm; // 8px - spacing('sm')
$bds-hero-tablet-description-gap: $bds-space-3xl; // 32px - spacing('3xl')
$bds-hero-tablet-cta-gap: $bds-space-lg; // 16px - spacing('lg')
$bds-hero-tablet-content-gap: $bds-space-3xl; // 32px - spacing('3xl')
// Accent-specific tablet spacing
$bds-hero-tablet-accent-title-gap: 16px;
$bds-hero-tablet-accent-description-gap: 24px;
$bds-hero-tablet-accent-content-gap: 40px;
$bds-hero-tablet-accent-title-gap: $bds-space-lg; // 16px - spacing('lg')
$bds-hero-tablet-accent-description-gap: $bds-space-2xl; // 24px - spacing('2xl')
$bds-hero-tablet-accent-content-gap: $bds-space-4xl; // 40px - spacing('4xl')
// Spacing - Mobile (<576px)
$bds-hero-mobile-container-py: 24px;
$bds-hero-mobile-title-surface-pt: 8px;
$bds-hero-mobile-title-surface-px: 8px;
$bds-hero-mobile-title-surface-pb: 16px;
$bds-hero-mobile-title-gap: 8px;
$bds-hero-mobile-description-gap: 24px;
$bds-hero-mobile-cta-gap: 16px;
$bds-hero-mobile-content-gap: 32px;
$bds-hero-mobile-container-py: $bds-space-2xl; // 24px - spacing('2xl')
$bds-hero-mobile-title-surface-pt: $bds-space-sm; // 8px - spacing('sm')
$bds-hero-mobile-title-surface-px: $bds-space-sm; // 8px - spacing('sm')
$bds-hero-mobile-title-surface-pb: $bds-space-lg; // 16px - spacing('lg')
$bds-hero-mobile-title-gap: $bds-space-sm; // 8px - spacing('sm')
$bds-hero-mobile-description-gap: $bds-space-2xl; // 24px - spacing('2xl')
$bds-hero-mobile-cta-gap: $bds-space-lg; // 16px - spacing('lg')
$bds-hero-mobile-content-gap: $bds-space-3xl; // 32px - spacing('3xl')
// =============================================================================
// Base Styles

View File

@@ -0,0 +1,46 @@
// BDS LinkSmallGrid Pattern Styles
// Brand Design System - Link grid section pattern with heading and responsive tile grid
//
// Naming Convention: BEM with 'bds' namespace
// .bds-link-small-grid - Base section container
// =============================================================================
// Design Tokens
// =============================================================================
// Note: Uses centralized spacing tokens from _spacing.scss.
// Spacing tokens
$bds-link-small-grid-spacing-base: $bds-space-2xl; // 24px - spacing('2xl')
$bds-link-small-grid-spacing-md: $bds-space-3xl; // 32px - spacing('3xl')
$bds-link-small-grid-spacing-lg: $bds-space-4xl; // 40px - spacing('4xl')
// Typography tokens (using existing typography classes)
// - Heading: h-md class (handled in component)
// - Description: body-l class (handled in component)
// =============================================================================
// Base Section Styles
// =============================================================================
.bds-link-small-grid {
// Section spacing
padding-top: $bds-link-small-grid-spacing-base;
padding-bottom: $bds-link-small-grid-spacing-base;
@include media-breakpoint-up(md) {
padding-top: $bds-link-small-grid-spacing-md;
padding-bottom: $bds-link-small-grid-spacing-md;
}
@include media-breakpoint-up(lg) {
padding-top: $bds-link-small-grid-spacing-lg;
padding-bottom: $bds-link-small-grid-spacing-lg;
}
// Background color - default to transparent
background-color: transparent;
}
// Header section uses SectionHeader component

View File

@@ -0,0 +1,121 @@
import React, { useMemo } from 'react';
import clsx from 'clsx';
import { PageGrid, PageGridRow, PageGridCol } from 'shared/components/PageGrid/page-grid';
import { SectionHeader } from 'shared/patterns/SectionHeader';
import { TileLink, TileLinkProps } from 'shared/patterns/TileLinks';
import { calculateTileOffset } from 'shared/utils/helpers';
export interface LinkItem extends Omit<TileLinkProps, 'variant'> {}
export interface LinkSmallGridProps {
/** Color variant - determines tile background color */
variant?: 'gray' | 'lilac';
/** Heading text (required) */
heading: string;
/** Optional description text */
description?: string;
/** Array of link items to display in the grid */
links: LinkItem[];
/** Additional CSS classes */
className?: string;
}
/**
* LinkSmallGrid Component
*
* A responsive grid section pattern for displaying navigational links using TileLink components.
* Features a heading, optional description, and a grid of clickable tiles with 2 color variants
* and full light/dark mode support.
*
* Grid Layout (12-column grid system):
* - Base (< 576px): 1 tile per row (each tile spans 4 of 4 columns = full width)
* - MD (576px - 991px): 2 tiles per row (each tile spans 4 of 8 columns = 50% width)
* - LG (≥ 992px): 4 tiles per row (each tile spans 3 of 12 columns = 25% width)
*
* Right-Alignment Logic (applied when < 10 total tiles):
* The first tile of each row gets an offset to right-align the grid at LG breakpoint only:
* - LG: 1 tile = offset 9, 2 tiles = offset 6, 3 tiles = offset 3, 4 tiles = offset 0
* - 10+ tiles: no offset (left-aligned grid)
* - MD and Base: no offset applied
*
* Each tile uses the TileLink component which features:
* - Window shade hover animation
* - Arrow icon with animation
* - Responsive sizing (64px height at all breakpoints)
* - Support for both links (href) and buttons (onClick)
* - Gray and Lilac color variants
*
* @example
* // Basic usage with gray variant
* <LinkSmallGrid
* variant="gray"
* heading="Quick Links"
* description="Navigate to key sections"
* links={[
* { label: "Documentation", href: "/docs" },
* { label: "Tutorials", href: "/tutorials" }
* ]}
* />
*
* @example
* // Lilac variant with click handlers
* <LinkSmallGrid
* variant="lilac"
* heading="Get Started"
* links={[
* { label: "Quick Start", onClick: () => navigate('/start') },
* { label: "Examples", href: "/examples" }
* ]}
* />
*/
export const LinkSmallGrid: React.FC<LinkSmallGridProps> = ({
variant = 'gray',
heading,
description,
links,
className,
}) => {
// Build class names using BEM with bds namespace
const classNames = clsx(
'bds-link-small-grid',
`bds-link-small-grid--${variant}`,
className
);
// Memoize offset calculations - only recalculate when links array changes
const linkOffsets = useMemo(() => {
const total = links.length;
return links.map((_, index) => calculateTileOffset(index, total));
}, [links]);
return (
<section className={classNames}>
<PageGrid>
<SectionHeader heading={heading} description={description} span={{ base: 4, md: 6, lg: 8 }} />
<PageGridRow>
{links.map((link, index) => {
const offset = linkOffsets[index];
const hasOffset = offset.lg > 0;
// Use href or label as key, fallback to index
const key = link.href || link.label || index;
return (
<PageGridCol
key={key}
span={{ base: 4, md: 4, lg: 3 }}
offset={hasOffset ? { lg: offset.lg } : undefined}
>
<TileLink
variant={variant}
{...link}
/>
</PageGridCol>
);
})}
</PageGridRow>
</PageGrid>
</section>
);
};
export default LinkSmallGrid;

View File

@@ -0,0 +1,154 @@
# LinkSmallGrid Component
A responsive grid section pattern for displaying navigational links using TileLink components.
## Overview
LinkSmallGrid is a pattern component that combines a heading, optional description, and a grid of TileLink components. It provides a consistent layout for presenting multiple navigation options or quick links.
## Features
- **Responsive Grid Layout**: Adapts from 1 column (mobile) to 4 columns (desktop)
- **Two Color Variants**: Gray and Lilac (applied to all tiles)
- **Light/Dark Mode**: Full theming support
- **Right-Alignment**: Automatically right-aligns grids with fewer than 10 tiles at desktop
- **Flexible Content**: Supports both links and click handlers
- **Accessible**: Semantic HTML with proper heading hierarchy
## Grid Layout
Based on a 12-column grid system:
| Breakpoint | Tiles per Row | Tile Span | Total Columns |
|------------|---------------|-----------|---------------|
| Base (< 576px) | 1 | 4 of 4 | Full width |
| MD (576px - 991px) | 2 | 4 of 8 | 50% width each |
| LG (≥ 992px) | 4 | 3 of 12 | 25% width each |
## Right-Alignment Logic
When there are **fewer than 10 total tiles**, the grid is right-aligned at the **LG breakpoint only**:
- **1 tile**: offset 9 columns
- **2 tiles**: offset 6 columns
- **3 tiles**: offset 3 columns
- **4+ tiles**: no offset (fills row)
- **10+ tiles**: no offset (left-aligned grid)
**Note**: MD and Base breakpoints never apply offset (always left-aligned).
## Usage
### Basic Usage (Gray Variant)
```tsx
<LinkSmallGrid
variant="gray"
heading="Quick Links"
description="Navigate to key sections"
links={[
{ label: "Documentation", href: "/docs" },
{ label: "Tutorials", href: "/tutorials" },
{ label: "API Reference", href: "/api" },
{ label: "Examples", href: "/examples" }
]}
/>
```
### Lilac Variant with Click Handlers
```tsx
<LinkSmallGrid
variant="lilac"
heading="Get Started"
links={[
{ label: "Quick Start", onClick: () => navigate('/start') },
{ label: "Examples", href: "/examples" },
{ label: "Templates", href: "/templates" }
]}
/>
```
### Without Description
```tsx
<LinkSmallGrid
variant="gray"
heading="Resources"
links={[
{ label: "Blog", href: "/blog" },
{ label: "Community", href: "/community" }
]}
/>
```
## Props
### LinkSmallGridProps
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `variant` | `'gray' \| 'lilac'` | `'gray'` | Color variant for all tiles |
| `heading` | `string` | Required | Section heading |
| `description` | `string` | - | Optional description text |
| `links` | `LinkItem[]` | Required | Array of link items |
| `className` | `string` | - | Additional CSS classes |
### LinkItem (extends TileLinkProps)
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `label` | `string` | Required | Link text/label |
| `href` | `string` | - | Link destination |
| `onClick` | `() => void` | - | Click handler |
| `disabled` | `boolean` | `false` | Disabled state |
| `className` | `string` | - | Additional CSS classes |
**Note**: `variant` is controlled by the parent LinkSmallGrid component.
## Layout Structure
```
<section className="bds-link-small-grid">
<PageGrid>
<PageGridRow>
<PageGridCol span={{ base: 4, md: 6, lg: 8 }}>
<header>
<h2>{heading}</h2>
<p>{description}</p>
</header>
</PageGridCol>
</PageGridRow>
<PageGridRow>
{links.map(link => (
<PageGridCol span={{ base: 4, md: 4, lg: 3 }} offset={...}>
<TileLink {...link} />
</PageGridCol>
))}
</PageGridRow>
</PageGrid>
</section>
```
## Performance
- **Memoized Offset Calculations**: Uses `useMemo` to avoid recalculating offsets on every render
- **Optimized Keys**: Uses `href` or `label` as React keys instead of array index for better reconciliation
## Files
- `LinkSmallGrid.tsx` - React component
- `LinkSmallGrid.scss` - Styles with BEM naming convention
- `README.md` - This file
## Related Components
- **TileLink**: Atomic component used for each tile in the grid
- **PageGrid/PageGridRow/PageGridCol**: Grid system components
- **calculateTileOffset**: Utility function for offset calculations (in `shared/utils/helpers.ts`)
## Design System
Part of the Brand Design System (BDS) with `bds-` namespace prefix.
## Showcase
See `about/link-small-grid-showcase.page.tsx` for examples with different link counts and variants.

View File

@@ -0,0 +1,2 @@
export { LinkSmallGrid, type LinkSmallGridProps, type LinkItem } from './LinkSmallGrid';

View File

@@ -0,0 +1,43 @@
// BDS LinkTextDirectory Pattern Styles
// Brand Design System - Section with heading and list of numbered cards
//
// Naming Convention: BEM with 'bds' namespace
// .bds-link-text-directory - Base section container
// =============================================================================
// Design Tokens
// =============================================================================
// Section padding
$bds-link-text-directory-padding-base: 24px;
$bds-link-text-directory-padding-md: 32px;
$bds-link-text-directory-padding-lg: 40px;
// =============================================================================
// Section Container
// =============================================================================
.bds-link-text-directory {
padding-top: $bds-link-text-directory-padding-base;
padding-bottom: $bds-link-text-directory-padding-base;
@include media-breakpoint-up(md) {
padding-top: $bds-link-text-directory-padding-md;
padding-bottom: $bds-link-text-directory-padding-md;
}
@include media-breakpoint-up(lg) {
padding-top: $bds-link-text-directory-padding-lg;
padding-bottom: $bds-link-text-directory-padding-lg;
}
ul {
padding-left: 0;
list-style: none;
}
@include bds-theme-mode(dark) {
color: $white;
}
}
// Header section uses SectionHeader component

View File

@@ -0,0 +1,101 @@
import React from 'react';
import clsx from 'clsx';
import { LinkTextCard } from 'shared/patterns/LinkTextCard';
import { ButtonConfig } from 'shared/patterns/ButtonGroup';
import { PageGrid, PageGridRow, PageGridCol } from 'shared/components/PageGrid/page-grid';
import { SectionHeader } from 'shared/patterns/SectionHeader';
export interface LinkTextCardData {
/** Heading text for the card */
heading: string;
/** Description text for the card */
description: string;
/** Array of button configurations (max 2) */
buttons: ButtonConfig[];
}
export interface LinkTextDirectoryProps {
/** Section heading (required) */
heading: string;
/** Optional description text */
description?: string;
/** Array of card data to display */
cards: LinkTextCardData[];
/** Additional CSS classes */
className?: string;
}
/**
* LinkTextDirectory Component
*
* A section pattern that displays a numbered list of LinkTextCard components.
* Features a heading, optional description, and a vertically stacked list of cards
* with automatic sequential numbering (01, 02, 03...).
*
* Layout:
* - Header section with heading + description
* - Responsive vertical spacing between cards
* - Desktop: Right-aligned cards with 40px gaps
* - Tablet: Left-aligned cards with 32px gaps
* - Mobile: Left-aligned cards with 24px gaps
*
* @example
* // Basic usage
* <LinkTextDirectory
* heading="Explore XRPL Developer Tools"
* description="XRP Ledger is a compliance-focused blockchain where financial applications come to life"
* cards={[
* {
* heading: "Fast Settlement and Low Fees",
* description: "Settle transactions in 3-5 seconds for a fraction of a cent",
* buttons: [
* { label: "Get Started", href: "/start" },
* { label: "Learn More", href: "/docs" }
* ]
* },
* {
* heading: "Secure and Reliable",
* description: "Built on proven blockchain technology",
* buttons: [{ label: "Read Docs", href: "/docs" }]
* }
* ]}
* />
*
* @example
* // Without description
* <LinkTextDirectory
* heading="Features"
* cards={cardData}
* />
*/
export const LinkTextDirectory: React.FC<LinkTextDirectoryProps> = ({
heading,
description,
cards,
className,
}) => {
return (
<PageGrid className={clsx('bds-link-text-directory', className)}>
<SectionHeader heading={heading} description={description} span={{ base: 12, md: 6, lg: 8 }} />
{/* Cards List */}
<PageGridRow className="bds-link-text-directory__list">
<PageGridCol span={{ base: 12, md: 8, lg: 8}} offset={{ lg: 4 }}>
<ul className="list-none pl-0">
{cards.map((card, index) => (
<LinkTextCard
key={card.heading || index}
index={index}
heading={card.heading}
description={card.description}
buttons={card.buttons}
/>
))}
</ul>
</PageGridCol>
</PageGridRow >
</PageGrid>
);
};
export default LinkTextDirectory;

View File

@@ -0,0 +1,307 @@
# LinkTextDirectory Component
A section pattern that displays a numbered list of LinkTextCard components with a heading and optional description.
## Overview
LinkTextDirectory is a section-level pattern that combines a header (heading + description) with a vertically stacked list of LinkTextCard components. Each card is automatically numbered sequentially (01, 02, 03...), making it perfect for feature lists, step-by-step guides, or numbered content sections.
## Features
- **Automatic Numbering**: Cards are numbered sequentially starting from 01
- **Responsive Layout**: Adaptive spacing and alignment across breakpoints
- **Desktop Right-Alignment**: Cards are right-aligned on desktop for visual interest
- **Minimal HTML Structure**: Flat, efficient DOM hierarchy
- **Light/Dark Mode**: Full theming support
- **Flexible Content**: Supports any number of cards
## Layout Behavior
| Breakpoint | Card Alignment | Gap Between Cards | Header Gap |
|------------|----------------|-------------------|------------|
| Base (< 576px) | Left | 24px | 8px |
| MD (576px - 991px) | Left | 32px | 8px |
| LG (≥ 992px) | Right | 40px | 16px |
**Desktop Behavior**: Cards are right-aligned using `align-items: flex-end`, creating a visually distinct layout compared to mobile/tablet.
## Usage
### Basic Usage
```tsx
<LinkTextDirectory
heading="Explore XRPL Developer Tools"
description="XRP Ledger is a compliance-focused blockchain where financial applications come to life"
cards={[
{
heading: "Fast Settlement and Low Fees",
description: "Settle transactions in 3-5 seconds for a fraction of a cent, ideal for large-scale, high-volume RWA tokenization",
buttons: [
{ label: "Get Started", href: "/start" },
{ label: "Learn More", href: "/docs" }
]
},
{
heading: "Secure and Reliable",
description: "Built on proven blockchain technology with enterprise-grade security",
buttons: [
{ label: "Read Documentation", href: "/docs" }
]
},
{
heading: "Developer Friendly",
description: "Comprehensive APIs and SDKs for seamless integration",
buttons: [
{ label: "View API", href: "/api" },
{ label: "See Examples", href: "/examples" }
]
}
]}
/>
```
### Without Description
```tsx
<LinkTextDirectory
heading="Key Features"
cards={featuresList}
/>
```
### With Dynamic Data
```tsx
const features = [
{
heading: "Feature One",
description: "Description for feature one",
buttons: [{ label: "Learn More", href: "/feature-1" }]
},
// ... more features
];
<LinkTextDirectory
heading="Platform Features"
description="Everything you need to build amazing applications"
cards={features}
/>
```
## Props
### LinkTextDirectoryProps
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `heading` | `string` | Required | Section heading |
| `description` | `string` | - | Optional description text |
| `cards` | `LinkTextCardData[]` | Required | Array of card data |
| `className` | `string` | - | Additional CSS classes |
### LinkTextCardData
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `heading` | `string` | Required | Card heading |
| `description` | `string` | Required | Card description |
| `buttons` | `ButtonConfig[]` | Required | Array of button configs (max 2) |
### ButtonConfig (from ButtonGroup)
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `label` | `string` | Required | Button text |
| `href` | `string` | - | Link destination |
| `onClick` | `() => void` | - | Click handler |
## Component Structure
```tsx
<PageGrid className="bds-link-text-directory">
<PageGridRow>
<PageGridCol className="bds-link-text-directory__header" span={{ base: 12, md: 6, lg: 8 }}>
<h2 className="h-md">{heading}</h2>
<p className="body-l">{description}</p>
</PageGridCol>
</PageGridRow>
<PageGridRow>
<PageGridCol span={{ base: 12, md: 8, lg: 8 }} offset={{ lg: 4 }}>
<ul>
{cards.map((card, index) => (
<LinkTextCard
key={index}
index={index}
heading={card.heading}
description={card.description}
buttons={card.buttons}
/>
))}
</ul>
</PageGridCol>
</PageGridRow>
</PageGrid>
```
**Key Design Decisions:**
- **PageGrid Integration**: Uses PageGrid system for responsive layout
- **Typography Classes**: Uses existing `h-md` and `body-l` utility classes
- **Flexbox Header**: Header uses flexbox with gap for spacing between heading and description
- **Desktop Right-Alignment**: Cards offset by 4 columns at LG breakpoint (right-aligned)
- **Semantic List**: Cards wrapped in `<ul>` with each card as `<li>`
## Responsive Spacing
| Breakpoint | Section Padding | Header Gap | Header Margin-Bottom |
|------------|-----------------|------------|----------------------|
| Base (< 576px) | 24px | 8px | 24px |
| MD (576px - 991px) | 32px | 8px | 32px |
| LG (≥ 992px) | 40px | 16px | 40px |
**Section Padding**: Top and bottom padding on the entire section
**Header Gap**: Space between heading and description (via flexbox gap)
**Header Margin-Bottom**: Space between header and cards list
## Styling
### CSS Classes
- `.bds-link-text-directory` - Main PageGrid container with section padding
- `.bds-link-text-directory__header` - Header section (flexbox column with gap)
### Typography
- **Heading**: `h-md` class (responsive heading)
- **Description**: `body-l` class (large body text)
- Card content uses LinkTextCard's built-in typography
### Grid Layout
- **Header Column**: `span={{ base: 12, md: 6, lg: 8 }}`
- **Cards Column**: `span={{ base: 12, md: 8, lg: 8 }}` with `offset={{ lg: 4 }}`
- Cards are right-aligned on desktop via the 4-column offset
### Dark Mode
- Text color changes to white in dark mode
- Applied to entire section via `bds-theme-mode(dark)` mixin
## Card Numbering
Cards are automatically numbered based on their array index:
```typescript
cards[0] LinkTextCard(index: 0) displays "01"
cards[1] LinkTextCard(index: 1) displays "02"
cards[2] LinkTextCard(index: 2) displays "03"
// ... and so on
```
## Files
- `LinkTextDirectory.tsx` - React component
- `LinkTextDirectory.scss` - SCSS styles
- `index.ts` - Barrel exports
- `README.md` - This file
## Related Components
- **LinkTextCard**: Used for each card in the list
- **ButtonGroup**: Used by LinkTextCard for action buttons
## Import
```tsx
import { LinkTextDirectory } from 'shared/sections/LinkTextDirectory';
// or
import {
LinkTextDirectory,
type LinkTextDirectoryProps,
type LinkTextCardData
} from 'shared/sections/LinkTextDirectory';
```
## Design System
Part of the Brand Design System (BDS) with `bds-` namespace prefix.
## Best Practices
1. **Consistent Card Content**: Try to keep similar text lengths across cards for visual balance
2. **Limit Cards**: 3-6 cards works best for readability
3. **Clear Descriptions**: Keep descriptions concise but informative
4. **Button Labels**: Use clear, action-oriented button labels
5. **Logical Ordering**: Order cards by priority or logical sequence
## Accessibility
- Semantic HTML with proper heading hierarchy (`<h2>` for section, `<h5>` for cards)
- Semantic list structure: `<ul>` containing `<li>` elements
- Sequential tab order through cards and buttons
- ARIA-compliant button and link elements (via ButtonGroup)
- Maintains focus order: heading description card 1 card 2 etc.
## Best Practices for React Keys
When mapping over cards, use a stable identifier instead of array index:
```tsx
// ❌ Avoid using index as key
{cards.map((card, index) => (
<LinkTextCard key={index} ... />
))}
// ✅ Better: Use a unique identifier
{cards.map((card, index) => (
<LinkTextCard key={card.id || card.heading} ... />
))}
// ✅ Best: Add an id field to LinkTextCardData
interface LinkTextCardData {
id: string; // Unique identifier
heading: string;
description: string;
buttons: ButtonConfig[];
}
{cards.map((card) => (
<LinkTextCard key={card.id} ... />
))}
```
## Example with All Features
```tsx
<LinkTextDirectory
heading="Why Choose XRPL"
description="The most efficient blockchain for real-world applications"
cards={[
{
heading: "Lightning Fast",
description: "Process thousands of transactions per second with sub-3-second finality",
buttons: [
{ label: "View Benchmarks", href: "/performance" },
{ label: "Try Demo", onClick: () => openDemo() }
]
},
{
heading: "Cost Effective",
description: "Minimal transaction fees make XRPL perfect for micro-transactions and high-volume use cases",
buttons: [
{ label: "See Pricing", href: "/pricing" }
]
},
{
heading: "Battle Tested",
description: "Over 10 years of continuous operation with billions of transactions processed",
buttons: [
{ label: "Read Case Studies", href: "/case-studies" },
{ label: "View Stats", href: "/statistics" }
]
}
]}
className="my-custom-class"
/>
```

Some files were not shown because too many files have changed in this diff Show More