Compare commits

..

6 Commits

Author SHA1 Message Date
Calvin Jhunjhuwala
df1ab88ef7 cleaning up duplicate, cleaning up button group, useMemo for rectangle grid 2026-01-21 18:41:19 -08:00
Calvin Jhunjhuwala
8f931a2a4c working ofset for large and medium 2026-01-21 17:06:57 -08:00
Calvin Jhunjhuwala
be46c362cf merging master 2026-01-21 16:02:20 -08:00
Calvin Jhunjhuwala
b9410305ef merging master, fixing merge conflicts 2026-01-21 15:19:30 -08:00
Calvin Jhunjhuwala
9d4ed9a477 updates to logo rectangle, need to work on the offsetting 2026-01-21 11:46:18 -08:00
Calvin Jhunjhuwala
4da20f1ac1 correct layout, working on right align next 2026-01-20 17:50:08 -08:00
17 changed files with 1332 additions and 1677 deletions

View File

@@ -1,769 +0,0 @@
import * as React from "react";
import {
PageGrid,
PageGridRow,
PageGridCol,
} from "shared/components/PageGrid/page-grid";
import HeaderHeroPrimaryMedia from "shared/patterns/HeaderHeroPrimaryMedia/HeaderHeroPrimaryMedia";
export const frontmatter = {
seo: {
title: "HeaderHeroPrimaryMedia Pattern Showcase",
description:
"Interactive showcase of the HeaderHeroPrimaryMedia pattern with all variants, media types, and responsive behavior.",
},
};
// Demo component for code examples
const CodeDemo = ({
title,
description,
code,
children,
}: {
title: string;
description?: string;
code?: string;
children: React.ReactNode;
}) => (
<div className="mb-26">
<h3 className="h4 mb-4">{title}</h3>
{description && <p className="mb-6">{description}</p>}
{code && (
<div
className="mb-6 p-4 bg-light br-4 text-black"
style={{
fontFamily: "monospace",
fontSize: "14px",
overflow: "auto",
}}
>
<pre style={{ margin: 0, whiteSpace: "pre-wrap", color: "#000" }}>
{code}
</pre>
</div>
)}
<div
style={{
border: "1px dashed #ccc",
padding: "16px",
backgroundColor: "#f9f9f9",
borderRadius: "8px",
}}
>
{children}
</div>
</div>
);
// Sample placeholder images
const placeholderImage =
"https://cdn.sanity.io/images/ior4a5y3/production/6e150606bc0a051a83b90aa830cc32854cc3f7df-2928x1920.jpg";
const placeholderVideo =
"https://cdn.sanity.io/files/ior4a5y3/production/6e2fcba46e3f045a5570c86fd5d20d5ba93d6aad.mp4";
// Sample custom animation component
const SampleAnimation = () => (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#0069ff",
color: "white",
fontSize: "24px",
fontWeight: "bold",
}}
>
Custom Animation Element
</div>
);
export default function HeaderHeroPrimaryMediaShowcase() {
return (
<div className="landing">
<PageGrid className="py-26">
<PageGridRow>
<PageGridCol>
<div className="text-center mb-26">
<h6 className="eyebrow mb-3">Pattern Showcase</h6>
<h1 className="h2 mb-4">HeaderHeroPrimaryMedia Pattern</h1>
<p className="longform">
A page-level hero pattern featuring a headline, subtitle,
call-to-action buttons, and a primary media element. The media
supports images, videos, or custom React elements, all
constrained to maintain a 9:16 aspect ratio with object-fit:
cover.
</p>
</div>
</PageGridCol>
</PageGridRow>
{/* Basic Usage */}
<PageGridRow>
<PageGridCol>
<CodeDemo
title="Basic Usage with Image Media"
description="The simplest implementation with an image, headline, subtitle, and primary CTA."
code={`<HeaderHeroPrimaryMedia
headline="Build on XRPL"
subtitle="Start developing today with our comprehensive developer tools and APIs."
callsToAction={[
{ children: "Get Started", href: "/docs" }
]}
media={{
type: "image",
src: "/img/hero.png",
alt: "XRPL Development"
}}
/>`}
>
<HeaderHeroPrimaryMedia
headline="Build on XRPL"
subtitle="Start developing today with our comprehensive developer tools and APIs."
callsToAction={[{ children: "Get Started", href: "/docs" }]}
media={{
type: "image",
src: placeholderImage,
alt: "XRPL Development",
}}
/>
</CodeDemo>
</PageGridCol>
</PageGridRow>
{/* Primary + Secondary CTA */}
<PageGridRow>
<PageGridCol>
<CodeDemo
title="Primary and Secondary CTAs"
description="Include both primary and secondary call-to-action buttons."
code={`<HeaderHeroPrimaryMedia
headline="Real-world asset tokenization"
subtitle="Learn how to issue crypto tokens and build tokenization solutions."
callsToAction={[
{ children: "Get Started", href: "/docs" },
{ children: "Learn More", href: "/about" }
]}
media={{
type: "image",
src: "/img/tokenization.png",
alt: "Tokenization"
}}
/>`}
>
<HeaderHeroPrimaryMedia
headline="Real-world asset tokenization"
subtitle="Learn how to issue crypto tokens and build tokenization solutions."
callsToAction={[
{ children: "Get Started", href: "/docs" },
{ children: "Learn More", href: "/about" },
]}
media={{
type: "image",
src: placeholderImage,
alt: "Tokenization",
}}
/>
</CodeDemo>
</PageGridCol>
</PageGridRow>
{/* Video Media */}
<PageGridRow>
<PageGridCol>
<CodeDemo
title="Video Media"
description="Use video elements with native video props support. The video will maintain the 9:16 aspect ratio and object-fit: cover."
code={`<HeaderHeroPrimaryMedia
headline="Watch and Learn"
subtitle="Explore our video tutorials and guides."
callsToAction={[
{ children: "Watch Tutorials", href: "/tutorials" }
]}
media={{
type: "video",
src: "/video/intro.mp4",
alt: "Introduction video",
autoPlay: true,
loop: true,
muted: true,
playsInline: true
}}
/>`}
>
<HeaderHeroPrimaryMedia
headline="Watch and Learn"
subtitle="Explore our video tutorials and guides."
callsToAction={[
{ children: "Watch Tutorials", href: "/tutorials" },
]}
media={{
type: "video",
src: placeholderVideo,
alt: "Introduction video",
autoPlay: true,
loop: true,
muted: true,
playsInline: true,
}}
/>
</CodeDemo>
</PageGridCol>
</PageGridRow>
{/* Custom Element Media */}
<PageGridRow>
<PageGridCol>
<CodeDemo
title="Custom Element Media"
description="Use custom React elements for animations, interactive components, or any custom media type."
code={`<HeaderHeroPrimaryMedia
headline="Interactive Experience"
subtitle="Engage with our custom interactive media."
callsToAction={[
{ children: "Explore", href: "/interactive" }
]}
media={{
type: "custom",
element: <MyAnimationComponent />
}}
/>`}
>
<HeaderHeroPrimaryMedia
headline="Interactive Experience"
subtitle="Engage with our custom interactive media."
callsToAction={[{ children: "Explore", href: "/interactive" }]}
media={{
type: "custom",
element: <SampleAnimation />,
}}
/>
</CodeDemo>
</PageGridCol>
</PageGridRow>
{/* Extended Image Props */}
<PageGridRow>
<PageGridCol>
<CodeDemo
title="Extended Image Props"
description="Leverage native img element props like loading, crossOrigin, etc. className and style are omitted from img props and only available on the container."
code={`<HeaderHeroPrimaryMedia
headline="Optimized Images"
subtitle="Use native image attributes for performance and security."
callsToAction={[
{ children: "View Gallery", href: "/gallery" }
]}
media={{
type: "image",
src: "/img/gallery.jpg",
alt: "Image gallery",
loading: "lazy",
crossOrigin: "anonymous",
decoding: "async"
}}
/>`}
>
<HeaderHeroPrimaryMedia
headline="Optimized Images"
subtitle="Use native image attributes for performance and security."
callsToAction={[{ children: "View Gallery", href: "/gallery" }]}
media={{
type: "image",
src: placeholderImage,
alt: "Image gallery",
loading: "lazy",
decoding: "async",
}}
/>
</CodeDemo>
</PageGridCol>
</PageGridRow>
{/* Extended Video Props */}
<PageGridRow>
<PageGridCol>
<CodeDemo
title="Extended Video Props"
description="Use native video element props for controls, preload, poster, etc."
code={`<HeaderHeroPrimaryMedia
headline="Video Content"
subtitle="Rich video experiences with full control."
callsToAction={[
{ children: "Watch Now", href: "/videos" }
]}
media={{
type: "video",
src: "/video/demo.mp4",
alt: "Demo video",
controls: true,
preload: "metadata",
poster: "/img/video-poster.jpg"
}}
/>`}
>
<HeaderHeroPrimaryMedia
headline="Video Content"
subtitle="Rich video experiences with full control."
callsToAction={[{ children: "Watch Now", href: "/videos" }]}
media={{
type: "video",
src: placeholderVideo,
alt: "Demo video",
controls: true,
preload: "metadata",
}}
/>
</CodeDemo>
</PageGridCol>
</PageGridRow>
{/* Missing Optional Fields - Validation Examples */}
<PageGridRow>
<PageGridCol>
<div className="mb-26">
<h2 className="h3 mb-6">Validation Examples</h2>
<p className="mb-6">
The component includes development-time validation that logs
warnings to the console when required props are missing. The
component will still render, but you'll see warnings in the
browser console.
</p>
</div>
</PageGridCol>
</PageGridRow>
{/* Missing Subtitle */}
<PageGridRow>
<PageGridCol>
<CodeDemo
title="Missing Subtitle (Warning Example)"
description="When subtitle is missing, a warning will be logged to the console. The component still renders but the subtitle area will be empty."
code={`<HeaderHeroPrimaryMedia
headline="Build on XRPL"
subtitle={undefined}
callsToAction={[
{ children: "Get Started", href: "/docs" }
]}
media={{
type: "image",
src: "/img/hero.png",
alt: "XRPL Development"
}}
/>`}
>
<HeaderHeroPrimaryMedia
headline="Build on XRPL"
subtitle={undefined as any}
callsToAction={[{ children: "Get Started", href: "/docs" }]}
media={{
type: "image",
src: placeholderImage,
alt: "XRPL Development",
}}
/>
</CodeDemo>
</PageGridCol>
</PageGridRow>
{/* Missing Secondary CTA */}
<PageGridRow>
<PageGridCol>
<CodeDemo
title="Primary CTA Only (No Secondary)"
description="The secondary CTA is optional. When omitted, only the primary CTA button is displayed. This is the recommended pattern when you want a single, focused call-to-action."
code={`<HeaderHeroPrimaryMedia
headline="Single Call to Action"
subtitle="Focus on one primary action for better conversion."
callsToAction={[
{ children: "Get Started", href: "/docs" }
// No secondary CTA - this is valid
]}
media={{
type: "image",
src: "/img/hero.png",
alt: "Single CTA example"
}}
/>`}
>
<HeaderHeroPrimaryMedia
headline="Single Call to Action"
subtitle="Focus on one primary action for better conversion."
callsToAction={[{ children: "Get Started", href: "/docs" }]}
media={{
type: "image",
src: placeholderImage,
alt: "Single CTA example",
}}
/>
</CodeDemo>
</PageGridCol>
</PageGridRow>
{/* Missing Media */}
<PageGridRow>
<PageGridCol>
<CodeDemo
title="Missing Media (Warning Example)"
description="When media is missing, a warning will be logged to the console. The component still renders but the media section will not be displayed."
code={`<HeaderHeroPrimaryMedia
headline="Content Without Media"
subtitle="Sometimes you may want to focus purely on the content without media."
callsToAction={[
{ children: "Learn More", href: "/about" }
]}
media={undefined}
/>`}
>
<HeaderHeroPrimaryMedia
headline="Content Without Media"
subtitle="Sometimes you may want to focus purely on the content without media."
callsToAction={[{ children: "Learn More", href: "/about" }]}
media={undefined as any}
/>
</CodeDemo>
</PageGridCol>
</PageGridRow>
{/* Design Constraints */}
<PageGridRow>
<PageGridCol>
<div className="mb-26">
<h2 className="h3 mb-6">Design Constraints</h2>
<p className="mb-6">
The HeaderHeroPrimaryMedia pattern enforces specific design
requirements to maintain visual consistency across all
implementations:
</p>
<ul className="mb-6">
<li>
<strong>Aspect Ratio:</strong> All media maintains a 9:16
aspect ratio (portrait orientation)
</li>
<li>
<strong>Object Fit:</strong> Media uses{" "}
<code>object-fit: cover</code> to fill the container while
maintaining aspect ratio
</li>
<li>
<strong>Responsive Behavior:</strong> The media container
adapts responsively while maintaining the aspect ratio
constraint
</li>
<li>
<strong>Type Safety:</strong> TypeScript ensures proper media
type discrimination and prop validation
</li>
</ul>
<div
className="p-4 bg-light br-4"
style={{ fontFamily: "monospace", fontSize: "14px" }}
>
<pre style={{ margin: 0, color: "#000" }}>
{`.bds-header-hero-primary-media__media-container {
width: 100%;
aspect-ratio: 9 / 16; /* Design requirement */
overflow: hidden;
}
.bds-header-hero-primary-media__media-element {
width: 100%;
height: 100%;
object-fit: cover; /* Ensures media covers container */
object-position: center;
}`}
</pre>
</div>
</div>
</PageGridCol>
</PageGridRow>
{/* Props Documentation */}
<PageGridRow>
<PageGridCol>
<div className="mb-26">
<h2 className="h3 mb-6">Props Documentation</h2>
<h4 className="h5 mb-4">HeaderHeroPrimaryMediaProps</h4>
<div className="mb-6">
<ul>
<li>
<code>headline</code> (required) -{" "}
<code>React.ReactNode</code> - Hero headline text
</li>
<li>
<code>subtitle</code> (required) -{" "}
<code>React.ReactNode</code> - Hero subtitle text
</li>
<li>
<code>callsToAction</code> (required) -{" "}
<code>[ButtonProps, ButtonProps?]</code> - Array with
primary CTA (required) and optional secondary CTA
</li>
<li>
<code>media</code> (required) - <code>HeaderHeroMedia</code>{" "}
- Media element (image, video, or custom)
</li>
<li>
<code>className</code> (optional) - <code>string</code> -
Additional CSS classes for the header element
</li>
<li>
All standard HTML <code>&lt;header&gt;</code> attributes are
supported
</li>
</ul>
</div>
<h4 className="h5 mb-4">HeaderHeroMedia Types</h4>
<div className="mb-6">
<h5 className="h6 mb-3">ImageMediaProps</h5>
<ul className="mb-4">
<li>
<code>type: "image"</code> (required)
</li>
<li>
<code>src: string</code> (required) - Image source URL
</li>
<li>
<code>alt: string</code> (required) - Alt text for
accessibility
</li>
<li>
All native <code>&lt;img&gt;</code> props except{" "}
<code>className</code> and <code>style</code>
</li>
</ul>
<h5 className="h6 mb-3">VideoMediaProps</h5>
<ul className="mb-4">
<li>
<code>type: "video"</code> (required)
</li>
<li>
<code>src: string</code> (required) - Video source URL
</li>
<li>
<code>alt?: string</code> (optional) - Alt text for
accessibility
</li>
<li>
All native <code>&lt;video&gt;</code> props except{" "}
<code>className</code> and <code>style</code>
</li>
</ul>
<h5 className="h6 mb-3">CustomMediaProps</h5>
<ul>
<li>
<code>type: "custom"</code> (required)
</li>
<li>
<code>element: React.ReactElement</code> (required) - Custom
React element to render
</li>
</ul>
</div>
<h4 className="h5 mb-4">Button Props (callsToAction)</h4>
<p className="mb-4">
The <code>callsToAction</code> prop accepts Button component
props, but <code>variant</code> and <code>color</code> are
automatically set:
</p>
<ul>
<li>
Primary CTA: <code>variant="primary"</code>,{" "}
<code>color="green"</code>
</li>
<li>
Secondary CTA: <code>variant="tertiary"</code>,{" "}
<code>color="green"</code>
</li>
<li>
All other Button props are supported (e.g.,{" "}
<code>children</code>, <code>href</code>, <code>onClick</code>
, etc.)
</li>
</ul>
</div>
</PageGridCol>
</PageGridRow>
{/* Code Examples */}
<PageGridRow>
<PageGridCol>
<div className="mb-26">
<h2 className="h3 mb-6">Code Examples</h2>
<h4 className="h5 mb-4">Import</h4>
<div
className="p-4 bg-light br-4 mb-6"
style={{ fontFamily: "monospace", fontSize: "14px" }}
>
<pre style={{ margin: 0, color: "#000" }}>
{`import HeaderHeroPrimaryMedia from "shared/patterns/HeaderHeroPrimaryMedia/HeaderHeroPrimaryMedia";`}
</pre>
</div>
<h4 className="h5 mb-4">Basic Example</h4>
<div
className="p-4 bg-light br-4 mb-6"
style={{
fontFamily: "monospace",
fontSize: "14px",
overflow: "auto",
backgroundColor: "#1e1e1e",
color: "#d4d4d4",
}}
>
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>
{`<HeaderHeroPrimaryMedia
headline="Build on XRPL"
subtitle="Start developing today with our comprehensive developer tools."
callsToAction={[
{ children: "Get Started", href: "/docs" }
]}
media={{
type: "image",
src: "/img/hero.png",
alt: "XRPL Development"
}}
/>`}
</pre>
</div>
<h4 className="h5 mb-4">With Secondary CTA</h4>
<div
className="p-4 bg-light br-4 mb-6"
style={{
fontFamily: "monospace",
fontSize: "14px",
overflow: "auto",
backgroundColor: "#1e1e1e",
color: "#d4d4d4",
}}
>
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>
{`<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" }
]}
media={{
type: "image",
src: "/img/tokenization.png",
alt: "Tokenization"
}}
/>`}
</pre>
</div>
<h4 className="h5 mb-4">Video Media</h4>
<div
className="p-4 bg-light br-4 mb-6"
style={{
fontFamily: "monospace",
fontSize: "14px",
overflow: "auto",
backgroundColor: "#1e1e1e",
color: "#d4d4d4",
}}
>
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>
{`<HeaderHeroPrimaryMedia
headline="Watch and Learn"
subtitle="Explore our video tutorials."
callsToAction={[
{ children: "Watch Tutorials", href: "/tutorials" }
]}
media={{
type: "video",
src: "/video/intro.mp4",
alt: "Introduction video",
autoPlay: true,
loop: true,
muted: true
}}
/>`}
</pre>
</div>
<h4 className="h5 mb-4">Custom Element</h4>
<div
className="p-4 bg-light br-4 mb-6"
style={{
fontFamily: "monospace",
fontSize: "14px",
overflow: "auto",
backgroundColor: "#1e1e1e",
color: "#d4d4d4",
}}
>
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>
{`<HeaderHeroPrimaryMedia
headline="Interactive Experience"
subtitle="Engage with custom media."
callsToAction={[
{ children: "Explore", href: "/interactive" }
]}
media={{
type: "custom",
element: <MyAnimationComponent />
}}
/>`}
</pre>
</div>
</div>
</PageGridCol>
</PageGridRow>
{/* Best Practices */}
<PageGridRow>
<PageGridCol>
<div className="mb-26">
<h2 className="h3 mb-6">Best Practices</h2>
<ul>
<li>
<strong>Media Selection:</strong> Choose media that works well
in a 9:16 portrait aspect ratio. Images and videos will be
cropped to fit.
</li>
<li>
<strong>Alt Text:</strong> Always provide meaningful alt text
for images. For videos, use the <code>alt</code> prop for
accessibility.
</li>
<li>
<strong>Performance:</strong> Use <code>loading="lazy"</code>{" "}
for images below the fold, and optimize video file sizes.
</li>
<li>
<strong>CTAs:</strong> Keep CTA text concise and
action-oriented. Primary CTA should be the main action.
</li>
<li>
<strong>Headlines:</strong> Keep headlines concise and
impactful. Use the subtitle for additional context.
</li>
<li>
<strong>Type Safety:</strong> Leverage TypeScript's
discriminated unions for type-safe media selection.
</li>
<li>
<strong>Responsive Design:</strong> Test your implementation
across all breakpoints to ensure media displays correctly.
</li>
</ul>
</div>
</PageGridCol>
</PageGridRow>
</PageGrid>
</div>
);
}

