From 484a2e4b2966dfb3588ae0bf56adf85e522f2a79 Mon Sep 17 00:00:00 2001
From: Oleksii Horbachevskyi <17987710+ohorbachevskyi@users.noreply.github.com>
Date: Wed, 26 Aug 2026 09:23:44 +0000
Subject: [PATCH 1/2] Merge branch 'main' into docs/card-styling-updates
remoteId: rem_01jwbtfqhbbam07z9j9m0etng6
remoteUpdateId: grupd_01m0yp3c953wmfnjz50ch4g9xt
branchName: docs/card-styling-updates
commitSha: 58efaf006b98b474ec66132dc20c14b1543da59e
commitUrl: https://github.com/Redocly/redocly/commit/58efaf006b98b474ec66132dc20c14b1543da59e
prId: 26402
---
docs/realm/content/markdoc-tags/cards.md | 283 +++++++++++++++++++++++
1 file changed, 283 insertions(+)
diff --git a/docs/realm/content/markdoc-tags/cards.md b/docs/realm/content/markdoc-tags/cards.md
index af9cf2c5c..f361a9b65 100644
--- a/docs/realm/content/markdoc-tags/cards.md
+++ b/docs/realm/content/markdoc-tags/cards.md
@@ -71,6 +71,63 @@ A card only works as a child of `cards`.
---
+- linkIcon
+- string
+- Icon displayed at the end of the card title.
+ Can be `chevron` or `arrow`.
+ The icon displays only on a cards that also have the `to` attribute.
+ Without this attribute, the title has no icon.
+
+---
+
+- cta
+- string
+- Call-to-action text displayed under the card content, followed by a chevron icon.
+ The text displays only on cards that also have the `to` attribute.
+ Clicking anywhere on the card opens that link.
+ The text and the icon change color on mouse hover.
+
+---
+
+- badge
+- string
+- Text of a badge that displays in the card title, after the link icon.
+
+---
+
+- badgeColor
+- string
+- Color of the badge.
+ Supports predefined color names for consistent styling.
+
+ **Supported color names:**
+ `red`, `green`, `blue`, `grey`, `turquoise`, `magenta`, `purple`, `carrot`, `raspberry`, `orange`, `grass`, `persian-green`, `sky`, `blueberry`.
+
+ To use a color of your own, see [Custom colors](#custom-colors).
+ Default: `grey`.
+
+---
+
+- badgeIcon
+- string
+- Icon to display inside the badge.
+- Either:
+ - A [Font Awesome](https://fontawesome.com/icons) icon name.
+ Realm has the following icon packs built in: Classic Regular, Classic Solid, Duotone Solid, and Classic Brands.
+ The icons automatically adjust their colors when users change the color mode.
+
+ To add an icon from the Classic Regular pack, you can provide the icon name only or prefix the name with `regular`.
+ To add an icon from other built-in packs, prefix the icon name with: `solid` (for Classic Solid), `duotone` (for Duotone Solid), or `brands` (for Classic Brands).
+
+ **Examples:** `book`, `duotone book`, `brands github`
+
+ Using other prefixes, including the `fa-` prefix, causes the icon to not render.
+ - Relative path to an icon image file.
+
+ **Example:** `./images/config-icon.svg`
+
+---
+
- variant
- string
- Visual style of the card.
@@ -122,6 +179,18 @@ A card only works as a child of `cards`.
---
+- iconColor
+- string
+- Color of the icon.
+ Supports predefined color names for consistent styling.
+
+ **Supported color names:**
+ `red`, `green`, `blue`, `grey`, `turquoise`, `magenta`, `purple`, `carrot`, `raspberry`, `orange`, `grass`, `persian-green`, `sky`, `blueberry`.
+
+ To use a color of your own, see [Custom colors](#custom-colors).
+
+---
+
- lineClamp
- string
- Limits the number of lines of text displayed in the card content.
@@ -283,6 +352,131 @@ Each card variant has unique hover styles for cards as links.
+### Link icons
+
+Set the `linkIcon` attribute on a link card to add an icon at the end of the card title.
+The icon needs the `to` attribute, and a card without `to` ignores it.
+
+{% cards columns=2 cardMinWidth=180 %}
+
+ {% card title="Chevron" to="#link-icons" linkIcon="chevron" %}
+ Uses the `chevron` icon.
+ {% /card %}
+
+ {% card title="Arrow" to="#link-icons" linkIcon="arrow" %}
+ Uses the `arrow` icon.
+ {% /card %}
+
+{% /cards %}
+
+
+ See link icon example syntax
+
+ {% markdoc-example %}
+ ``` {% process=false %}
+ {% cards columns=2 cardMinWidth=180 %}
+
+ {% card title="Chevron" to="#link-icons" linkIcon="chevron" %}
+ Uses the `chevron` icon.
+ {% /card %}
+
+ {% card title="Arrow" to="#link-icons" linkIcon="arrow" %}
+ Uses the `arrow` icon.
+ {% /card %}
+
+ {% /cards %}
+ ```
+ {% /markdoc-example %}
+
+
+
+### Cards with a call to action
+
+Use the `cta` attribute to add call-to-action text under the card content.
+A chevron icon follows the text, and both change color when a reader hovers over the card.
+The call to action needs the `to` attribute, so that a click anywhere on the card opens that link.
+The `align` attribute positions the call to action together with the rest of the card content.
+
+{% cards columns=2 cardMinWidth=180 %}
+
+ {% card title="Quickstart" to="#cards-with-a-call-to-action" cta="Start building" %}
+ Publish your first project in five minutes.
+ {% /card %}
+
+ {% card title="API reference" to="#cards-with-a-call-to-action" cta="Browse the endpoints" variant="outlined" %}
+ Every endpoint, parameter, and response schema.
+ {% /card %}
+
+{% /cards %}
+
+
+ See call to action example syntax
+
+ {% markdoc-example %}
+ ``` {% process=false %}
+ {% cards columns=2 cardMinWidth=180 %}
+
+ {% card title="Quickstart" to="#cards-with-a-call-to-action" cta="Start building" %}
+ Publish your first project in five minutes.
+ {% /card %}
+
+ {% card title="API reference" to="#cards-with-a-call-to-action" cta="Browse the endpoints" variant="outlined" %}
+ Every endpoint, parameter, and response schema.
+ {% /card %}
+
+ {% /cards %}
+ ```
+ {% /markdoc-example %}
+
+
+
+### Cards with a badge
+
+Use the `badge` attribute to label a card.
+The badge displays in the card title, after the link icon.
+`badgeColor` picks a color from the badge palette, and `badgeIcon` puts an icon inside the badge.
+
+{% cards columns=3 cardMinWidth=200 %}
+
+ {% card title="Webhooks" badge="Beta" badgeColor="blue" %}
+ A color name.
+ {% /card %}
+
+ {% card title="Legacy tokens" badge="Deprecated" badgeColor="carrot" badgeIcon="triangle-exclamation" %}
+ A color name and an icon.
+ {% /card %}
+
+ {% card title="Scorecards" badge="New" badgeColor="green" %}
+ A color name.
+ {% /card %}
+
+{% /cards %}
+
+
+ See badge example syntax
+
+ {% markdoc-example %}
+ ``` {% process=false %}
+ {% cards columns=3 cardMinWidth=200 %}
+
+ {% card title="Webhooks" badge="Beta" badgeColor="blue" %}
+ A color name.
+ {% /card %}
+
+ {% card title="Legacy tokens" badge="Deprecated" badgeColor="carrot" badgeIcon="triangle-exclamation" %}
+ A color name and an icon.
+ {% /card %}
+
+ {% card title="Scorecards" badge="New" badgeColor="green" %}
+ A color name.
+ {% /card %}
+
+ {% /cards %}
+ ```
+ {% /markdoc-example %}
+
+
+
### Icon cards
Use the `icon` attribute to add an icon to a card and choose its styles with `iconVariant`.
@@ -336,6 +530,62 @@ Use the `icon` attribute to add an icon to a card and choose its styles with `ic
+#### Icon colors
+
+Use the `iconColor` attribute to color the icon with a name from the palette.
+
+{% cards columns=4 cardMinWidth=180 %}
+
+ {% card title="Red icon" icon="./images/cards-markdoc/ghost-icon.svg" iconColor="red" variant="outlined" %}
+ Uses `iconColor="red"`.
+ {% /card %}
+
+ {% card title="Sky icon" icon="./images/cards-markdoc/ghost-icon.svg" iconColor="sky" variant="outlined" %}
+ Uses `iconColor="sky"`.
+ {% /card %}
+
+ {% card title="Grass icon" icon="./images/cards-markdoc/ghost-icon.svg" iconColor="grass" variant="outlined" %}
+ Uses `iconColor="grass"`.
+ {% /card %}
+
+ {% card title="Theme icon" icon="./images/cards-markdoc/ghost-icon.svg" variant="outlined" %}
+ No `iconColor`, so the icon keeps the theme color.
+ {% /card %}
+
+{% /cards %}
+
+An icon from a file takes the color only where the file sets no color of its own.
+An icon that hardcodes a `fill` keeps the colors of the file.
+
+
+ See icon color example syntax
+
+ {% markdoc-example %}
+ ``` {% process=false %}
+ {% cards columns=4 cardMinWidth=180 %}
+
+ {% card title="Red icon" icon="images/ghost-icon.svg" iconColor="red" variant="outlined" %}
+ Uses `iconColor="red"`.
+ {% /card %}
+
+ {% card title="Sky icon" icon="images/ghost-icon.svg" iconColor="sky" variant="outlined" %}
+ Uses `iconColor="sky"`.
+ {% /card %}
+
+ {% card title="Grass icon" icon="images/ghost-icon.svg" iconColor="grass" variant="outlined" %}
+ Uses `iconColor="grass"`.
+ {% /card %}
+
+ {% card title="Theme icon" icon="images/ghost-icon.svg" variant="outlined" %}
+ No `iconColor`, so the icon keeps the theme color.
+ {% /card %}
+
+ {% /cards %}
+ ```
+ {% /markdoc-example %}
+
+
+
#### Icon card positioning
Use the `layout` and `align` attributes to control the positioning of elements in your icon card.
@@ -450,6 +700,39 @@ Use the `layout` and `align` attributes to control the positioning of elements i
+### Custom colors
+
+The palette covers the built-in names.
+To use a color of your own, name it and define it in your [theme stylesheet](../../branding/customize-styles.md), then use that name in both attributes.
+
+Define a `.tag-{name}` class for the badge, and a `.card-icon-color-{name}` class for the icon:
+
+```css {% title="@theme/styles.css" %}
+.tag-mycolor {
+ --tag-bg-color: #DCE8FF;
+ --tag-color: #1A3A6B;
+}
+
+.card-icon-color-mycolor {
+ --card-icon-color: #1A3A6B;
+}
+```
+
+The badge takes a pair of colors, because it needs a background and a text color that stays readable on it.
+The icon takes one color.
+
+Use the name the same way as a built-in one:
+
+{% markdoc-example %}
+ ```markdoc {% process=false %}
+ {% card title="Webhooks" icon="images/ghost-icon.svg" iconColor="mycolor" badge="Beta" badgeColor="mycolor" %}
+ Uses a custom color.
+ {% /card %}
+ ```
+{% /markdoc-example %}
+
+To set different colors for dark mode, define the same classes again under `:root.dark`.
+
### Image cards
Add an image to a card using the `image` attribute.
From 11f74bcea0b6e5f9ed13f16f0169b595779c2bc1 Mon Sep 17 00:00:00 2001
From: Oleksii Horbachevskyi
Date: Wed, 26 Aug 2026 10:21:03 +0000
Subject: [PATCH 2/2] Add `demo` custom tag
---
@theme/markdoc/components.tsx | 1 +
.../components/ColorControl/ColorControl.tsx | 106 ++
.../components/Demo/AttributeControl.tsx | 242 ++++
.../markdoc/components/Demo/Demo-markdoc.ts | 41 +
@theme/markdoc/components/Demo/Demo.tsx | 313 +++++
.../components/Demo/MarkdownContent.tsx | 76 ++
@theme/markdoc/components/Demo/properties.ts | 247 ++++
@theme/markdoc/components/Demo/tag-source.ts | 40 +
.../components/RadioGroup/RadioGroup.tsx | 120 ++
@theme/markdoc/schema.ts | 3 +
docs/realm/content/markdoc-tags/cards.md | 1104 ++++++-----------
package.json | 6 +-
12 files changed, 1588 insertions(+), 711 deletions(-)
create mode 100644 @theme/markdoc/components/ColorControl/ColorControl.tsx
create mode 100644 @theme/markdoc/components/Demo/AttributeControl.tsx
create mode 100644 @theme/markdoc/components/Demo/Demo-markdoc.ts
create mode 100644 @theme/markdoc/components/Demo/Demo.tsx
create mode 100644 @theme/markdoc/components/Demo/MarkdownContent.tsx
create mode 100644 @theme/markdoc/components/Demo/properties.ts
create mode 100644 @theme/markdoc/components/Demo/tag-source.ts
create mode 100644 @theme/markdoc/components/RadioGroup/RadioGroup.tsx
diff --git a/@theme/markdoc/components.tsx b/@theme/markdoc/components.tsx
index f3a804f4c..4964fc142 100644
--- a/@theme/markdoc/components.tsx
+++ b/@theme/markdoc/components.tsx
@@ -7,4 +7,5 @@ export { Quiz } from '@redocly/marketing-pages/components/Quiz/Quiz.js';
export { TagBadge } from './components/TagBadge/TagBadge';
export { SplitView, LeftView, RightView } from './components/SplitView/SplitView';
export { GroupElements } from './components/GroupElements/GroupElements';
+export { Demo } from './components/Demo/Demo';
export * from '../../docs/realm/@theme/markdoc/components';
\ No newline at end of file
diff --git a/@theme/markdoc/components/ColorControl/ColorControl.tsx b/@theme/markdoc/components/ColorControl/ColorControl.tsx
new file mode 100644
index 000000000..c457b1da2
--- /dev/null
+++ b/@theme/markdoc/components/ColorControl/ColorControl.tsx
@@ -0,0 +1,106 @@
+import React, { useId } from 'react';
+import styled from 'styled-components';
+
+import { CheckmarkIcon } from '@redocly/theme/icons/CheckmarkIcon/CheckmarkIcon';
+
+export type ColorControlProps = {
+ options: readonly string[];
+ value?: string;
+ onChange: (value: string) => void;
+ /** Groups the inputs. Generated when the caller passes nothing. */
+ name?: string;
+ ariaLabel?: string;
+ className?: string;
+};
+
+/**
+ * Picks one of the palette color names. Each swatch carries the theme's own
+ * `.tag-{name}` class, so it takes the color from `--tag-color` and also covers
+ * the custom names a project defines in its stylesheet.
+ */
+export function ColorControl({
+ options,
+ value,
+ onChange,
+ name,
+ ariaLabel,
+ className,
+}: ColorControlProps) {
+ const generatedName = useId();
+
+ return (
+
+ {options.map((option) => (
+
+ onChange(option)}
+ />
+
+ {value === option && (
+
+
+
+ )}
+
+
+ ))}
+
+ );
+}
+
+const ColorControlWrapper = styled.div`
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-xxs);
+ min-height: var(--demo-control-height, 32px);
+`;
+
+/* Shares the row with the other swatches, so a long palette never wraps. */
+const Swatch = styled.label`
+ position: relative;
+ display: flex;
+ flex: 1 1 auto;
+ min-width: 0;
+ max-width: 20px;
+ cursor: pointer;
+`;
+
+/* Kept in the layout so it stays focusable with the keyboard. */
+const Radio = styled.input`
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ opacity: 0;
+ pointer-events: none;
+`;
+
+const SwatchFill = styled.span`
+ position: relative;
+ display: block;
+ width: 100%;
+ aspect-ratio: 1;
+ border-radius: 50%;
+ background: var(--tag-color, var(--text-color-secondary));
+
+ ${Radio}:focus-visible + & {
+ outline: 2px solid var(--color-primary-base);
+ outline-offset: 2px;
+ }
+`;
+
+const CheckMark = styled.span`
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+`;
diff --git a/@theme/markdoc/components/Demo/AttributeControl.tsx b/@theme/markdoc/components/Demo/AttributeControl.tsx
new file mode 100644
index 000000000..d1dd3149a
--- /dev/null
+++ b/@theme/markdoc/components/Demo/AttributeControl.tsx
@@ -0,0 +1,242 @@
+import React, { useId, useMemo } from 'react';
+import styled from 'styled-components';
+
+import { Select } from '@redocly/theme/components/Select/Select';
+import { Switch } from '@redocly/theme/components/Switch/Switch';
+import { Tooltip } from '@redocly/theme/components/Tooltip/Tooltip';
+import { InformationIcon } from '@redocly/theme/icons/InformationIcon/InformationIcon';
+
+import { ColorControl } from '../ColorControl/ColorControl';
+import { RadioGroup } from '../RadioGroup/RadioGroup';
+import { getControlKind, getEnumOptions } from './properties';
+
+import type { AttributeControlKind, AttributeDescriptor, AttributeValue } from './properties';
+
+export type AttributeControlProps = {
+ name: string;
+ /** Shown instead of the name, exactly as given. */
+ label?: string;
+ descriptor: AttributeDescriptor;
+ value: AttributeValue;
+ onChange: (value: AttributeValue) => void;
+ className?: string;
+};
+
+export function AttributeControl({
+ name,
+ label: labelText,
+ descriptor,
+ value,
+ onChange,
+ className,
+}: AttributeControlProps) {
+ const controlId = useId();
+ const kind = getControlKind(descriptor);
+ // Select and ColorControl run effects on every new options identity, so keep the array stable.
+ const options = useMemo(() => getEnumOptions(descriptor), [descriptor]);
+ const selectOptions = useMemo(
+ () =>
+ options?.map((option) => ({
+ value: option,
+ label: String(option),
+ element: String(option),
+ })),
+ [options],
+ );
+ const colorOptions = useMemo(() => options?.map((option) => String(option)), [options]);
+
+ const label = (
+
+
+ {kind === 'color' && value !== undefined && {formatLabel(String(value))}}
+ {descriptor.description && (
+
+
+
+
+
+ )}
+
+ );
+
+ // A switch keeps its label on one row, with the control at the end.
+ if (kind === 'switch') {
+ return (
+
+ {label}
+
+
+ );
+ }
+
+ return (
+
+ {label}
+ {kind === 'color' && colorOptions ? (
+
+ ) : kind === 'radio' && selectOptions ? (
+
+ ) : kind === 'select' && selectOptions ? (
+ onChange(next as AttributeValue)}
+ />
+ ) : kind === 'textarea' ? (
+
+ );
+}
+
+/** Turns a name such as "badgeColor" or "persian-green" into "Badge color" / "Persian green". */
+function formatLabel(name: string): string {
+ const words = name
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
+ .replace(/[-_]+/g, ' ')
+ .toLowerCase();
+
+ return words.charAt(0).toUpperCase() + words.slice(1);
+}
+
+function readInputValue(raw: string, kind: AttributeControlKind): AttributeValue {
+ if (kind !== 'number') {
+ return raw;
+ }
+
+ return raw === '' ? undefined : Number(raw);
+}
+
+const AttributeControlWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-xxs);
+ min-width: 0;
+`;
+
+const SwitchFieldWrapper = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--spacing-sm);
+ min-width: 0;
+`;
+
+const LabelRow = styled.div`
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+ min-height: 22px;
+`;
+
+const Label = styled.label`
+ color: var(--text-color-secondary);
+ font-family: var(--font-family-base);
+ font-size: var(--font-size-base);
+ font-weight: var(--font-weight-medium);
+ line-height: var(--line-height-base);
+`;
+
+const ValueName = styled.span`
+ color: var(--text-color-helper);
+ font-family: var(--font-family-base);
+ font-size: var(--font-size-base);
+ font-weight: var(--font-weight-regular);
+ line-height: var(--line-height-base);
+`;
+
+const Required = styled.span`
+ color: var(--color-red-6, #d64545);
+ margin-left: 2px;
+`;
+
+const DescriptionTrigger = styled.button`
+ display: flex;
+ align-items: center;
+ padding: 0;
+ border: none;
+ background: none;
+ cursor: help;
+
+ &:focus-visible {
+ outline: 2px solid var(--color-primary-base);
+ outline-offset: 2px;
+ border-radius: var(--border-radius);
+ }
+`;
+
+const controlTypography = `
+ color: var(--text-color-primary);
+ font-family: var(--font-family-base);
+ font-size: var(--font-size-base);
+ font-weight: var(--font-weight-regular);
+ line-height: var(--line-height-base);
+`;
+
+const controlSurface = `
+ border: 1px solid var(--input-border-color);
+ border-radius: var(--border-radius-lg);
+ background: var(--input-bg-color);
+
+ &:hover {
+ border-color: var(--color-warm-grey-4);
+ }
+
+ &:focus {
+ border-color: var(--color-primary-base);
+ outline: none;
+ }
+`;
+
+const FullWidthSelect = styled(Select)`
+ width: 100%;
+ min-height: var(--demo-control-height);
+`;
+
+const Input = styled.input`
+ ${controlTypography}
+ ${controlSurface}
+ width: 100%;
+ min-height: var(--demo-control-height);
+ padding: 0 var(--spacing-sm);
+`;
+
+const TextArea = styled.textarea`
+ ${controlTypography}
+ ${controlSurface}
+ width: 100%;
+ padding: var(--spacing-xs) var(--spacing-sm);
+ font-family: var(--font-family-monospaced);
+ font-size: var(--font-size-sm);
+ line-height: var(--line-height-sm);
+ resize: vertical;
+`;
diff --git a/@theme/markdoc/components/Demo/Demo-markdoc.ts b/@theme/markdoc/components/Demo/Demo-markdoc.ts
new file mode 100644
index 000000000..c885b01b4
--- /dev/null
+++ b/@theme/markdoc/components/Demo/Demo-markdoc.ts
@@ -0,0 +1,41 @@
+import markdoc from '@markdoc/markdoc';
+
+import type { Config, Node } from '@markdoc/markdoc';
+import type { MarkdocTagSchema } from '@redocly/theme/markdoc/tags/types';
+
+export const DemoTag: MarkdocTagSchema = {
+ render: 'Demo',
+ attributes: {
+ tag: {
+ type: String,
+ required: true,
+ description: 'Name of the built-in Markdoc tag to demonstrate, such as "admonition".',
+ },
+ properties: {
+ type: Object,
+ description:
+ 'Attribute descriptors of the demonstrated tag, either as a flat map or as "groups", "content", and "attributes". Each descriptor holds a "type", and optionally "default", "required", "description", "enum", and "group".',
+ },
+ layout: {
+ type: String,
+ default: 'horizontal',
+ matches: ['horizontal', 'vertical'],
+ description:
+ 'Places the form beside the preview ("horizontal", the default) or under it ("vertical").',
+ },
+ },
+ transform(node: Node, config: Config) {
+ const attributes = node.transformAttributes(config);
+ // The body doubles as text, so the form can edit it and the snippet can show it.
+ const body = node.children
+ .map((child) => markdoc.format(child))
+ .join('\n')
+ .trim();
+
+ return new markdoc.Tag(
+ 'Demo',
+ { ...attributes, initialChildren: body },
+ node.transformChildren(config),
+ );
+ },
+};
diff --git a/@theme/markdoc/components/Demo/Demo.tsx b/@theme/markdoc/components/Demo/Demo.tsx
new file mode 100644
index 000000000..e441b5937
--- /dev/null
+++ b/@theme/markdoc/components/Demo/Demo.tsx
@@ -0,0 +1,313 @@
+import React, { useMemo, useState } from 'react';
+import styled, { css } from 'styled-components';
+
+import { Button } from '@redocly/theme/components/Button/Button';
+import { CodeBlock } from '@redocly/theme/components/CodeBlock/CodeBlock';
+import { CodeIcon } from '@redocly/theme/icons/CodeIcon/CodeIcon';
+
+import {
+ components as builtInComponents,
+ tags as builtInTags,
+} from '@redocly/theme/markdoc/default';
+
+import { AttributeControl } from './AttributeControl';
+import { MarkdownContent } from './MarkdownContent';
+import {
+ getActiveValues,
+ getInsertIndex,
+ getInitialGroupState,
+ getInitialValues,
+ parseProperties,
+ toTagProps,
+} from './properties';
+import { buildTagSource } from './tag-source';
+
+import type { AttributeDescriptor, AttributeValue } from './properties';
+
+export type DemoProps = {
+ tag: string;
+ properties?: unknown;
+ /** "horizontal" puts the form beside the preview, "vertical" under it. */
+ layout?: 'horizontal' | 'vertical';
+ initialChildren?: string;
+ children?: React.ReactNode;
+ className?: string;
+};
+
+/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
+type AnyComponent = React.ComponentType;
+
+export function Demo({
+ tag,
+ properties,
+ layout = 'horizontal',
+ initialChildren,
+ children,
+ className,
+ }: DemoProps) {
+ const isStacked = layout === 'vertical';
+ const { groups, descriptors, content, separators } = useMemo(
+ () => parseProperties(properties),
+ [properties],
+ );
+ const [values, setValues] = useState>(() =>
+ getInitialValues(descriptors),
+ );
+ const [groupState, setGroupState] = useState>(() =>
+ getInitialGroupState(groups),
+ );
+ const [childrenText, setChildrenText] = useState(initialChildren ?? '');
+ const [isCodeVisible, setIsCodeVisible] = useState(false);
+
+ const schema = builtInTags[tag];
+ const renderName = typeof schema?.render === 'string' ? schema.render : undefined;
+ const Component = renderName
+ ? (builtInComponents as unknown as Record)[renderName]
+ : undefined;
+
+ if (!Component) {
+ return (
+
+
+ Unknown Markdoc tag {tag}. Use one of the built-in tags, such as{' '}
+ admonition or accordion.
+
+
+ );
+ }
+
+ const tagProps = toTagProps(getActiveValues(values, descriptors, groupState));
+ const controls = Object.entries(descriptors);
+ const groupNames = new Set(groups.map((group) => group.name));
+ // A hidden attribute keeps its value, so it leaves the form but not the tag.
+ const shownControls = controls.filter(([, descriptor]) => !descriptor.hidden);
+ // Loose attributes come first, then a section for each group, as in the design.
+ const looseControls = shownControls.filter(
+ ([, descriptor]) => !descriptor.group || !groupNames.has(descriptor.group),
+ );
+ const groupSections = groups
+ .map((group) => ({
+ group,
+ fields: shownControls.filter(([, descriptor]) => descriptor.group === group.name),
+ }))
+ // Keep a group that only hosts the content field.
+ .filter(({ group, fields }) => fields.length > 0 || content?.group === group.name);
+ // The body only gets a form field when the properties describe "content".
+ const editsChildren = content !== undefined;
+ const source = buildTagSource({
+ tag,
+ attributes: tagProps,
+ body: editsChildren ? childrenText : initialChildren,
+ selfClosing: schema.selfClosing,
+ });
+ const contentField = content && (
+ setChildrenText(value === undefined ? '' : String(value))}
+ />
+ );
+
+ /** Lays out the fields of one scope, with the content field and any lines in place. */
+ function renderFields(entries: [string, AttributeDescriptor][], groupName?: string) {
+ const names = entries.map(([name]) => name);
+ const contentIndex =
+ contentField && content?.group === groupName
+ ? getInsertIndex(names, content.location)
+ : -1;
+ // A set, so two lines asking for the same place never stack.
+ const lineIndexes = new Set(
+ separators
+ .filter((separator) => separator.group === groupName)
+ .map((separator) => getInsertIndex(names, separator.location)),
+ );
+
+ const nodes: React.ReactNode[] = [];
+
+ for (let index = 0; index <= entries.length; index++) {
+ if (contentIndex === index) {
+ nodes.push(contentField);
+ }
+
+ // Skipped while nothing precedes it, so no line starts the scope.
+ if (lineIndexes.has(index) && nodes.length > 0) {
+ nodes.push();
+ }
+
+ if (index < entries.length) {
+ const [name, descriptor] = entries[index];
+
+ nodes.push(
+ setValues((current) => ({ ...current, [name]: value }))}
+ />,
+ );
+ }
+ }
+
+ return nodes;
+ }
+
+ return (
+
+ {isCodeVisible && (
+
+ )}
+
+
+ }
+ aria-expanded={isCodeVisible}
+ onClick={() => setIsCodeVisible((visible) => !visible)}
+ >
+ {isCodeVisible ? 'Hide code' : 'Show code' }
+
+
+ {schema.selfClosing ? (
+
+ ) : (
+
+ {editsChildren ? : children}
+
+ )}
+
+
+ {(shownControls.length > 0 || editsChildren) && (
+
+ {renderFields(looseControls)}
+ {groupSections.map(({ group, fields }) => (
+
+
+ setGroupState((current) => ({ ...current, [group.name]: Boolean(value) }))
+ }
+ />
+ {groupState[group.name] && (
+ {renderFields(fields, group.name)}
+ )}
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+const DemoWrapper = styled.div`
+ --demo-control-height: 32px;
+
+ container: demo / inline-size;
+ margin: var(--spacing-md) 0;
+`;
+
+const Layout = styled.div<{ $stacked: boolean }>`
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-lg);
+
+ /* The docs content column has a capped width, so the panel rather than the
+ viewport decides whether the form fits beside the preview. */
+ ${({ $stacked }) =>
+ !$stacked &&
+ css`
+ @container demo (min-width: 640px) {
+ flex-direction: row;
+ align-items: flex-start;
+ }
+ `}
+`;
+
+const PreviewColumn = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--spacing-sm);
+ flex: 1 1 auto;
+ min-width: 0;
+
+ > *:not(button) {
+ width: 100%;
+ }
+`;
+
+const Preview = styled.div`
+ display: grid;
+ > *:first-child {
+ margin-top: 0;
+ }
+
+ > *:last-child {
+ margin-bottom: 0;
+ }
+`;
+
+const FieldSeparator = styled.div`
+ border-top: 1px solid var(--border-color-secondary);
+ margin: var(--spacing-sm) 0 var(--spacing-xs);
+`;
+
+const GroupSection = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+
+ &:not(:last-child) {
+ /* Separates the section from what comes before it, as the design's divider does. */
+ padding-bottom: var(--spacing-sm);
+ border-bottom: 1px solid var(--border-color-secondary);
+ }
+`;
+
+const GroupPanel = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--spacing-unit) * 4);
+ padding: calc(var(--spacing-unit) * 3.5);
+ border-radius: var(--border-radius-lg);
+ background-color: var(--layer-color);
+`;
+
+const Controls = styled.div<{ $stacked: boolean }>`
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-sm);
+ width: 100%;
+ padding: var(--spacing-lg);
+ border: 1px solid var(--border-color-secondary);
+ border-radius: calc(var(--border-radius) * 6); // var(--card-border-radius);
+ background-color: var(--bg-color);
+
+ /* Stacked keeps the panel full width, so it never sits in a 360px column. */
+ ${({ $stacked }) =>
+ !$stacked &&
+ css`
+ @container demo (min-width: 640px) {
+ flex: 0 0 360px;
+ width: 360px;
+ margin-top: var(--spacing-xl);
+ }
+ `}
+`;
+
+const Error = styled.p`
+ color: var(--color-red-6, #d64545);
+ margin: 0;
+`;
diff --git a/@theme/markdoc/components/Demo/MarkdownContent.tsx b/@theme/markdoc/components/Demo/MarkdownContent.tsx
new file mode 100644
index 000000000..b3979b349
--- /dev/null
+++ b/@theme/markdoc/components/Demo/MarkdownContent.tsx
@@ -0,0 +1,76 @@
+import React, { useMemo } from 'react';
+import markdoc from '@markdoc/markdoc';
+
+import { Markdown } from '@redocly/theme/components/Markdown/Markdown';
+import {
+ components as builtInComponents,
+ tags as builtInTags,
+} from '@redocly/theme/markdoc/default';
+
+import type { Config, Node, RenderableTreeNode } from '@markdoc/markdoc';
+
+export type MarkdownContentProps = {
+ source: string;
+ className?: string;
+};
+
+/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
+type AnyComponent = React.ComponentType;
+
+const componentMap = builtInComponents as unknown as Record;
+
+function PassThrough({ children }: React.PropsWithChildren) {
+ return <>{children}>;
+}
+
+/**
+ * Markdoc passes the resolved value straight to createElement, so a tag with no
+ * component must fall back to one instead of to undefined.
+ */
+function resolveComponent(name: string): AnyComponent {
+ return componentMap[name] ?? PassThrough;
+}
+
+/** Markdoc renders a capitalized name as a component, and a lowercase one as an HTML element. */
+function isComponentTag(node: RenderableTreeNode): boolean {
+ return (
+ markdoc.Tag.isTag(node) && typeof node.name === 'string' && node.name[0] === node.name[0].toUpperCase()
+ );
+}
+
+const nodes = {
+ // The default document node wraps the output in an element.
+ document: {},
+ paragraph: {
+ // A component inside a
renders a block element, which the browser moves out
+ // of the paragraph. React then rewrites the tree on every keystroke and throws
+ // "Maximum update depth exceeded", so drop the
around such content.
+ transform(node: Node, config: Config) {
+ const children = node.transformChildren(config);
+
+ return children.some(isComponentTag) ? children : new markdoc.Tag('p', {}, children);
+ },
+ },
+};
+
+export function MarkdownContent({ source, className }: MarkdownContentProps) {
+ const rendered = useMemo(() => {
+ try {
+ const content = markdoc.transform(markdoc.parse(source), {
+ tags: builtInTags,
+ nodes,
+ }) as RenderableTreeNode;
+
+ return markdoc.renderers.react(content, React, { components: resolveComponent });
+ } catch {
+ // Keep the text readable while it is half-typed.
+ return source;
+ }
+ }, [source]);
+
+ return (
+
+ {rendered}
+
+ );
+}
diff --git a/@theme/markdoc/components/Demo/properties.ts b/@theme/markdoc/components/Demo/properties.ts
new file mode 100644
index 000000000..6a6217859
--- /dev/null
+++ b/@theme/markdoc/components/Demo/properties.ts
@@ -0,0 +1,247 @@
+export type AttributeValue = string | number | boolean | undefined;
+
+export type AttributeDescriptor = {
+ type?: string;
+ required?: boolean;
+ default?: AttributeValue;
+ enum?: AttributeValue[];
+ matches?: unknown;
+ description?: string;
+ /** Name of the group whose toggle shows or hides this attribute. */
+ group?: string;
+ /** Keeps the attribute off the form while its value still reaches the tag. */
+ hidden?: boolean;
+};
+
+export type AttributeDescriptors = Record;
+
+/** A toggle that shows or hides the attributes assigned to it. */
+export type PropertyGroup = {
+ name: string;
+ label?: string;
+ enabled?: boolean;
+};
+
+/** Describes the body of the demonstrated tag rather than one of its attributes. */
+export type ContentDescriptor = {
+ description?: string;
+ required?: boolean;
+ /** Name of the group whose panel holds the field. */
+ group?: string;
+ /** "start", "end", or the name of the attribute the field follows. */
+ location?: string;
+};
+
+/** A horizontal line between form fields. */
+export type SeparatorDescriptor = {
+ /** Name of the group whose panel holds the line. */
+ group?: string;
+ /** "start", "end", or the name of the attribute the line follows. */
+ location?: string;
+};
+
+export type ParsedProperties = {
+ groups: PropertyGroup[];
+ descriptors: AttributeDescriptors;
+ content?: ContentDescriptor;
+ separators: SeparatorDescriptor[];
+};
+
+export type AttributeControlKind =
+ | 'color'
+ | 'radio'
+ | 'select'
+ | 'switch'
+ | 'number'
+ | 'textarea'
+ | 'text';
+
+/**
+ * Reads the `properties` attribute of the demo tag.
+ * Markdoc passes an object literal or a `$frontmatter` reference as an object,
+ * so a string arrives only when the author writes plain JSON.
+ *
+ * Accepts a flat map of descriptors, or `{groups, content, attributes}` when the
+ * form needs toggles or an editable body. An `attributes` object marks the second shape.
+ */
+export function parseProperties(properties: unknown): ParsedProperties {
+ const source = typeof properties === 'string' ? safeParseJson(properties) : properties;
+
+ if (!isPlainObject(source)) {
+ return { groups: [], descriptors: {}, separators: [] };
+ }
+
+ if (isPlainObject(source.attributes)) {
+ return {
+ groups: parseGroups(source.groups),
+ content: parseContent(source.content),
+ separators: parseSeparators(source.separators),
+ descriptors: toDescriptors(source.attributes),
+ };
+ }
+
+ return { groups: [], descriptors: toDescriptors(source), separators: [] };
+}
+
+function parseSeparators(separators: unknown): SeparatorDescriptor[] {
+ if (!Array.isArray(separators)) {
+ return [];
+ }
+
+ return separators.filter(isPlainObject).map((separator) => ({
+ group: typeof separator.group === 'string' ? separator.group : undefined,
+ location: typeof separator.location === 'string' ? separator.location : undefined,
+ }));
+}
+
+function parseContent(content: unknown): ContentDescriptor | undefined {
+ if (!isPlainObject(content)) {
+ return undefined;
+ }
+
+ return {
+ description: typeof content.description === 'string' ? content.description : undefined,
+ required: content.required === true,
+ group: typeof content.group === 'string' ? content.group : undefined,
+ location: typeof content.location === 'string' ? content.location : undefined,
+ };
+}
+
+/**
+ * Index a field or a line takes among the given attribute names.
+ * "start" and an unknown name both put it first, "end" puts it last, and any
+ * other name puts it straight after that attribute.
+ */
+export function getInsertIndex(names: string[], location?: string): number {
+ if (location === 'end') {
+ return names.length;
+ }
+
+ if (!location || location === 'start') {
+ return 0;
+ }
+
+ const index = names.indexOf(location);
+
+ return index === -1 ? 0 : index + 1;
+}
+
+/** "children" names the tag body, so it never travels with the attributes. */
+function toDescriptors(source: Record): AttributeDescriptors {
+ return Object.fromEntries(
+ Object.entries(source).filter(([, descriptor]) => isPlainObject(descriptor)),
+ ) as AttributeDescriptors;
+}
+
+function parseGroups(groups: unknown): PropertyGroup[] {
+ if (!Array.isArray(groups)) {
+ return [];
+ }
+
+ return groups
+ .filter((group): group is Record => isPlainObject(group))
+ .filter((group) => typeof group.name === 'string' && group.name.length > 0)
+ .map((group) => ({
+ name: group.name as string,
+ label: typeof group.label === 'string' ? group.label : undefined,
+ // A group shows its attributes unless the author opts out.
+ enabled: group.enabled !== false,
+ }));
+}
+
+/** Groups start out on unless the author sets `enabled: false`. */
+export function getInitialGroupState(groups: PropertyGroup[]): Record {
+ return Object.fromEntries(groups.map((group) => [group.name, group.enabled !== false]));
+}
+
+/**
+ * Attributes of a disabled group leave the tag entirely, so both the preview and
+ * the snippet behave as if the author never set them.
+ */
+export function getActiveValues(
+ values: Record,
+ descriptors: AttributeDescriptors,
+ groupState: Record,
+): Record {
+ return Object.fromEntries(
+ Object.entries(values).filter(([name]) => {
+ const group = descriptors[name]?.group;
+
+ // An attribute pointing at an undeclared group stays visible.
+ return !group || !(group in groupState) || groupState[group];
+ }),
+ );
+}
+
+/** Returns the allowed values of an attribute, or `undefined` when it accepts any value. */
+export function getEnumOptions(descriptor: AttributeDescriptor): AttributeValue[] | undefined {
+ const options = descriptor.enum ?? descriptor.matches;
+ return Array.isArray(options) && options.length > 0 ? (options as AttributeValue[]) : undefined;
+}
+
+/** Above this count the options go into a dropdown instead of a radio group. */
+export const MAX_RADIO_GROUP_OPTIONS = 4;
+
+
+export function getControlKind(descriptor: AttributeDescriptor): AttributeControlKind {
+ const type = String(descriptor.type).toLowerCase();
+
+ // A color always uses swatches, however many names it offers.
+ if (type === 'color') {
+ return 'color';
+ }
+
+ const options = getEnumOptions(descriptor);
+
+ if (options) {
+ return options.length <= MAX_RADIO_GROUP_OPTIONS ? 'radio' : 'select';
+ }
+
+ switch (type) {
+ case 'boolean':
+ return 'switch';
+ case 'number':
+ return 'number';
+ case 'text':
+ return 'textarea';
+ default:
+ return 'text';
+ }
+}
+
+export function getInitialValues(descriptors: AttributeDescriptors): Record {
+ return Object.fromEntries(
+ Object.entries(descriptors).map(([name, descriptor]) => [name, getInitialValue(descriptor)]),
+ );
+}
+
+function getInitialValue(descriptor: AttributeDescriptor): AttributeValue {
+ if (descriptor.default !== undefined) {
+ return descriptor.default;
+ }
+
+ return getControlKind(descriptor) === 'switch' ? false : undefined;
+}
+
+/**
+ * Drops unset attributes so the demonstrated tag falls back to its own defaults
+ * instead of receiving an empty value.
+ */
+export function toTagProps(values: Record): Record {
+ return Object.fromEntries(
+ Object.entries(values).filter(([, value]) => value !== undefined && value !== ''),
+ );
+}
+
+function safeParseJson(value: string): unknown {
+ try {
+ return JSON.parse(value);
+ } catch {
+ return undefined;
+ }
+}
+
+function isPlainObject(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
diff --git a/@theme/markdoc/components/Demo/tag-source.ts b/@theme/markdoc/components/Demo/tag-source.ts
new file mode 100644
index 000000000..d9c8b2ea9
--- /dev/null
+++ b/@theme/markdoc/components/Demo/tag-source.ts
@@ -0,0 +1,40 @@
+import type { AttributeValue } from './properties';
+
+/** Wrap the attributes onto their own lines past this width. */
+const MAX_OPEN_TAG_LENGTH = 72;
+
+export type TagSourceInput = {
+ tag: string;
+ attributes: Record;
+ body?: string;
+ selfClosing?: boolean;
+};
+
+/** Builds the Markdoc source that reproduces the current preview. */
+export function buildTagSource({ tag, attributes, body, selfClosing }: TagSourceInput): string {
+ const pairs = Object.entries(attributes).map(([name, value]) => `${name}=${formatValue(value)}`);
+ const closing = selfClosing ? '/%}' : '%}';
+ const singleLine = [`{%`, tag, ...pairs, closing].join(' ');
+
+ const wrapped = pairs.map(
+ (pair, index) => ` ${pair}${index === pairs.length - 1 ? ` ${closing}` : ''}`,
+ );
+ const openTag =
+ singleLine.length <= MAX_OPEN_TAG_LENGTH
+ ? singleLine
+ : [`{% ${tag}`, ...wrapped].join('\n');
+
+ if (selfClosing) {
+ return openTag;
+ }
+
+ return [openTag, body?.length ? body : '...', `{% /${tag} %}`].join('\n');
+}
+
+function formatValue(value: AttributeValue): string {
+ if (typeof value !== 'string') {
+ return String(value);
+ }
+
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
+}
diff --git a/@theme/markdoc/components/RadioGroup/RadioGroup.tsx b/@theme/markdoc/components/RadioGroup/RadioGroup.tsx
new file mode 100644
index 000000000..64766ef6f
--- /dev/null
+++ b/@theme/markdoc/components/RadioGroup/RadioGroup.tsx
@@ -0,0 +1,120 @@
+import React, { useId } from 'react';
+import styled from 'styled-components';
+
+export type RadioGroupOption = {
+ value: T;
+ label: string;
+};
+
+export type RadioGroupProps = {
+ options: readonly RadioGroupOption[];
+ value?: T;
+ onChange: (value: T) => void;
+ /** Groups the inputs. Generated when the caller passes nothing. */
+ name?: string;
+ ariaLabel?: string;
+ /** Fills the available width and gives every option an equal share of it. */
+ stretch?: boolean;
+ className?: string;
+};
+
+export function RadioGroup({
+ options,
+ value,
+ onChange,
+ name,
+ ariaLabel,
+ stretch = false,
+ className,
+}: RadioGroupProps) {
+ const generatedName = useId();
+
+ return (
+
+ {options.map((option) => (
+
+ ))}
+
+ );
+}
+
+const RadioGroupWrapper = styled.div<{ $stretch: boolean }>`
+ display: ${({ $stretch }) => ($stretch ? 'flex' : 'inline-flex')};
+ width: ${({ $stretch }) => ($stretch ? '100%' : 'auto')};
+ /* The control sits in a stretching column, so keep it at the width of its options. */
+ align-self: flex-start;
+ max-width: 100%;
+ border: 1px solid var(--border-color-primary);
+ border-radius: var(--border-radius-md);
+ /* Scrolls rather than squeezing the labels when the row is too narrow. */
+ overflow-x: auto;
+ overflow-y: hidden;
+`;
+
+const Option = styled.label<{ $stretch: boolean }>`
+ position: relative;
+ display: flex;
+ /* Share the row evenly when stretched, and never shrink otherwise so a label is never cut off. */
+ flex: ${({ $stretch }) => ($stretch ? '1 1 0' : '0 0 auto')};
+ min-width: 0;
+
+ & + & {
+ border-left: 1px solid var(--border-color-primary);
+ }
+`;
+
+/* Kept in the layout so it stays focusable with the keyboard. */
+const Radio = styled.input`
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ opacity: 0;
+ pointer-events: none;
+`;
+
+const Segment = styled.span`
+ --color-primary-main: var(--color-info-base);
+
+ flex: 1 1 auto;
+ text-align: center;
+ padding: 5px var(--spacing-xxs);
+ background: var(--bg-color);
+ color: var(--text-color-primary);
+ font-family: var(--font-family-base);
+ font-size: var(--font-size-base);
+ font-weight: var(--font-weight-regular);
+ line-height: var(--line-height-base);
+ cursor: pointer;
+ white-space: nowrap;
+
+ ${Radio}:hover + & {
+ background: var(--color-warm-grey-2);
+ }
+
+ ${Radio}:checked + & {
+ background: var(--color-primary-main);
+ color: var(--color-primary-text-on-color);
+ font-weight: var(--font-weight-medium);
+ cursor: default;
+ }
+
+ ${Radio}:focus-visible + & {
+ outline: 2px solid var(--color-primary-main);
+ outline-offset: -2px;
+ }
+`;
diff --git a/@theme/markdoc/schema.ts b/@theme/markdoc/schema.ts
index e0903c244..a612e848d 100644
--- a/@theme/markdoc/schema.ts
+++ b/@theme/markdoc/schema.ts
@@ -10,6 +10,8 @@ import {
tags as betaDocsTags,
functions as betaDocsFunctions,
} from '../../docs/realm/@theme/markdoc/schema';
+import { DemoTag } from './components/Demo/Demo-markdoc';
+
const docsOverview: Schema = {
attributes: {
@@ -62,6 +64,7 @@ export const tags = {
licensesPackageVersion,
wideTileCards,
quiz,
+ demo: DemoTag,
...betaDocsTags,
};
diff --git a/docs/realm/content/markdoc-tags/cards.md b/docs/realm/content/markdoc-tags/cards.md
index f361a9b65..2250ed9fa 100644
--- a/docs/realm/content/markdoc-tags/cards.md
+++ b/docs/realm/content/markdoc-tags/cards.md
@@ -7,6 +7,319 @@ plans:
- Pro
- Enterprise
- Enterprise+
+variantProperties:
+ groups:
+ - name: content
+ label: Change content
+ enabled: true
+# content:
+# required: false
+# location: end
+ attributes:
+ variant:
+ type: string
+ default: elevated
+ enum: [filled, outlined, elevated, ghost]
+ description: Visual style of the card.
+ title:
+ hidden: true
+ type: string
+ required: true
+ default: Variant card
+ description: The title of the card.
+linkProperties:
+ groups:
+ - name: link
+ label: Show link
+ enabled: true
+# content:
+# required: false
+# location: end
+ separators:
+ - location: cta
+# - location: variant
+ attributes:
+ to:
+ type: string
+ default: "#cards-as-links"
+ description: URL or path for the card to link to. The whole card becomes a link.
+ linkIcon:
+ type: string
+ default: arrow
+ enum: [chevron, arrow, ""]
+ description: Icon at the end of the title. Needs the to attribute.
+ cta:
+ type: string
+ default: Start building
+ description: Call-to-action text under the content. Needs the to attribute.
+ variant:
+ type: string
+ default: elevated
+ enum: [filled, outlined, elevated, ghost]
+ description: Visual style of the card.
+ title:
+ hidden: true
+ type: string
+ required: true
+ default: Quickstart
+ description: The title of the card.
+badgeProperties:
+ groups:
+ - name: badge
+ label: Show badge
+ enabled: true
+# content:
+# required: false
+# location: end
+# separators:
+# - location: badgeColor
+ attributes:
+ badge:
+ type: string
+ default: Beta
+ description: Text of a badge that displays after the link icon.
+# group: badge
+ badgeIcon:
+ type: string
+ description: Icon inside the badge. A Font Awesome name, or a path to an icon file.
+ default: rocket
+ # group: badge
+ badgeColor:
+ type: color
+ default: blue
+ enum: [red, green, blue, grey, turquoise, magenta, purple, carrot, raspberry, orange, grass, persian-green, sky, blueberry]
+ description: Color of the badge. Defaults to grey.
+# group: badge
+ variant:
+ hidden: true
+ type: string
+ default: elevated
+ enum: [filled, outlined, elevated, ghost]
+ description: Visual style of the card.
+ title:
+ hidden: true
+ type: string
+ required: true
+ default: Webhooks
+ description: The title of the card.
+iconProperties:
+ groups:
+ - name: icon
+ label: Show icon
+ enabled: true
+# content:
+# required: false
+# location: end
+# separators:
+# - location: iconColor
+ attributes:
+ icon:
+ type: string
+ default: duotone book
+ description: A Font Awesome name, such as book or duotone book, or a path to an icon file.
+# group: icon
+ iconVariant:
+ type: string
+ default: ghost
+ enum: [ghost, filled]
+ description: Style of the icon. The filled variant adds a padded outline with rounded corners.
+# group: icon
+ iconColor:
+ type: color
+ enum: [red, green, blue, grey, turquoise, magenta, purple, carrot, raspberry, orange, grass, persian-green, sky, blueberry]
+ description: Color of the icon. Without it, the icon keeps the theme color.
+# group: icon
+ variant:
+ hidden: true
+ type: string
+ default: elevated
+ enum: [filled, outlined, elevated, ghost]
+ description: Visual style of the card.
+ title:
+ hidden: true
+ type: string
+ required: true
+ default: Icon card
+ description: The title of the card.
+layoutProperties:
+# content:
+# required: false
+# location: end
+# separators:
+# - location: lineClamp
+ attributes:
+ layout:
+ type: string
+ default: vertical
+ enum: [horizontal, vertical]
+ description: Layout of the card.
+ align:
+ type: string
+ default: start
+ enum: [start, center, end]
+ description: Alignment of the card content.
+ lineClamp:
+ type: number
+ default: 3
+ description: Limits the number of lines of content before the text is cut.
+ icon:
+ hidden: true
+ type: string
+ default: heart
+ description: An icon makes the effect of the layout easier to see.
+ variant:
+ hidden: true
+ type: string
+ default: elevated
+ enum: [filled, outlined, elevated, ghost]
+ description: Visual style of the card.
+ title:
+ hidden: true
+ type: string
+ required: true
+ default: Icon card
+ description: The title of the card.
+imageProperties:
+# content:
+# required: false
+# location: end
+# separators:
+# - location: align
+ attributes:
+ imagePosition:
+ type: string
+ default: start
+ enum: [start, end]
+ description: Position of the image in the card.
+ group: image
+ layout:
+ type: string
+ default: vertical
+ enum: [horizontal, vertical]
+ description: Layout of the card.
+ align:
+ type: string
+ default: start
+ enum: [start, center, end]
+ description: Alignment of the card content.
+ image:
+ type: string
+ default: https://redocly.com/assets/card-gradient.2ab0e831d9790df6711b087a0b876fb82f4a3450a0797a64972c736a8d8f0c05.804422cf.jpeg
+ description: Path or URL of an image to display in the card.
+ group: image
+ variant:
+ hidden: true
+ type: string
+ default: elevated
+ enum: [filled, outlined, elevated, ghost]
+ description: Visual style of the card.
+ title:
+ hidden: true
+ type: string
+ required: true
+ default: Image card
+ description: The title of the card.
+customizeProperties:
+ groups:
+ - name: link
+ label: Show link
+ enabled: false
+ - name: badge
+ label: Show badge
+ enabled: false
+ - name: image
+ label: Show image
+ enabled: false
+ - name: icon
+ label: Show icon
+ enabled: false
+ content:
+ description: Body of the card. Accepts Markdown and Markdoc tags.
+ location: title
+ attributes:
+ title:
+ type: string
+ required: true
+ default: Your card
+ description: The title of the card.
+ variant:
+ type: string
+ default: elevated
+ enum: [filled, outlined, elevated, ghost]
+ description: Visual style of the card.
+ layout:
+ type: string
+ default: vertical
+ enum: [horizontal, vertical]
+ description: Layout of the card.
+ align:
+ type: string
+ default: start
+ enum: [start, center, end]
+ description: Alignment of the card content.
+ lineClamp:
+ type: number
+ default: 3
+ description: Limits the number of lines of text displayed in the card content.
+ to:
+ type: string
+ default: "#customize-tags"
+ description: URL or path for the card to link to. The whole card becomes a link.
+ group: link
+ linkIcon:
+ type: string
+ default: chevron
+ enum: [chevron, arrow]
+ description: Icon displayed at the end of the card title.
+ group: link
+ cta:
+ type: string
+ default: Start building
+ description: Call-to-action text under the card content.
+ group: link
+ badge:
+ type: string
+ default: New
+ description: Text of a badge that displays in the card title, after the link icon.
+ group: badge
+ badgeColor:
+ type: color
+ default: blue
+ enum: [red, green, blue, grey, turquoise, magenta, purple, carrot, raspberry, orange, grass, persian-green, sky, blueberry]
+ description: Color of the badge. Defaults to grey.
+ group: badge
+ badgeIcon:
+ type: string
+ default: rocket
+ description: Icon inside the badge. A Font Awesome name, or a relative path to an icon file.
+ group: badge
+ image:
+ type: string
+ default: https://redocly.com/assets/card-gradient.2ab0e831d9790df6711b087a0b876fb82f4a3450a0797a64972c736a8d8f0c05.804422cf.jpeg
+ description: Path or URL of an image to display in the card.
+ group: image
+ imagePosition:
+ type: string
+ default: start
+ enum: [start, end]
+ description: Position of the image in the card.
+ group: image
+ icon:
+ type: string
+ default: duotone book
+ description: A Font Awesome name, such as book or duotone book, or a relative path to an icon file.
+ group: icon
+ iconVariant:
+ type: string
+ default: filled
+ enum: [ghost, filled]
+ description: Style of the icon. The filled variant adds a padded outline with rounded corners.
+ group: icon
+ iconColor:
+ type: color
+ enum: [red, green, blue, grey, turquoise, magenta, purple, carrot, raspberry, orange, grass, persian-green, sky, blueberry]
+ description: Color of the icon. Without it, the icon keeps the theme color.
+ group: icon
---
# Card and Cards tags
@@ -14,37 +327,36 @@ plans:
The `card` and `cards` tags work together to create visually appealing, structured content elements ("cards") in your documentation.
-The `card` tag allows you to create an individual card element that contains your markup.
-The `cards` tag organizes multiple cards into a responsive grid layout.
+The `card` tag allows you to create an individual card element that contains your markup, while the `cards` tag organizes multiple cards into a responsive grid layout.
## Syntax and usage
Add an opening and closing `cards` tag to wrap the card elements.
{% markdoc-example %}
- ```markdoc {% process=false %}
- {% cards %}
- {% /cards %}
- ```
+```markdoc {% process=false %}
+{% cards %}
+{% /cards %}
+```
{% /markdoc-example %}
Wrap content in a `card` and configure them using attributes.
A card only works as a child of `cards`.
{% markdoc-example %}
- ```markdoc {% process=false %}
- {% cards %}
- {% card title="First card" icon="images/first-card.svg" %}
- I'm a **card**.
- {% /card %}
- {% card title="Second card" icon="images/second-card.svg" %}
- I'm a card, _too_.
- {% /card %}
- {% card title="Third card" icon="images/third-card.svg" %}
- I'm actually a brochure.
- {% /card %}
- {% /cards %}
- ```
+```markdoc {% process=false %}
+{% cards %}
+ {% card title="First card" icon="images/first-card.svg" %}
+ I'm a **card**.
+ {% /card %}
+ {% card title="Second card" icon="images/second-card.svg" %}
+ I'm a card, _too_.
+ {% /card %}
+ {% card title="Third card" icon="images/third-card.svg" %}
+ I'm actually a brochure.
+ {% /card %}
+{% /cards %}
+```
{% /markdoc-example %}
## Attributes - Card
@@ -59,8 +371,14 @@ A card only works as a child of `cards`.
- title
- string
-- **REQUIRED.**
- The title of the card.
+- **REQUIRED.** The title of the card.
+
+---
+
+- variant
+- string
+- Visual style of the card: `filled`, `outlined`, `elevated`, or `ghost`.
+ Defaults to `filled`.
---
@@ -73,19 +391,15 @@ A card only works as a child of `cards`.
- linkIcon
- string
-- Icon displayed at the end of the card title.
- Can be `chevron` or `arrow`.
- The icon displays only on a cards that also have the `to` attribute.
- Without this attribute, the title has no icon.
+- Icon displayed at the end of the card title: `chevron` or `arrow`.
+ Displays only on cards that also set `to`.
---
- cta
- string
- Call-to-action text displayed under the card content, followed by a chevron icon.
- The text displays only on cards that also have the `to` attribute.
- Clicking anywhere on the card opens that link.
- The text and the icon change color on mouse hover.
+ Displays only on cards that also set `to`.
---
@@ -98,41 +412,16 @@ A card only works as a child of `cards`.
- badgeColor
- string
- Color of the badge.
- Supports predefined color names for consistent styling.
-
- **Supported color names:**
- `red`, `green`, `blue`, `grey`, `turquoise`, `magenta`, `purple`, `carrot`, `raspberry`, `orange`, `grass`, `persian-green`, `sky`, `blueberry`.
-
- To use a color of your own, see [Custom colors](#custom-colors).
- Default: `grey`.
+ One of `red`, `green`, `blue`, `grey`, `turquoise`, `magenta`, `purple`, `carrot`,
+ `raspberry`, `orange`, `grass`, `persian-green`, `sky`, `blueberry`, or a custom name.
+ Defaults to `grey`.
---
- badgeIcon
- string
- Icon to display inside the badge.
-- Either:
- - A [Font Awesome](https://fontawesome.com/icons) icon name.
- Realm has the following icon packs built in: Classic Regular, Classic Solid, Duotone Solid, and Classic Brands.
- The icons automatically adjust their colors when users change the color mode.
-
- To add an icon from the Classic Regular pack, you can provide the icon name only or prefix the name with `regular`.
- To add an icon from other built-in packs, prefix the icon name with: `solid` (for Classic Solid), `duotone` (for Duotone Solid), or `brands` (for Classic Brands).
-
- **Examples:** `book`, `duotone book`, `brands github`
-
- Using other prefixes, including the `fa-` prefix, causes the icon to not render.
- - Relative path to an icon image file.
-
- **Example:** `./images/config-icon.svg`
-
----
-
-- variant
-- string
-- Visual style of the card.
- Can be `filled`, `outlined`, `elevated`, or `ghost`.
- Defaults to `filled`.
+ A Font Awesome name or a relative path to an icon file.
---
@@ -144,37 +433,24 @@ A card only works as a child of `cards`.
- imagePosition
- string
-- Set position of the image in the card to `start` or `end`.
+- Position of the image in the card: `start` or `end`.
Defaults to `start`.
---
- icon
- string
-- Either:
- - A [Font Awesome](https://fontawesome.com/icons) icon name.
- Realm has the following icon packs built in: Classic Regular, Classic Solid, Duotone Solid, and Classic Brands.
- The icons automatically adjust their colors when users change the color mode.
-
- To add an icon from the Classic Regular pack, you can provide the icon name only or prefix the name with `regular`.
- To add an icon from another built-in pack, prefix the icon name with `solid` (Classic Solid), `duotone` (Duotone Solid), or `brands` (Classic Brands).
-
- **Examples:** `book`, `duotone book`, `brands github`
-
- Using other prefixes, including the `fa-` prefix, causes the icon to not render.
- - Relative path to an icon image file.
-
- **Example:** `./images/config-icon.svg`
+- A Font Awesome name, or a relative path to an icon file.
+ Prefix the name with `solid`, `duotone`, or `brands` to pick a pack.
+ The Classic Regular pack needs no prefix.
+ Other prefixes, including `fa-`, stop the icon from rendering.
---
- iconVariant
- string
-- Style of the icon.
- Can be `ghost` or `filled`:
- - `ghost` displays the icon as is
- - `filled` adds a padded outline with rounded corners to the icon
-
+- Style of the icon: `ghost` displays the icon as is, and `filled` adds a padded
+ outline with rounded corners.
Defaults to `ghost`.
---
@@ -182,39 +458,29 @@ A card only works as a child of `cards`.
- iconColor
- string
- Color of the icon.
- Supports predefined color names for consistent styling.
-
- **Supported color names:**
- `red`, `green`, `blue`, `grey`, `turquoise`, `magenta`, `purple`, `carrot`, `raspberry`, `orange`, `grass`, `persian-green`, `sky`, `blueberry`.
-
- To use a color of your own, see [Custom colors](#custom-colors).
+ Takes the same names as `badgeColor`.
---
- lineClamp
-- string
+- number
- Limits the number of lines of text displayed in the card content.
-Once exceeded, text cuts off at the first white space and "..." is appended.
+ Once exceeded, text cuts off at the first white space and "..." is appended.
---
- layout
- string
-- Layout of the card.
- Can be `horizontal` or `vertical`.
+- Layout of the card: `horizontal`, or `vertical`.
Defaults to `vertical`.
---
- align
- string
-- Alignment of card content.
- Can be `start`, `center`, or `end`.
+- Alignment of card content: `start`, `center`, or `end`.
Defaults to `start`.
- Card orientation changes with layout:
-
- - For horizontal layouts, `start` means left.
- - For vertical layouts, `start` means top.
+ For horizontal layouts `start` means left, and for vertical layouts it means top.
{% /table %}
@@ -249,643 +515,57 @@ Once exceeded, text cuts off at the first white space and "..." is appended.
Use the `variant` attribute to change the appearance of the card with pre-configured styles.
-{% cards columns=4 cardMinWidth=180 %}
-
- {% card title="Filled card" variant="filled" %}
- Uses `filled` variant.
- {% /card %}
-
- {% card title="Outlined card" variant="outlined" %}
- Uses `outlined` variant.
- {% /card %}
-
- {% card title="Elevated card" variant="elevated" %}
- Uses `elevated` variant.
- {% /card %}
-
- {% card title="Ghost card" variant="ghost" %}
- Uses `ghost` variant.
- {% /card %}
-
-{% /cards %}
-
-
- See card variant example syntax
-
- {% markdoc-example %}
- ``` {% process=false %}
- {% cards columns=4 cardMinWidth=180 %}
-
- {% card title="Filled card" variant="filled" %}
- Uses `filled` variant.
- {% /card %}
-
- {% card title="Outlined card" variant="outlined" %}
- Uses `outlined` variant.
- {% /card %}
-
- {% card title="Elevated card" variant="elevated" %}
- Uses `elevated` variant.
- {% /card %}
-
- {% card title="Ghost card" variant="ghost" %}
- Uses `ghost` variant.
- {% /card %}
-
- {% /cards %}
- ```
- {% /markdoc-example %}
-
-
+{% demo tag="card" properties=$frontmatter.variantProperties %}
+Pick a variant to compare the styles.
+{% /demo %}
### Cards as links
-Set the `to` attribute to configure the entire card as a link.
-Each card variant has unique hover styles for cards as links.
-
-{% cards columns=4 cardMinWidth=180 %}
-
- {% card title="Filled card" to="#cards-as-links" variant="filled" %}
- Hover to see styles.
- {% /card %}
-
- {% card title="Outlined card" to="#cards-as-links" variant="outlined" %}
- Hover to see styles.
- {% /card %}
-
- {% card title="Elevated card" to="#cards-as-links" variant="elevated" %}
- Hover to see styles.
- {% /card %}
-
- {% card title="Ghost card" to="#cards-as-links" variant="ghost" %}
- Hover to see styles.
- {% /card %}
-
-{% /cards %}
-
-
- See link cards example syntax
-
- {% markdoc-example %}
- ``` {% process=false %}
- {% cards columns=4 cardMinWidth=180 %}
-
- {% card title="Filled card" to="#cards-as-links" variant="filled" %}
- Hover to see styles.
- {% /card %}
-
- {% card title="Outlined card" to="#cards-as-links" variant="outlined" %}
- Hover to see styles.
- {% /card %}
-
- {% card title="Elevated card" to="#cards-as-links" variant="elevated" %}
- Hover to see styles.
- {% /card %}
-
- {% card title="Ghost card" to="#cards-as-links" variant="ghost" %}
- Hover to see styles.
- {% /card %}
-
- {% /cards %}
- ```
- {% /markdoc-example %}
-
-
-
-### Link icons
-
-Set the `linkIcon` attribute on a link card to add an icon at the end of the card title.
-The icon needs the `to` attribute, and a card without `to` ignores it.
-
-{% cards columns=2 cardMinWidth=180 %}
-
- {% card title="Chevron" to="#link-icons" linkIcon="chevron" %}
- Uses the `chevron` icon.
- {% /card %}
-
- {% card title="Arrow" to="#link-icons" linkIcon="arrow" %}
- Uses the `arrow` icon.
- {% /card %}
-
-{% /cards %}
-
-
- See link icon example syntax
-
- {% markdoc-example %}
- ``` {% process=false %}
- {% cards columns=2 cardMinWidth=180 %}
-
- {% card title="Chevron" to="#link-icons" linkIcon="chevron" %}
- Uses the `chevron` icon.
- {% /card %}
-
- {% card title="Arrow" to="#link-icons" linkIcon="arrow" %}
- Uses the `arrow` icon.
- {% /card %}
-
- {% /cards %}
- ```
- {% /markdoc-example %}
-
-
-
-### Cards with a call to action
-
-Use the `cta` attribute to add call-to-action text under the card content.
-A chevron icon follows the text, and both change color when a reader hovers over the card.
-The call to action needs the `to` attribute, so that a click anywhere on the card opens that link.
-The `align` attribute positions the call to action together with the rest of the card content.
-
-{% cards columns=2 cardMinWidth=180 %}
-
- {% card title="Quickstart" to="#cards-with-a-call-to-action" cta="Start building" %}
- Publish your first project in five minutes.
- {% /card %}
-
- {% card title="API reference" to="#cards-with-a-call-to-action" cta="Browse the endpoints" variant="outlined" %}
- Every endpoint, parameter, and response schema.
- {% /card %}
-
-{% /cards %}
+Set `to` to turn the whole card into a link.
+`linkIcon` adds an icon after the title, and `cta` adds call-to-action text under the content; both need `to`.
-
- See call to action example syntax
-
- {% markdoc-example %}
- ``` {% process=false %}
- {% cards columns=2 cardMinWidth=180 %}
-
- {% card title="Quickstart" to="#cards-with-a-call-to-action" cta="Start building" %}
- Publish your first project in five minutes.
- {% /card %}
-
- {% card title="API reference" to="#cards-with-a-call-to-action" cta="Browse the endpoints" variant="outlined" %}
- Every endpoint, parameter, and response schema.
- {% /card %}
-
- {% /cards %}
- ```
- {% /markdoc-example %}
-
-
-
-### Cards with a badge
-
-Use the `badge` attribute to label a card.
-The badge displays in the card title, after the link icon.
-`badgeColor` picks a color from the badge palette, and `badgeIcon` puts an icon inside the badge.
-
-{% cards columns=3 cardMinWidth=200 %}
-
- {% card title="Webhooks" badge="Beta" badgeColor="blue" %}
- A color name.
- {% /card %}
+Each card variant has unique hover styles for cards as links.
- {% card title="Legacy tokens" badge="Deprecated" badgeColor="carrot" badgeIcon="triangle-exclamation" %}
- A color name and an icon.
- {% /card %}
+{% demo tag="card" properties=$frontmatter.linkProperties %}
+Publish your first project in five minutes.
+{% /demo %}
- {% card title="Scorecards" badge="New" badgeColor="green" %}
- A color name.
- {% /card %}
+## Cards with a badge
-{% /cards %}
+The `badge` labels a card, `badgeColor` picks a color from the palette, and `badgeIcon` puts an
+icon inside the badge.
+The color picker offers the built-in names, and each swatch takes its color from the palette.
-
- See badge example syntax
-
- {% markdoc-example %}
- ``` {% process=false %}
- {% cards columns=3 cardMinWidth=200 %}
-
- {% card title="Webhooks" badge="Beta" badgeColor="blue" %}
- A color name.
- {% /card %}
-
- {% card title="Legacy tokens" badge="Deprecated" badgeColor="carrot" badgeIcon="triangle-exclamation" %}
- A color name and an icon.
- {% /card %}
-
- {% card title="Scorecards" badge="New" badgeColor="green" %}
- A color name.
- {% /card %}
-
- {% /cards %}
- ```
- {% /markdoc-example %}
-
-
+{% demo tag="card" properties=$frontmatter.badgeProperties %}
+Turn the badge off to see the card without it.
+{% /demo %}
### Icon cards
-Use the `icon` attribute to add an icon to a card and choose its styles with `iconVariant`.
+Use the `icon` attribute to add an icon to a card, choose its styles with `iconVariant` and a color with `iconColor`.
-{% cards columns=4 cardMinWidth=180 %}
-
- {% card title="Filled card" icon="./images/cards-markdoc/ghost-icon.svg" to="#cards-as-links" variant="filled" iconVariant="filled" %}
- ...with `filled` icon variant.
- {% /card %}
-
- {% card title="Outlined card" icon="./images/cards-markdoc/ghost-icon.svg" to="#cards-as-links" variant="outlined" iconVariant="ghost" %}
- ...with `ghost` icon variant.
- {% /card %}
-
- {% card title="Elevated card" icon="./images/cards-markdoc/ghost-icon.svg" to="#cards-as-links" variant="elevated" iconVariant="filled" %}
- ...with `filled` icon variant.
- {% /card %}
-
- {% card title="Ghost card" icon="./images/cards-markdoc/ghost-icon.svg" to="#cards-as-links" variant="ghost" iconVariant="ghost" %}
- ...with `ghost` icon variant.
- {% /card %}
-
-{% /cards %}
-
-
- See icon cards example syntax
-
- {% markdoc-example %}
- ``` {% process=false %}
- {% cards columns=4 cardMinWidth=180 %}
-
- {% card title="Filled card" icon="images/ghost-icon.svg" to="#cards-as-links" variant="filled" iconVariant="filled" %}
- ...with `filled` icon variant.
- {% /card %}
-
- {% card title="Outlined card" icon="images/ghost-icon.svg" to="#cards-as-links" variant="outlined" iconVariant="ghost" %}
- ...with `ghost` icon variant.
- {% /card %}
-
- {% card title="Elevated card" icon="images/ghost-icon.svg" to="#cards-as-links" variant="elevated" iconVariant="filled" %}
- ...with `filled` icon variant.
- {% /card %}
-
- {% card title="Ghost card" icon="images/ghost-icon.svg" to="#cards-as-links" variant="ghost" iconVariant="ghost" %}
- ...with `ghost` icon variant.
- {% /card %}
-
- {% /cards %}
- ```
- {% /markdoc-example %}
-
-
-
-#### Icon colors
-
-Use the `iconColor` attribute to color the icon with a name from the palette.
-
-{% cards columns=4 cardMinWidth=180 %}
-
- {% card title="Red icon" icon="./images/cards-markdoc/ghost-icon.svg" iconColor="red" variant="outlined" %}
- Uses `iconColor="red"`.
- {% /card %}
-
- {% card title="Sky icon" icon="./images/cards-markdoc/ghost-icon.svg" iconColor="sky" variant="outlined" %}
- Uses `iconColor="sky"`.
- {% /card %}
-
- {% card title="Grass icon" icon="./images/cards-markdoc/ghost-icon.svg" iconColor="grass" variant="outlined" %}
- Uses `iconColor="grass"`.
- {% /card %}
-
- {% card title="Theme icon" icon="./images/cards-markdoc/ghost-icon.svg" variant="outlined" %}
- No `iconColor`, so the icon keeps the theme color.
- {% /card %}
-
-{% /cards %}
-
-An icon from a file takes the color only where the file sets no color of its own.
-An icon that hardcodes a `fill` keeps the colors of the file.
-
-
- See icon color example syntax
-
- {% markdoc-example %}
- ``` {% process=false %}
- {% cards columns=4 cardMinWidth=180 %}
-
- {% card title="Red icon" icon="images/ghost-icon.svg" iconColor="red" variant="outlined" %}
- Uses `iconColor="red"`.
- {% /card %}
-
- {% card title="Sky icon" icon="images/ghost-icon.svg" iconColor="sky" variant="outlined" %}
- Uses `iconColor="sky"`.
- {% /card %}
-
- {% card title="Grass icon" icon="images/ghost-icon.svg" iconColor="grass" variant="outlined" %}
- Uses `iconColor="grass"`.
- {% /card %}
-
- {% card title="Theme icon" icon="images/ghost-icon.svg" variant="outlined" %}
- No `iconColor`, so the icon keeps the theme color.
- {% /card %}
-
- {% /cards %}
- ```
- {% /markdoc-example %}
-
-
+{% demo tag="card" properties=$frontmatter.iconProperties %}
+Try `solid rocket` or `brands github` in the icon field.
+{% /demo %}
#### Icon card positioning
Use the `layout` and `align` attributes to control the positioning of elements in your icon card.
+`lineClamp` keeps cards in a grid the same height when their content differs in length.
-**Horizontal layout icon cards:**
-
-{% cards cardMinWidth=180 %}
-
- {% card title="Icon card" layout="horizontal" align="start" icon="./images/cards-markdoc/ghost-icon.svg" variant="outlined" %}
- Layout = `horizontal`
-
- Align = `start`
- {% /card %}
-
- {% card title="Icon card" layout="horizontal" align="center" icon="./images/cards-markdoc/ghost-icon.svg" variant="outlined" %}
- Layout = `horizontal`
-
- Align = `center`
- {% /card %}
-
- {% card title="Icon card" layout="horizontal" align="end" variant="outlined" icon="./images/cards-markdoc/ghost-icon.svg" %}
- Layout = `horizontal`
-
- Align = `end`
- {% /card %}
-
-{% /cards %}
-
-
- See horizontal icon card example syntax
-
- {% markdoc-example %}
- ``` {% process=false %}
- {% cards cardMinWidth=180 %}
-
- {% card title="Icon card" layout="horizontal" align="start" icon="./images/cards-markdoc/ghost-icon.svg" variant="outlined" %}
- Layout = `horizontal`
-
- Align = `start`
- {% /card %}
-
- {% card title="Icon card" layout="horizontal" align="center" icon="./images/cards-markdoc/ghost-icon.svg" variant="outlined" %}
- Layout = `horizontal`
-
- Align = `center`
- {% /card %}
-
- {% card title="Icon card" layout="horizontal" align="end" variant="outlined" icon="./images/cards-markdoc/ghost-icon.svg" %}
- Layout = `horizontal`
-
- Align = `end`
- {% /card %}
-
- {% /cards %}
- ```
- {% /markdoc-example %}
-
-
-
-**Vertical layout icon cards:**
-
-{% cards cardMinWidth=180 %}
-
- {% card title="Icon card" layout="vertical" align="start" icon="./images/cards-markdoc/ghost-icon.svg" variant="outlined" %}
- Layout = `vertical`
-
- Align = `start`
- {% /card %}
-
- {% card title="Icon card" layout="vertical" align="center" icon="./images/cards-markdoc/ghost-icon.svg" variant="outlined" %}
- Layout = `vertical`
-
- Align = `center`
- {% /card %}
-
- {% card title="Icon card" layout="vertical" align="end" variant="outlined" icon="./images/cards-markdoc/ghost-icon.svg" %}
- Layout = `vertical`
-
- Align = `end`
- {% /card %}
-
-{% /cards %}
-
-
- See vertical icon card example syntax
-
- {% markdoc-example %}
- ``` {% process=false %}
- {% cards cardMinWidth=180 %}
-
- {% card title="Icon card" layout="vertical" align="start" icon="./images/cards-markdoc/ghost-icon.svg" variant="outlined" %}
- Layout = `vertical`
-
- Align = `start`
- {% /card %}
-
- {% card title="Icon card" layout="vertical" align="center" icon="./images/cards-markdoc/ghost-icon.svg" variant="outlined" %}
- Layout = `vertical`
-
- Align = `center`
- {% /card %}
-
- {% card title="Icon card" layout="vertical" align="end" variant="outlined" icon="./images/cards-markdoc/ghost-icon.svg" %}
- Layout = `vertical`
-
- Align = `end`
- {% /card %}
-
- {% /cards %}
- ```
- {% /markdoc-example %}
-
-
-
-### Custom colors
-
-The palette covers the built-in names.
-To use a color of your own, name it and define it in your [theme stylesheet](../../branding/customize-styles.md), then use that name in both attributes.
-
-Define a `.tag-{name}` class for the badge, and a `.card-icon-color-{name}` class for the icon:
-
-```css {% title="@theme/styles.css" %}
-.tag-mycolor {
- --tag-bg-color: #DCE8FF;
- --tag-color: #1A3A6B;
-}
-
-.card-icon-color-mycolor {
- --card-icon-color: #1A3A6B;
-}
-```
-
-The badge takes a pair of colors, because it needs a background and a text color that stays readable on it.
-The icon takes one color.
-
-Use the name the same way as a built-in one:
-
-{% markdoc-example %}
- ```markdoc {% process=false %}
- {% card title="Webhooks" icon="images/ghost-icon.svg" iconColor="mycolor" badge="Beta" badgeColor="mycolor" %}
- Uses a custom color.
- {% /card %}
- ```
-{% /markdoc-example %}
-
-To set different colors for dark mode, define the same classes again under `:root.dark`.
+{% demo tag="card" properties=$frontmatter.layoutProperties %}
+Cards work best when they present information in a concise way, so keep the body short and let the title carry the meaning.
+{% /demo %}
### Image cards
Add an image to a card using the `image` attribute.
-{% cards columns=4 cardMinWidth=180 %}
-
- {% card title="Filled card" image="./images/cards-markdoc/card-gradient.jpeg" to="#cards-as-links" variant="filled" %}
- ...with an image!
- {% /card %}
-
- {% card title="Outlined card" image="./images/cards-markdoc/card-gradient.jpeg" to="#cards-as-links" variant="outlined" %}
- ...with an image!
- {% /card %}
-
- {% card title="Elevated card" image="./images/cards-markdoc/card-gradient.jpeg" to="#cards-as-links" variant="elevated" %}
- ...with an image!
- {% /card %}
-
- {% card title="Ghost card" image="./images/cards-markdoc/card-gradient.jpeg" to="#cards-as-links" variant="ghost" %}
- ...with an image!
- {% /card %}
-
-{% /cards %}
-
-
- See image cards example syntax
-
- {% markdoc-example %}
- ``` {% process=false %}
- {% cards columns=4 cardMinWidth=180 %}
-
- {% card title="Filled card" image="images/card-gradient.jpeg" to="#cards-as-links" variant="filled" %}
- ...with an image!
- {% /card %}
-
- {% card title="Outlined card" image="images/card-gradient.jpeg" to="#cards-as-links" variant="outlined" %}
- ...with an image!
- {% /card %}
-
- {% card title="Elevated card" image="images/card-gradient.jpeg" to="#cards-as-links" variant="elevated" %}
- ...with an image!
- {% /card %}
-
- {% card title="Ghost card" image="images/card-gradient.jpeg" to="#cards-as-links" variant="ghost" %}
- ...with an image!
- {% /card %}
-
- {% /cards %}
- ```
- {% /markdoc-example %}
-
-
-
-#### Image card positioning
-
Use the `layout`, `align`, and `imagePosition` attributes to control the positioning of elements in your image card.
-**Vertical layout image cards:**
-
-{% cards cardMinWidth=180 %}
-
- {% card title="Image card" layout="vertical" align="start" imagePosition="start" image="./images/cards-markdoc/card-gradient.jpeg" variant="outlined" %}
- Layout = `vertical`
-
- Align = `start`
-
- imagePosition = `start`
- {% /card %}
-
- {% card title="Image card" layout="vertical" align="center" imagePosition="start" image="./images/cards-markdoc/card-gradient.jpeg" variant="outlined" %}
- Layout = `vertical`
-
- Align = `center`
-
- imagePosition = `start`
- {% /card %}
-
- {% card title="Image card" layout="vertical" align="end" imagePosition="end" image="./images/cards-markdoc/card-gradient.jpeg" variant="outlined" %}
- Layout = `vertical`
-
- Align = `end`
-
- imagePosition = `end`
- {% /card %}
-
-{% /cards %}
-
-
- See vertical image card example syntax
-
- {% markdoc-example %}
- ``` {% process=false %}
- {% cards cardMinWidth=180 %}
-
- {% card title="Image card" layout="vertical" align="start" imagePosition="start" image="images/card-gradient.jpeg" variant="outlined" %}
- Layout = `vertical`
-
- Align = `start`
-
- imagePosition = `start`
- {% /card %}
-
- {% card title="Image card" layout="vertical" align="center" imagePosition="start" image="images/card-gradient.jpeg" variant="outlined" %}
- Layout = `vertical`
-
- Align = `center`
-
- imagePosition = `start`
- {% /card %}
-
- {% card title="Image card" layout="vertical" align="end" imagePosition="end" image="images/card-gradient.jpeg" variant="outlined" %}
- Layout = `vertical`
-
- Align = `end`
-
- imagePosition = `end`
- {% /card %}
-
- {% /cards %}
- ```
- {% /markdoc-example %}
-
-
-
-**Horizontal layout image cards:**
-
-{% cards cardMinWidth=180 %}
-
- {% card title="Image card" layout="horizontal" align="start" imagePosition="start" image="./images/cards-markdoc/card-gradient.jpeg" variant="outlined" %}
- Layout = `horizontal`
-
- Align = `start`
-
- imagePosition = `start`
- {% /card %}
-
- {% card title="Image card" layout="horizontal" align="center" imagePosition="start" image="./images/cards-markdoc/card-gradient.jpeg" variant="outlined" %}
- Layout = `horizontal`
-
- Align = `center`
-
- imagePosition = `start`
- {% /card %}
-
- {% card title="Image card" layout="horizontal" align="end" imagePosition="end" image="./images/cards-markdoc/card-gradient.jpeg" variant="outlined" %}
- Layout = `horizontal`
-
- Align = `end`
-
- imagePosition = `end`
- {% /card %}
-
-{% /cards %}
+{% demo tag="card" properties=$frontmatter.imageProperties %}
+Change `layout`, `align` and `imagePosition` to see how the image affects the card.
+{% /demo %}
## Best practices
@@ -925,8 +605,16 @@ Incorrect paths will show Markdoc errors in your console.
Using different amounts of content in each card can make the cards in your grid appear to be different sizes.
Consider rephrasing your content or using the `lineClamp` attribute to maintain a consistent height.
+## Customize `card`
+
+Every attribute of `card` in one place.
+Turn on the parts you need, shape the card, then select **Show code** and copy the snippet
+into your project.
+
+{% demo tag="card" properties=$frontmatter.customizeProperties %}
+Shape this card with the form, then copy the snippet into your project.
+{% /demo %}
+
## Resources
- **[Markdoc overview for technical writers](https://redocly.com/learn/markdoc)** - Learn how to use Markdoc in your documentation
-- **[Markdoc tags](./index.md)** - See the full list of supported Markdoc tags
-
diff --git a/package.json b/package.json
index a5f1feb2e..7eea36330 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,7 @@
"license": "UNLICENSED",
"dependencies": {
"@redocly/marketing-pages": "^0.2.27",
- "@redocly/realm": "0.137.0-next.1",
+ "@redocly/realm": "0.137.0-next.4",
"buffer": "^6.0.3",
"highlight-words-core": "^1.2.3",
"path": "^0.12.7",
@@ -40,8 +40,8 @@
"js-cookie@<3.0.7": "3.0.7",
"markdown-it@<14.2.0": "14.3.0",
"dompurify@<3.4.13": "3.4.13",
- "@redocly/realm": "0.137.0-next.1",
- "@redocly/theme": "0.69.0-next.1",
+ "@redocly/realm": "0.137.0-next.4",
+ "@redocly/theme": "0.69.0-next.4",
"markdownlint-cli2": {
"js-yaml": "^4.3.1"
}