File diff suppressed because one or more lines are too long

View File

@@ -3,9 +3,14 @@ import clsx from 'clsx';
import { Button } from '../../components/Button/Button';
export interface ButtonConfig {
/** Button text label */
label: string;
/** URL to navigate to - renders button as a link */
href?: string;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
/** Force the color to remain constant regardless of theme mode */
forceColor?: boolean;
/** Click handler - matches Button component's onClick signature */
onClick?: () => void;
}
export interface ButtonGroupProps {
@@ -15,7 +20,9 @@ export interface ButtonGroupProps {
tertiaryButton?: ButtonConfig;
/** Button color theme */
color?: 'green' | 'black';
/** Gap between buttons on tablet+ (0px or 8px) */
/** Whether to force the color to remain constant regardless of theme mode */
forceColor?: boolean;
/** Gap between buttons on tablet+ (0px or 4px) */
gap?: 'none' | 'small';
/** Additional CSS classes */
className?: string;
@@ -47,6 +54,7 @@ export const ButtonGroup: React.FC<ButtonGroupProps> = ({
primaryButton,
tertiaryButton,
color = 'green',
forceColor = false,
gap = 'small',
className = '',
}) => {
@@ -67,8 +75,9 @@ export const ButtonGroup: React.FC<ButtonGroupProps> = ({
<Button
variant="primary"
color={color}
forceColor={forceColor}
href={primaryButton.href}
onClick={primaryButton?.onClick as (() => void) | undefined}
onClick={primaryButton.onClick}
>
{primaryButton.label}
</Button>
@@ -77,8 +86,9 @@ export const ButtonGroup: React.FC<ButtonGroupProps> = ({
<Button
variant="tertiary"
color={color}
forceColor={forceColor}
href={tertiaryButton.href}
onClick={tertiaryButton?.onClick as (() => void) | undefined}
onClick={tertiaryButton.onClick}
>
{tertiaryButton.label}
</Button>

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 } from '../../components/ButtonGroup/ButtonGroup';
export interface CalloutMediaBannerProps {
/** Color variant - determines background color (ignored if backgroundImage is provided) */
@@ -18,13 +18,13 @@ export interface CalloutMediaBannerProps {
primaryButton?: {
label: string;
href?: string;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onClick?: () => void;
};
/** Tertiary button configuration */
tertiaryButton?: {
label: string;
href?: string;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onClick?: () => void;
};
/** Additional CSS classes */
className?: string;

View File

@@ -1,224 +0,0 @@
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";
const isEmpty = (val: unknown): boolean => {
if (val === null || val === undefined) return true;
if (typeof val === "string") return val.trim().length === 0;
if (Array.isArray(val)) return val.length === 0;
return !Boolean(val);
};
type DesignContrainedButtonProps = Omit<ButtonProps, "variant" | "color">;
/**
* 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;
};
/**
* 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
};
/**
* 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
};
/**
* Custom element media type - allows passing any React element
* The element will be wrapped in a container with the required aspect ratio
*/
type CustomMediaProps = {
type: "custom";
element: React.ReactElement;
};
/**
* Discriminated union of all supported media types.
* Each type allows extending native React element props while ensuring
* the media container maintains the 9:16 aspect ratio and object-fit: cover.
*/
export type HeaderHeroMedia =
| ImageMediaProps
| VideoMediaProps
| CustomMediaProps;
export interface HeaderHeroPrimaryMediaProps
extends React.ComponentPropsWithoutRef<"header"> {
/** Hero title text (display-md typography) */
headline: React.ReactNode;
/** Hero subtitle text (subhead-sm-l typography) */
subtitle: React.ReactNode;
callsToAction: [DesignContrainedButtonProps, DesignContrainedButtonProps?];
/** Media element - supports image, video, or custom React element */
media: HeaderHeroMedia;
}
/**
* Renders the appropriate media element based on the media type.
* All media is wrapped in a container with 9:16 aspect ratio and object-fit: cover.
*/
const MediaRenderer: React.FC<{ media: HeaderHeroMedia }> = memo(
({ media }) => {
const mediaContainerClassName =
"bds-header-hero-primary-media__media-container";
const mediaElementClassName =
"bds-header-hero-primary-media__media-element";
switch (media.type) {
case "image":
{
const { type, ...imgProps } = media;
return (
<div className={mediaContainerClassName}>
<img {...imgProps} className={mediaElementClassName} />
</div>
);
}
case "video": {
const { type, alt, ...videoProps } = media;
return (
<div className={mediaContainerClassName}>
<video
{...videoProps}
className={mediaElementClassName}
aria-label={alt}
/>
</div>
);
}
case "custom": {
const { element } = media;
return (
<div className={mediaContainerClassName}>
<div className={mediaElementClassName}>{element}</div>
</div>
);
}
default: {
return null;
}
}
}
);
const HeaderHeroPrimaryMedia = forwardRef<
HTMLElement,
HeaderHeroPrimaryMediaProps
>((props, ref) => {
const { headline, subtitle, callsToAction, media, className, ...restProps } =
props;
const [primaryCta, secondaryCta] = callsToAction;
// Headline is critical - exit early if missing
if (!headline) {
console.error("Headline is required for HeaderHeroPrimaryMedia");
return null;
}
// Validate other props and log warnings for missing optional/required fields
// Note: These props log warnings but don't prevent rendering
useEffect(() => {
const propsToValidate = {
subtitle,
callsToAction,
media,
};
Object.entries(propsToValidate).forEach(([key, value]) => {
if (isEmpty(value)) {
console.warn(`${key} is required for HeaderHeroPrimaryMedia`);
}
});
}, [subtitle, callsToAction, media]);
return (
<header
className={clsx("bds-header-hero-primary-media", className)}
ref={ref}
{...restProps}
>
<PageGrid>
<PageGrid.Row>
<PageGrid.Col
span={{ base: 12, md: 6, lg: 5 }}
className="bds-header-hero-primary-media__headline-container"
>
<h1 className="bds-header-hero-primary-media__headline display-md">
<span>{headline}</span>
</h1>
</PageGrid.Col>
<PageGrid.Col offset={{ base: 0, lg: 1 }} span={{ base: 12, lg: 5 }}>
<div className="bds-header-hero-primary-media__cta-container">
{!isEmpty(subtitle) && (
<div className="bds-header-hero-primary-media__subtitle body-l">
{subtitle}
</div>
)}
{(!isEmpty(primaryCta) || !isEmpty(secondaryCta)) && (
<div className="bds-header-hero-primary-media__cta-buttons">
<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}
/>
)}
</div>
)}
</div>
</PageGrid.Col>
</PageGrid.Row>
{/* Media */}
{!isEmpty(media) && (
<PageGrid.Row>
<PageGrid.Col span={12}>
<MediaRenderer media={media} />
</PageGrid.Col>
</PageGrid.Row>
)}
</PageGrid>
</header>
);
});
export default HeaderHeroPrimaryMedia;

View File

@@ -1,213 +0,0 @@
# HeaderHeroPrimaryMedia Pattern
A page-level hero pattern featuring a headline, subtitle, call-to-action buttons, and a primary media element. Supports images, videos, or custom React elements with enforced aspect ratios and object-fit constraints.
## Overview
The HeaderHeroPrimaryMedia component provides a structured hero section with:
- Responsive headline and subtitle layout
- Primary and optional secondary call-to-action buttons
- Media element (image, video, or custom) with responsive aspect ratios
- Development-time validation warnings
## Basic Usage
```tsx
import HeaderHeroPrimaryMedia from "shared/patterns/HeaderHeroPrimaryMedia/HeaderHeroPrimaryMedia";
function MyPage() {
return (
<HeaderHeroPrimaryMedia
headline="Build on XRPL"
subtitle="Start developing today with our comprehensive developer tools."
callsToAction={[{ children: "Get Started", href: "/docs" }]}
media={{
type: "image",
src: "/img/hero.png",
alt: "XRPL Development",
}}
/>
);
}
```
## Props
| Prop | Type | Required | Description |
| --------------- | ------------------------------ | -------- | ------------------------------------------------------------ |
| `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 |
| `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
The `callsToAction` prop accepts Button component props, but `variant` and `color` are automatically set:
- **Primary CTA**: `variant="primary"`, `color="green"`
- **Secondary CTA**: `variant="tertiary"`, `color="green"`
All other Button props are supported (e.g., `children`, `href`, `onClick`, etc.).
## Media Types
The `media` prop accepts a discriminated union of three types:
### Image Media
```tsx
media={{
type: "image",
src: string, // Required
alt: string, // Required
// ... all native <img> props except className and style
}}
```
**Example:**
```tsx
media={{
type: "image",
src: "/img/hero.png",
alt: "Hero image",
loading: "lazy",
decoding: "async"
}}
```
### Video Media
```tsx
media={{
type: "video",
src: string, // Required
alt?: string, // Optional but recommended
// ... all native <video> props except className and style
}}
```
**Example:**
```tsx
media={{
type: "video",
src: "/video/intro.mp4",
alt: "Introduction video",
autoPlay: true,
loop: true,
muted: true,
playsInline: true
}}
```
### Custom Element Media
```tsx
media={{
type: "custom",
element: React.ReactElement // Required
}}
```
**Example:**
```tsx
media={{
type: "custom",
element: <MyAnimationComponent />
}}
```
## Examples
### With Secondary CTA
```tsx
<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" },
]}
media={{
type: "image",
src: "/img/tokenization.png",
alt: "Tokenization",
}}
/>
```
### Video Media
```tsx
<HeaderHeroPrimaryMedia
headline="Watch and Learn"
subtitle="Explore our video tutorials."
callsToAction={[{ children: "Watch Tutorials", href: "/tutorials" }]}
media={{
type: "video",
src: "/video/intro.mp4",
alt: "Introduction video",
autoPlay: true,
loop: true,
muted: true,
}}
/>
```
### Custom Element
```tsx
<HeaderHeroPrimaryMedia
headline="Interactive Experience"
subtitle="Engage with custom media."
callsToAction={[{ children: "Explore", href: "/interactive" }]}
media={{
type: "custom",
element: <MyAnimationComponent />,
}}
/>
```
## Design Constraints
The component enforces specific design requirements:
- **Aspect Ratios**: Media maintains responsive aspect ratios:
- Base: `16:9`
- Medium (md+): `2:1`
- Large (lg+): `3:1`
- **Object Fit**: All media uses `object-fit: cover` to fill the container
- **Type Safety**: TypeScript discriminated unions ensure type-safe media selection
## Validation
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
## CSS Classes
The component generates the following CSS classes:
- `bds-header-hero-primary-media` - Root header element
- `bds-header-hero-primary-media__headline` - Headline container
- `bds-header-hero-primary-media__subtitle` - Subtitle element
- `bds-header-hero-primary-media__cta-container` - CTA container
- `bds-header-hero-primary-media__cta-buttons` - CTA buttons wrapper
- `bds-header-hero-primary-media__media-container` - Media container
- `bds-header-hero-primary-media__media-element` - Media element
## Best Practices
1. **Media Selection**: Choose media that works well with the responsive aspect ratios (16:9 base, 2:1 md+, 3:1 lg+)
2. **Alt Text**: Always provide meaningful alt text for images and videos
3. **Performance**: Use `loading="lazy"` for images below the fold
4. **CTAs**: Keep CTA text concise and action-oriented
5. **Headlines**: Keep headlines concise and impactful

View File

@@ -1,130 +0,0 @@
.bds-header-hero-primary-media {
padding-top: 24px;
padding-bottom: 24px;
@include media-breakpoint-up(md) {
padding-top: 32px;
padding-bottom: 32px;
}
@include media-breakpoint-up(lg) {
padding-top: 170px;
padding-bottom: 40px;
}
@include bds-theme-mode(light) {
background-color: $white;
}
@include bds-theme-mode(dark) {
background-color: $black;
}
&__headline-container {
margin-bottom: 32px; // this margin is also default with the class - however, to avoid regressive changes we are reinforcing here
@include media-breakpoint-up(lg) {
margin-bottom: 0px;
}
}
&__headline {
margin: 0;
display: flex;
align-items: flex-end;
height: 100%;
* {
max-width: 100%;
overflow-wrap: break-word;
word-break: break-word;
}
}
&__subtitle {
margin: 0;
padding: 0;
@include bds-theme-mode(light) {
color: $gray-500
}
}
&__cta-container {
display: flex;
flex-direction: column;
gap: 24px;
justify-content: flex-end;
min-height: 100%;
@include media-breakpoint-up(lg) {
gap: 40px;
}
}
&__cta-buttons {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
flex-wrap: wrap;
gap: 16px;
@include media-breakpoint-up(md) {
flex-direction: row;
justify-content: flex-start;
align-items: center;
}
@include media-breakpoint-up(lg) {
gap: 24px;
}
& .bds-btn--tertiary {
padding: 0; // Design requires this button in this use-case to be overwritten with no padding
&:hover,
&:focus-visible,
&:focus {
padding: 0 !important;
}
}
}
&__media-container {
width: 100%;
aspect-ratio: 16 / 9; // Design req uirement: 16/9 aspect ratio
overflow: hidden;
position: relative;
margin-top: 24px;
height: auto;
@include media-breakpoint-up(md) {
margin-top: 32px;
aspect-ratio: 2 / 1;
}
@include media-breakpoint-up(lg) {
margin-top: 40px;
aspect-ratio: 3 / 1;
}
}
&__media-element {
// Styles are applied inline to ensure object-fit: cover
// This ensures the media covers the entire container area
// while maintaining the aspect ratio constraint
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
}

View File

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

View File

@@ -0,0 +1,177 @@
import React, { useMemo } from 'react';
import clsx from 'clsx';
import { PageGrid, PageGridCol, PageGridRow } from 'shared/components/PageGrid/page-grid';
import { TileLogo } from '../../components/TileLogo/TileLogo';
export interface LogoItem {
/** Logo image source URL */
src: string;
/** Alt text for the logo image */
alt: string;
/** Optional link URL - makes the logo clickable */
href?: string;
/** Optional click handler - makes the logo a button */
onClick?: () => void;
/** Disabled state */
disabled?: boolean;
}
export interface LogoRectangleGridProps {
/** Color variant - determines background color */
variant?: 'gray' | 'green';
/** Heading text (required) */
heading: string;
/** Optional description text */
description?: string;
/** Array of logo items to display in the grid */
logos: LogoItem[];
/** Additional CSS classes */
className?: string;
}
/**
* Calculates the md and lg offsets for the first tile of each row to right-align the grid.
*
* This is a 3-tile-per-row grid. To right-align, we offset based on how many tiles are in that row:
*
* lg (12 columns, each tile = 3 cols):
* - 3 tiles in row: offset 3
* - 2 tiles in row: offset 6
* - 1 tile in row: offset 9
*
* md (8 columns, each tile = 2 cols):
* - 3 tiles in row: offset 2
* - 2 tiles in row: offset 4
* - 1 tile in row: offset 6
*
* Only tiles 1-9 (positions 0-8) are right-aligned. 10+ tiles = no offset.
*
* @param index - The tile's position (0-based)
* @param total - Total number of tiles
* @returns Object with md and lg offset values for this tile (both 0 if not first of row)
*/
const calculateTileOffset = (index: number, total: number): { md: number; lg: number } => {
// No offset if 10+ tiles total
if (total >= 10) return { md: 0, lg: 0 };
// Only first tile of each row gets offset (every 3rd position starting at 0)
if (index % 3 !== 0) return { md: 0, lg: 0 };
// Calculate which row this tile is in
const row = Math.floor(index / 3);
// Calculate how many tiles are in this row
const tilesInThisRow = Math.min(3, total - row * 3);
// Calculate offset to right-align
// lg: (4 - tilesInRow) * 3 → 3 tiles = 3, 2 tiles = 6, 1 tile = 9
// md: (4 - tilesInRow) * 2 → 3 tiles = 2, 2 tiles = 4, 1 tile = 6
const lgOffset = (4 - tilesInThisRow) * 3;
const mdOffset = (4 - tilesInThisRow) * 2;
return { md: mdOffset, lg: lgOffset };
};
/**
* LogoRectangleGrid Component
*
* A responsive grid pattern for displaying company/partner logos with rectangle tiles
* and dynamic offset based on tile count. Features 9:5 aspect ratio rectangle tiles
* with 2 color variants and dark mode support.
*
* Offset Logic (lg breakpoint only, applied to first tile):
* - 1 tile: offset 9
* - 2 tiles: offset 6
* - 3 tiles: offset 3
* - 4 tiles: offset 9
* - 5 tiles: offset 6
* - 6 tiles: offset 3
* - 7 tiles: offset 9
* - 8 tiles: offset 6
* - 9 tiles: offset 3
* - 10+ tiles: no offset
*
* @example
* // Basic usage with gray variant
* <LogoRectangleGrid
* variant="gray"
* heading="Developer tools & APIs"
* description="Streamline development with comprehensive tools."
* logos={[
* { src: "/logos/company1.svg", alt: "Company 1" },
* { src: "/logos/company2.svg", alt: "Company 2" }
* ]}
* />
*
* @example
* // With clickable logos
* <LogoRectangleGrid
* variant="green"
* heading="Our Partners"
* description="Leading companies building on XRPL."
* logos={[
* { src: "/logos/partner1.svg", alt: "Partner 1", href: "https://partner1.com" }
* ]}
* />
*/
export const LogoRectangleGrid: React.FC<LogoRectangleGridProps> = ({
variant = 'gray',
heading,
description,
logos,
className = '',
}) => {
// Build class names using BEM with bds namespace
const classNames = clsx(
'bds-logo-rectangle-grid',
`bds-logo-rectangle-grid--${variant}`,
className
);
// Memoize offset calculations - only recalculate when logos array changes
const logoOffsets = useMemo(() => {
const total = logos.length;
return logos.map((_, index) => calculateTileOffset(index, total));
}, [logos]);
return (
<PageGrid className={classNames}>
<PageGridRow>
<PageGridCol span={{ base: 4, md: 6, lg: 8 }}>
{/* Header Section */}
<div className="bds-logo-rectangle-grid__header">
<div className="bds-logo-rectangle-grid__text">
<h4 className="h-md mb-0">{heading}</h4>
{description && <p className="body-l mb-0">{description}</p>}
</div>
</div>
</PageGridCol>
</PageGridRow>
<PageGridRow>
{logos.map((logo, index) => {
const offset = logoOffsets[index];
const hasOffset = offset.md > 0 || offset.lg > 0;
return (
<PageGridCol
key={index}
span={{ base: 2, md: 2, lg: 3 }}
offset={hasOffset ? { md: offset.md, lg: offset.lg } : undefined}
>
<TileLogo
shape="rectangle"
variant={variant === 'gray' ? 'neutral' : 'green'}
logo={logo.src}
alt={logo.alt}
href={logo.href}
onClick={logo.onClick}
disabled={logo.disabled}
/>
</PageGridCol>
);
})}
</PageGridRow>
</PageGrid>
);
};
export default LogoRectangleGrid;

View File

@@ -0,0 +1,422 @@
# LogoRectangleGrid Component
A responsive grid pattern for displaying company/partner logos with rectangle tiles and dynamic alignment based on tile count. Built on top of the TileLogo component, featuring 9:5 aspect ratio rectangle tiles with 2 color variants and full dark mode support.
## Features
- **2 Color Variants**: Gray and Green backgrounds
- **Dynamic Alignment**: Grid alignment changes based on logo count (1-3: right, 4: left, 5-9: right, 9+: left)
- **Responsive Grid**: Automatically adapts from 2 columns (mobile) to 3 columns (tablet) to 4 columns (desktop)
- **Required Header**: Heading is required, description is optional
- **Clickable Logos**: Support for optional links on individual logos
- **Dark Mode Support**: Full light and dark mode compatibility
- **Rectangle Tiles**: Maintains 9:5 aspect ratio at all breakpoints
- **Grid Integration**: Built-in PageGrid wrapper with standard container support
## Responsive Behavior
The component automatically adapts its grid layout based on viewport width:
| Breakpoint | Columns | Gap | Tile Aspect Ratio |
|------------|---------|-----|-------------------|
| Mobile (< 768px) | 2 | 8px | 9:5 |
| Tablet (768px - 1023px) | 3 | 8px | 9:5 |
| Desktop (≥ 1024px) | 4 | 8px | 9:5 |
## Dynamic Alignment
The grid alignment changes based on the number of logos:
| Logo Count | Alignment | Offset |
|------------|-----------|--------|
| 1-3 | Right | 4 columns |
| 4 | Left | 0 columns |
| 5-9 | Right | 4 columns |
| 9+ | Left | 0 columns |
This creates a visually balanced layout that adapts to different content volumes.
## Color Variants
The LogoRectangleGrid pattern uses two color variants that map directly to TileLogo component variants:
| LogoRectangleGrid Variant | TileLogo Variant | Description |
|---------------------------|------------------|-------------|
| `gray` | `neutral` | Subtle, professional appearance for general partner showcases |
| `green` | `green` | Highlights featured or primary partners |
Colors are managed by the TileLogo component and automatically adapt between light and dark modes with proper hover states and animations.
## Props API
```typescript
interface LogoItem {
/** Logo image source URL */
src: string;
/** Alt text for the logo image */
alt: string;
/** Optional link URL - makes the logo clickable */
href?: string;
/** Optional click handler - makes the logo a button */
onClick?: () => void;
/** Disabled state */
disabled?: boolean;
}
interface LogoRectangleGridProps {
/** Color variant - determines background color */
variant?: 'gray' | 'green';
/** Heading text (required) */
heading: string;
/** Optional description text */
description?: string;
/** Array of logo items to display in the grid */
logos: LogoItem[];
/** Additional CSS classes */
className?: string;
}
```
### Default Values
- `variant`: `'gray'`
- `description`: `undefined`
- `className`: `''`
### Required Props
- `heading`: Heading text (required)
- `logos`: Array of logo items (required)
## Usage Examples
### Basic Usage with Gray Variant
```tsx
import { LogoRectangleGrid } from 'shared/patterns/LogoRectangleGrid';
<LogoRectangleGrid
variant="gray"
heading="Developer tools & APIs"
logos={[
{ src: "/img/logos/company1.svg", alt: "Company 1" },
{ src: "/img/logos/company2.svg", alt: "Company 2" },
{ src: "/img/logos/company3.svg", alt: "Company 3" },
{ src: "/img/logos/company4.svg", alt: "Company 4" }
]}
/>
```
### With Description
```tsx
<LogoRectangleGrid
variant="green"
heading="Developer tools & APIs"
description="Streamline development and build powerful RWA tokenization solutions with XRP Ledger's comprehensive developer toolset."
logos={[
{ src: "/img/logos/tool1.svg", alt: "Tool 1" },
{ src: "/img/logos/tool2.svg", alt: "Tool 2" },
{ src: "/img/logos/tool3.svg", alt: "Tool 3" },
{ src: "/img/logos/tool4.svg", alt: "Tool 4" },
{ src: "/img/logos/tool5.svg", alt: "Tool 5" },
{ src: "/img/logos/tool6.svg", alt: "Tool 6" },
{ src: "/img/logos/tool7.svg", alt: "Tool 7" },
{ src: "/img/logos/tool8.svg", alt: "Tool 8" }
]}
/>
```
### With Clickable Logos
```tsx
<LogoRectangleGrid
variant="gray"
heading="Our Partners"
description="Leading companies building on XRPL."
logos={[
{
src: "/img/logos/partner1.svg",
alt: "Partner 1",
href: "https://partner1.com"
},
{
src: "/img/logos/partner2.svg",
alt: "Partner 2",
href: "https://partner2.com"
}
]}
/>
```
### With Button Handlers
```tsx
<LogoRectangleGrid
variant="green"
heading="Interactive Partners"
description="Click any logo to learn more."
logos={[
{
src: "/img/logos/partner1.svg",
alt: "Partner 1",
onClick: () => openModal('partner1')
},
{
src: "/img/logos/partner2.svg",
alt: "Partner 2",
onClick: () => openModal('partner2')
}
]}
/>
```
### With Disabled State
```tsx
<LogoRectangleGrid
variant="gray"
heading="Coming Soon"
description="New partners joining the ecosystem."
logos={[
{
src: "/img/logos/partner1.svg",
alt: "Partner 1",
href: "/partners/partner1"
},
{
src: "/img/logos/coming-soon.svg",
alt: "Coming Soon",
disabled: true
}
]}
/>
```
### Heading Only (No Description)
```tsx
<LogoRectangleGrid
variant="gray"
heading="Ecosystem Members"
logos={[
{ src: "/img/logos/sponsor1.svg", alt: "Sponsor 1" },
{ src: "/img/logos/sponsor2.svg", alt: "Sponsor 2" },
{ src: "/img/logos/sponsor3.svg", alt: "Sponsor 3" },
{ src: "/img/logos/sponsor4.svg", alt: "Sponsor 4" }
]}
/>
```
### Demonstrating Alignment Logic
```tsx
// 1-3 logos: Right-aligned
<LogoRectangleGrid
variant="green"
heading="Featured Partners"
logos={[
{ src: "/img/logos/partner1.svg", alt: "Partner 1" },
{ src: "/img/logos/partner2.svg", alt: "Partner 2" }
]}
/>
// 4 logos: Left-aligned
<LogoRectangleGrid
variant="gray"
heading="Core Technologies"
logos={[
{ src: "/img/logos/tech1.svg", alt: "Tech 1" },
{ src: "/img/logos/tech2.svg", alt: "Tech 2" },
{ src: "/img/logos/tech3.svg", alt: "Tech 3" },
{ src: "/img/logos/tech4.svg", alt: "Tech 4" }
]}
/>
// 5-9 logos: Right-aligned
<LogoRectangleGrid
variant="green"
heading="Developer Tools"
logos={[
{ src: "/img/logos/tool1.svg", alt: "Tool 1" },
{ src: "/img/logos/tool2.svg", alt: "Tool 2" },
{ src: "/img/logos/tool3.svg", alt: "Tool 3" },
{ src: "/img/logos/tool4.svg", alt: "Tool 4" },
{ src: "/img/logos/tool5.svg", alt: "Tool 5" },
{ src: "/img/logos/tool6.svg", alt: "Tool 6" }
]}
/>
// 9+ logos: Left-aligned
<LogoRectangleGrid
variant="gray"
heading="Partner Ecosystem"
logos={[
// ... 12 logos
]}
/>
```
## Important Implementation Details
### Logo Image Requirements
For best results, logo images should:
- Be SVG format for crisp scaling
- Have transparent backgrounds
- Be reasonably sized (width: 150-250px recommended for 9:5 aspect ratio)
- Use monochrome or simple color schemes
- Have consistent visual weight across all logos
### Grid Behavior
- The grid uses PageGridCol components for responsive layout
- Each tile uses `span={{ base: 2, md: 2, lg: 3 }}` (2 cols on mobile out of 4, 2 cols on tablet out of 6, 3 cols on desktop out of 12)
- This creates 2 columns on mobile, 3 columns on tablet, and 4 columns on desktop
- Tiles maintain a 9:5 aspect ratio using the TileLogo rectangle shape
- Gaps between tiles are handled by PageGrid's built-in gutter system
- Grid automatically wraps to new rows as needed
- Grid alignment changes dynamically based on logo count
### Alignment Logic Implementation
The alignment is controlled by the `alignRight` variable:
```typescript
const logoCount = logos.length;
const alignRight =
(logoCount >= 1 && logoCount <= 3) ||
(logoCount >= 5 && logoCount <= 9);
```
When `alignRight` is true:
- Grid container spans 8 columns on desktop (lg breakpoint)
- Grid container has 4-column offset on desktop (pushes content right)
When `alignRight` is false:
- Grid container spans full width
- Grid container has 0 offset (content aligns left)
### Clickable Logo Behavior
Logo tiles leverage the TileLogo component's interactive capabilities:
- **With `href` property**: Renders as a link (`<a>` tag) with window shade hover animation
- **With `onClick` property**: Renders as a button with the same interactive states
- **With `disabled` property**: Prevents interaction and applies disabled styling
- **Interactive states**: Default, Hover, Focused, Pressed, and Disabled
- **Animation**: Window shade effect that wipes from bottom to top on hover
- All tiles automatically maintain focus states for keyboard accessibility
## Styling
### BEM Class Structure
```scss
.bds-logo-rectangle-grid // Base component
.bds-logo-rectangle-grid--gray // Gray variant (maps to TileLogo 'neutral')
.bds-logo-rectangle-grid--green // Green variant (maps to TileLogo 'green')
.bds-logo-rectangle-grid__header // Header section container
.bds-logo-rectangle-grid__text // Text content container
```
**Note**: Individual logo tiles are rendered using the TileLogo component with its own BEM structure (`bds-tile-logo`) and `shape="rectangle"`. Grid layout is handled by PageGridRow and PageGridCol components.
### Typography Tokens
- **Heading**: Uses `heading-md` type token (Tobias Light font)
- Desktop: 40px / 46px line-height / -1px letter-spacing
- Tablet: 36px / 45px line-height / -0.5px letter-spacing
- Mobile: 32px / 40px line-height / 0px letter-spacing
- **Description**: Uses `body-l` type token (Booton Light font)
- Desktop: 18px / 26.1px line-height / -0.5px letter-spacing
- Tablet: 18px / 26.1px line-height / -0.5px letter-spacing
- Mobile: 18px / 26.1px line-height / -0.5px letter-spacing
### Color Tokens
All colors are sourced from `styles/_colors.scss`:
```scss
// Tile backgrounds
$gray-200 // Gray variant (light mode)
$gray-700 // Gray variant (dark mode)
$green-200 // Green variant (light mode)
$green-300 // Green variant (dark mode)
```
## Accessibility
- Semantic HTML structure with proper heading hierarchy
- All logos include descriptive alt text
- Clickable logos have proper link semantics
- Keyboard navigation support with visible focus states
- Color contrast meets WCAG AA standards in all variants
## Best Practices
### When to Use Each Variant
- **Gray**: General-purpose logo grids, subtle integration
- **Green**: Featured partnerships, brand-focused sections
### Content Guidelines
- **Heading**: Required, keep concise (1-2 lines preferred), use sentence case
- **Description**: Optional, provide context (2-3 lines max), complete sentences
- **Logo Count**: Consider the alignment logic when choosing how many logos to display
- **Alt Text**: Use company/product names, not generic "logo"
### Logo Preparation
1. **Consistent Sizing**: Ensure all logos have similar visual weight
2. **Format**: Use SVG for scalability and crisp rendering
3. **Background**: Transparent backgrounds work best
4. **Aspect Ratio**: Rectangle tiles work well with horizontal logos
5. **Padding**: Include minimal internal padding in the SVG itself
### Performance
- Use optimized SVG files (run through SVGO or similar)
- Consider lazy loading for grids with many logos
- Provide appropriate alt text for all images
- Use `width` and `height` attributes on img tags when possible
### Technical Implementation
- **Grid System**: Uses PageGridCol with `span={{ base: 2, md: 2, lg: 3 }}` for responsive layout
- **Tile Rendering**: Leverages TileLogo component with `shape="rectangle"` for all logo tiles
- **Variant Mapping**: LogoRectangleGrid 'gray' TileLogo 'neutral', LogoRectangleGrid 'green' TileLogo 'green'
- **Interactive States**: TileLogo handles href (links), onClick (buttons), and disabled states
- **Aspect Ratio**: Rectangle tiles maintained by TileLogo with CSS `aspect-ratio: 9/5`
- **Animations**: Window shade hover effect managed by TileLogo component
- **Dynamic Alignment**: Grid alignment controlled by conditional offset based on logo count
## Files
- `LogoRectangleGrid.tsx` - Component implementation
- `LogoRectangleGrid.scss` - Styles with color variants and responsive breakpoints
- `index.ts` - Barrel exports
- `README.md` - This documentation
## Related Components
- **TileLogo**: Core component used to render individual logo tiles with interactive states and rectangle shape
- **LogoSquareGrid**: Similar pattern but with square tiles instead of rectangle tiles
- **PageGrid**: Used internally for responsive grid structure and standard container support
## Design References
- **Figma Design**: [Section Logo - Rectangle Grid](https://www.figma.com/design/gaTsImoTRsiRXAGzbGKcCd/Section-Logo---Rectangle-Grid?node-id=1-2)
- **Showcase Page**: `/about/logo-rectangle-grid-showcase.page.tsx`
- **Component Location**: `shared/patterns/LogoRectangleGrid/`
## Version History
- **January 2026**: Initial implementation
- Figma design alignment with 2 color variants
- Responsive grid with 2/3/4 column layout
- Dynamic alignment based on logo count
- Required header section with optional description
- Clickable logo support
- Rectangle tiles with 9:5 aspect ratio

View File

@@ -0,0 +1,2 @@
export { LogoRectangleGrid } from './LogoRectangleGrid';
export type { LogoRectangleGridProps, LogoItem } from './LogoRectangleGrid';

View File

@@ -2,7 +2,7 @@ import React from 'react';
import clsx from 'clsx';
import { PageGrid, PageGridCol, PageGridRow } from 'shared/components/PageGrid/page-grid';
import { TileLogo } from '../../components/TileLogo/TileLogo';
import { ButtonGroup } from '../ButtonGroup/ButtonGroup';
import { ButtonGroup } from '../../components/ButtonGroup/ButtonGroup';
export interface LogoItem {
/** Logo image source URL */
@@ -28,13 +28,13 @@ export interface LogoSquareGridProps {
primaryButton?: {
label: string;
href?: string;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onClick?: () => void;
};
/** Tertiary button configuration */
tertiaryButton?: {
label: string;
href?: string;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onClick?: () => void;
};
/** Array of logo items to display in the grid */
logos: LogoItem[];

View File

@@ -5332,7 +5332,7 @@ textarea.form-control-lg {
display: table-cell !important;
}
.d-flex, .bds-logo-square-grid__text, .bds-logo-square-grid__header, .bds-logo-square-grid, .bds-callout-media-banner__text, .bds-callout-media-banner__content, .bds-callout-media-banner > [class*=bds-grid__col], .bds-callout-media-banner, .bds-button-group {
.d-flex, .bds-logo-rectangle-grid__text, .bds-logo-rectangle-grid__header, .bds-logo-rectangle-grid, .bds-logo-square-grid__text, .bds-logo-square-grid__header, .bds-logo-square-grid, .bds-callout-media-banner__text, .bds-callout-media-banner__content, .bds-callout-media-banner > [class*=bds-grid__col], .bds-callout-media-banner, .bds-button-group {
display: flex !important;
}
@@ -5646,7 +5646,7 @@ textarea.form-control-lg {
width: 75% !important;
}
.w-100, .bds-logo-square-grid, .bds-callout-media-banner__content, .bds-callout-media-banner {
.w-100, .bds-logo-rectangle-grid, .bds-logo-square-grid, .bds-callout-media-banner__content, .bds-callout-media-banner {
width: 100% !important;
}
@@ -5706,7 +5706,7 @@ textarea.form-control-lg {
flex-direction: row !important;
}
.flex-column, .bds-logo-square-grid__text, .bds-logo-square-grid__header, .bds-logo-square-grid, .bds-callout-media-banner__text, .bds-callout-media-banner__content, .bds-callout-media-banner > [class*=bds-grid__col], .bds-button-group {
.flex-column, .bds-logo-rectangle-grid__text, .bds-logo-rectangle-grid__header, .bds-logo-rectangle-grid, .bds-logo-square-grid__text, .bds-logo-square-grid__header, .bds-logo-square-grid, .bds-callout-media-banner__text, .bds-callout-media-banner__content, .bds-callout-media-banner > [class*=bds-grid__col], .bds-button-group {
flex-direction: column !important;
}
@@ -11295,7 +11295,7 @@ aside .active-parent > a {
width: 48px;
}
.w-100, .bds-logo-square-grid, .bds-callout-media-banner__content, .bds-callout-media-banner {
.w-100, .bds-logo-rectangle-grid, .bds-logo-square-grid, .bds-callout-media-banner__content, .bds-callout-media-banner {
width: 100%;
}
@@ -19567,120 +19567,6 @@ html.light .bds-card-icon--disabled.bds-card-icon--green .bds-card-icon__icon-im
unicode-bidi: isolate;
}
.bds-header-hero-primary-media {
padding-top: 24px;
padding-bottom: 24px;
}
@media (min-width: 576px) {
.bds-header-hero-primary-media {
padding-top: 32px;
padding-bottom: 32px;
}
}
@media (min-width: 992px) {
.bds-header-hero-primary-media {
padding-top: 170px;
padding-bottom: 40px;
}
}
html.light .bds-header-hero-primary-media {
background-color: #FFFFFF;
}
html.dark .bds-header-hero-primary-media {
background-color: #141414;
}
.bds-header-hero-primary-media__headline-container {
margin-bottom: 32px;
}
@media (min-width: 992px) {
.bds-header-hero-primary-media__headline-container {
margin-bottom: 0px;
}
}
.bds-header-hero-primary-media__headline {
margin: 0;
display: flex;
align-items: flex-end;
height: 100%;
}
.bds-header-hero-primary-media__headline * {
max-width: 100%;
overflow-wrap: break-word;
word-break: break-word;
}
.bds-header-hero-primary-media__subtitle {
margin: 0;
padding: 0;
}
html.light .bds-header-hero-primary-media__subtitle {
color: #72777E;
}
.bds-header-hero-primary-media__cta-container {
display: flex;
flex-direction: column;
gap: 24px;
justify-content: flex-end;
min-height: 100%;
}
@media (min-width: 992px) {
.bds-header-hero-primary-media__cta-container {
gap: 40px;
}
}
.bds-header-hero-primary-media__cta-buttons {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
flex-wrap: wrap;
gap: 16px;
}
@media (min-width: 576px) {
.bds-header-hero-primary-media__cta-buttons {
flex-direction: row;
justify-content: flex-start;
align-items: center;
}
}
@media (min-width: 992px) {
.bds-header-hero-primary-media__cta-buttons {
gap: 24px;
}
}
.bds-header-hero-primary-media__cta-buttons .bds-btn--tertiary {
padding: 0;
}
.bds-header-hero-primary-media__cta-buttons .bds-btn--tertiary:hover, .bds-header-hero-primary-media__cta-buttons .bds-btn--tertiary:focus-visible, .bds-header-hero-primary-media__cta-buttons .bds-btn--tertiary:focus {
padding: 0 !important;
}
.bds-header-hero-primary-media__media-container {
width: 100%;
aspect-ratio: 16/9;
overflow: hidden;
position: relative;
margin-top: 24px;
height: auto;
}
@media (min-width: 576px) {
.bds-header-hero-primary-media__media-container {
margin-top: 32px;
aspect-ratio: 2/1;
}
}
@media (min-width: 992px) {
.bds-header-hero-primary-media__media-container {
margin-top: 40px;
aspect-ratio: 3/1;
}
}
.bds-header-hero-primary-media__media-element {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
.bds-hero-split-media {
width: 100%;
}
@@ -20362,235 +20248,48 @@ html.dark .bds-cards-featured__description {
color: #FFFFFF;
}
.bds-callout-media-banner {
box-sizing: border-box;
min-height: 280px;
}
.bds-callout-media-banner {
background-size: cover;
background-position: center;
background-repeat: no-repeat;
padding: 16px;
.bds-logo-rectangle-grid {
gap: 24px;
}
@media (min-width: 576px) {
.bds-callout-media-banner {
padding: 24px;
}
}
@media (min-width: 992px) {
.bds-callout-media-banner {
padding: 40px 32px;
min-height: 360px;
}
}
.bds-callout-media-banner__content {
gap: 48px;
}
@media (min-width: 576px) {
.bds-callout-media-banner__content {
gap: 64px;
}
}
@media (min-width: 1280px) {
.bds-callout-media-banner__content {
gap: 80px;
}
}
.bds-callout-media-banner--centered .bds-callout-media-banner__content {
justify-content: center;
}
.bds-callout-media-banner__text {
flex-direction: column;
color: var(--bds-cmb-text-color, #232021);
gap: 16px;
}
@media (min-width: 576px) {
.bds-callout-media-banner__text {
gap: 24px;
}
}
@media (min-width: 1280px) {
.bds-callout-media-banner__text {
.bds-logo-rectangle-grid {
gap: 32px;
}
}
@media (min-width: 992px) {
.bds-logo-rectangle-grid {
gap: 40px;
}
}
.bds-callout-media-banner__heading {
font-family: "Tobias", "Noto Serif", monospace;
font-weight: 300;
font-size: 32px;
line-height: 40px;
letter-spacing: 0px;
margin-bottom: 16px;
.bds-logo-rectangle-grid__header {
margin-top: 24px;
margin-bottom: 24px;
gap: 24px;
}
@media (min-width: 576px) {
.bds-callout-media-banner__heading {
font-size: 36px;
line-height: 45px;
letter-spacing: -0.5px;
margin-bottom: 16px;
.bds-logo-rectangle-grid__header {
gap: 32px;
margin-top: 32px;
margin-bottom: 32px;
}
}
@media (min-width: 992px) {
.bds-callout-media-banner__heading {
font-size: 40px;
line-height: 46px;
letter-spacing: -1px;
margin-bottom: 16px;
.bds-logo-rectangle-grid__header {
gap: 40px;
margin-top: 40px;
margin-bottom: 40px;
}
}
.bds-callout-media-banner__heading {
margin: 0;
color: inherit !important;
}
.bds-callout-media-banner__subheading {
font-family: "Booton", "Noto Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-weight: 400;
font-size: 24px;
line-height: 30px;
letter-spacing: -1px;
margin-bottom: 16px;
}
@media (min-width: 576px) {
.bds-callout-media-banner__subheading {
font-size: 28px;
line-height: 35px;
letter-spacing: -0.75px;
margin-bottom: 16px;
}
.bds-logo-rectangle-grid__text {
gap: 8px;
}
@media (min-width: 992px) {
.bds-callout-media-banner__subheading {
font-size: 32px;
line-height: 40px;
letter-spacing: -0.5px;
margin-bottom: 16px;
.bds-logo-rectangle-grid__text {
gap: 16px;
}
}
.bds-callout-media-banner__subheading {
margin: 0;
color: inherit;
}
.bds-callout-media-banner--default {
background-color: #FFFFFF;
}
.bds-callout-media-banner--light-gray {
background-color: #E6EAF0;
}
.bds-callout-media-banner--lilac {
background-color: #C0A7FF;
}
.bds-callout-media-banner--green {
background-color: #70EE97;
}
.bds-callout-media-banner--gray {
background-color: #CAD4DF;
}
.bds-callout-media-banner--image {
background-color: transparent;
}
.bds-callout-media-banner--image::before {
content: "";
top: 0;
left: 0;
right: 0;
bottom: 0;
inset: 0;
background: linear-gradient(135deg, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0.2) 50%, rgba(0, 0, 0, 0.1) 100%);
z-index: 0;
pointer-events: none;
}
.bds-callout-media-banner--image-text-black::before {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 0.2) 50%, rgba(255, 255, 255, 0.1) 100%);
}
.bds-callout-media-banner--image-text-black .bds-callout-media-banner__text {
color: #141414 !important;
}
html.dark .bds-callout-media-banner--default {
background-color: #232325;
}
html.dark .bds-callout-media-banner--default .bds-callout-media-banner__text {
color: #FFFFFF;
}
html.dark .bds-callout-media-banner--light-gray {
background-color: #343437;
}
html.dark .bds-callout-media-banner--light-gray .bds-callout-media-banner__text {
color: #FFFFFF;
}
html.dark .bds-callout-media-banner--lilac {
background-color: #7649E3;
}
html.dark .bds-callout-media-banner--lilac .bds-callout-media-banner__text {
color: #FFFFFF;
}
html.dark .bds-callout-media-banner--green {
background-color: #21E46B;
}
html.dark .bds-callout-media-banner--green .bds-callout-media-banner__text {
color: #141414;
}
html.dark .bds-callout-media-banner--gray {
background-color: #454549;
}
html.dark .bds-callout-media-banner--gray .bds-callout-media-banner__text {
color: #FFFFFF;
}
html.dark .bds-callout-media-banner--image .bds-callout-media-banner__text {
color: #FFFFFF;
}
html.dark .bds-callout-media-banner--image-text-black .bds-callout-media-banner__text {
color: #141414 !important;
}
html.light .bds-callout-media-banner--default {
background-color: #FFFFFF;
}
html.light .bds-callout-media-banner--default .bds-callout-media-banner__text {
color: #141414;
}
html.light .bds-callout-media-banner--light-gray {
background-color: #E6EAF0;
}
html.light .bds-callout-media-banner--light-gray .bds-callout-media-banner__text {
color: #141414;
}
html.light .bds-callout-media-banner--lilac {
background-color: #C0A7FF;
}
html.light .bds-callout-media-banner--lilac .bds-callout-media-banner__text {
color: #141414;
}
html.light .bds-callout-media-banner--green {
background-color: #70EE97;
}
html.light .bds-callout-media-banner--green .bds-callout-media-banner__text {
color: #141414;
}
html.light .bds-callout-media-banner--gray {
background-color: #CAD4DF;
}
html.light .bds-callout-media-banner--gray .bds-callout-media-banner__text {
color: #141414;
}
html.light .bds-callout-media-banner--image .bds-callout-media-banner__text {
color: #FFFFFF;
}
html.light .bds-callout-media-banner--image-text-black .bds-callout-media-banner__text {
color: #141414 !important;
}
.bds-feature-two-column__button-group .bds-btn--tertiary {
padding-top: 0px !important;

View File

@@ -98,13 +98,12 @@ $line-height-base: 1.5;
@import "../shared/components/TileLogo/TileLogo.scss";
@import "../shared/components/CardIcon/CardIcon.scss";
@import "../shared/components/SmallTilesSection/_small-tiles-section.scss";
@import "../shared/patterns/HeaderHeroPrimaryMedia/_header-hero-primary-media.scss";
@import "../shared/patterns/HeaderHeroSplitMedia/HeaderHeroSplitMedia.scss";
@import "../shared/patterns/ButtonGroup/ButtonGroup.scss";
@import "../shared/patterns/CalloutMediaBanner/CalloutMediaBanner.scss";
@import "../shared/patterns/LogoSquareGrid/LogoSquareGrid.scss";
@import "../shared/patterns/CardsFeatured/CardsFeatured.scss";
@import "../shared/patterns/CalloutMediaBanner/CalloutMediaBanner.scss";
@import "../shared/patterns/LogoRectangleGrid/LogoRectangleGrid.scss";
@import "../shared/patterns/FeatureTwoColumn/FeatureTwoColumn.scss";
@import "_code-tabs.scss";
@import "_code-walkthrough.scss";