Recipes
A collection of practical recipes showing how to build Vev components. Each recipe is focused and self-contained.
Hello World
The minimal component — a single editable text prop.
import React from 'react';
import { registerVevComponent } from '@vev/react';
type Props = {
text: string;
};
const HelloWorld = ({ text = 'Hello, Vev!' }: Props) => {
return <h1>{text}</h1>;
};
registerVevComponent(HelloWorld, {
name: 'Hello World',
props: [{ name: 'text', type: 'string' }],
});
export default HelloWorld;Image with caption
Using the built-in Image component with an editable caption.
import React from 'react';
import { registerVevComponent, Image } from '@vev/react';
import styles from './ImageCard.module.css';
type Props = {
image: { url: string; key: string };
caption: string;
};
const ImageCard = ({ image, caption }: Props) => {
return (
<figure className={styles.wrapper}>
<Image className={styles.image} src={image} />
{caption && <figcaption className={styles.caption}>{caption}</figcaption>}
</figure>
);
};
registerVevComponent(ImageCard, {
name: 'Image Card',
props: [
{ name: 'image', type: 'image' },
{ name: 'caption', type: 'string' },
],
});
export default ImageCard;Conditional rendering
Show or hide elements using a boolean prop.
import React from 'react';
import { registerVevComponent } from '@vev/react';
type Props = {
title: string;
showBadge: boolean;
badgeLabel: string;
};
const Badge = ({ title, showBadge, badgeLabel = 'New' }: Props) => {
return (
<div>
<h2>{title}</h2>
{showBadge && <span className="badge">{badgeLabel}</span>}
</div>
);
};
registerVevComponent(Badge, {
name: 'Badge',
props: [
{ name: 'title', type: 'string' },
{ name: 'showBadge', type: 'boolean' },
{
name: 'badgeLabel',
type: 'string',
hidden: (ctx) => !ctx.value.showBadge,
},
],
});
export default Badge;Select
A select field that shows different options depending on the selected value.
import React from 'react';
import { registerVevComponent } from '@vev/react';
type Props = {
variant: 'primary' | 'secondary' | 'ghost';
label: string;
};
const Button = ({ variant = 'primary', label = 'Click me' }: Props) => {
return <button className={`btn btn--${variant}`}>{label}</button>;
};
registerVevComponent(Button, {
name: 'Button',
props: [
{ name: 'label', type: 'string' },
{
name: 'variant',
type: 'select',
options: {
display: 'radio',
items: [
{ label: 'Primary', value: 'primary' },
{ label: 'Secondary', value: 'secondary' },
{ label: 'Ghost', value: 'ghost' },
],
},
},
],
});
export default Button;Card list
An array of objects rendered as a list of cards. Use of: 'object' with fields to define the shape of each item.
import React from 'react';
import { registerVevComponent, Image } from '@vev/react';
import styles from './CardList.module.css';
interface Card {
title: string;
description: string;
image: { url: string; key: string };
}
interface Props {
cards: Card[];
}
const CardList = ({ cards = [] }: Props) => {
return (
<ul className={styles.list}>
{cards.map((card, i) => (
<li key={i} className={styles.card}>
<Image src={card.image} />
<h3>{card.title}</h3>
<p>{card.description}</p>
</li>
))}
</ul>
);
};
registerVevComponent(CardList, {
name: 'Card List',
props: [
{
name: 'cards',
type: 'array',
of: 'object',
fields: [
{ name: 'title', type: 'string', title: 'Title' },
{ name: 'description', type: 'string', title: 'Description', options: { multiline: true } },
{ name: 'image', type: 'image', title: 'Image' },
],
},
],
});
export default CardList;Mixed content blocks
An array with multiple item types. Each entry in the of array defines a possible item schema.
Items are stored as { schemaName: { ...fields } } — the key is the schema's name.
import React from 'react';
import { registerVevComponent, Image } from '@vev/react';
interface Props {
blocks: Array<
| { text: { heading: string; body: string } }
| { image: { src: { url: string; key: string }; caption: string } }
>;
}
const ContentBlocks = ({ blocks = [] }: Props) => {
return (
<div>
{blocks.map((block, i) => {
if ('text' in block) {
return (
<div key={i}>
<h2>{block.text.heading}</h2>
<p>{block.text.body}</p>
</div>
);
}
if ('image' in block) {
return (
<figure key={i}>
<Image src={block.image.src} />
{block.image.caption && <figcaption>{block.image.caption}</figcaption>}
</figure>
);
}
return null;
})}
</div>
);
};
registerVevComponent(ContentBlocks, {
name: 'Content Blocks',
props: [
{
name: 'blocks',
type: 'array',
of: [
{
name: 'text',
type: 'object',
title: 'Text Block',
fields: [
{ name: 'heading', type: 'string', title: 'Heading' },
{ name: 'body', type: 'string', title: 'Body', options: { multiline: true } },
],
},
{
name: 'image',
type: 'object',
title: 'Image Block',
fields: [
{ name: 'src', type: 'image', title: 'Image' },
{ name: 'caption', type: 'string', title: 'Caption' },
],
},
],
},
],
});
export default ContentBlocks;Animate on scroll
Trigger an animation when the component enters the viewport using useVisible.
import React, { useRef } from 'react';
import { registerVevComponent, useVisible } from '@vev/react';
import styles from './FadeIn.module.css';
type Props = {
text: string;
};
const FadeIn = ({ text }: Props) => {
const ref = useRef<HTMLDivElement>(null);
const isVisible = useVisible(ref);
return (
<div
ref={ref}
className={`${styles.wrapper} ${isVisible ? styles.visible : styles.hidden}`}
>
{text}
</div>
);
};
registerVevComponent(FadeIn, {
name: 'Fade In',
props: [{ name: 'text', type: 'string' }],
});
export default FadeIn;Breakpoint-dependent props
Add breakpoint: true to a prop, and the user can give it a different value on
each breakpoint. Your component still receives one plain value — the one for the
breakpoint being rendered.
import React from 'react';
import { registerVevComponent } from '@vev/react';
type Props = {
slidesPerView: number;
loop: boolean;
};
const Carousel = ({ slidesPerView, loop }: Props) => {
return <div className="carousel" data-slides={slidesPerView} data-loop={loop} />;
};
registerVevComponent(Carousel, {
name: 'Carousel',
props: [
{ name: 'slidesPerView', type: 'number', breakpoint: true },
{ name: 'loop', type: 'boolean' },
],
});
export default Carousel;The editor marks a flagged field with a device icon, so it is clear which props can vary before anyone touches them. It is dimmed while the value is the same everywhere, blue when the value is inherited from a wider breakpoint, and highlighted when this breakpoint has a value of its own. Click the highlighted icon to drop the override and inherit again.
A breakpoint with no value of its own inherits from the next wider one, exactly like the CSS does. You only store what you actually changed.
The flag works on a prop at any depth — inside an object field, or inside the
items of an array field. Do not flag a field and one of its own children; flag
one or the other.
When not to use it
A published page is prerendered once, with the main breakpoint's values, and one file is served to every viewport. A narrower breakpoint's value is applied when the page hydrates, not before.
So use breakpoint props for configuration and behaviour — how many slides to
show, whether to autoplay. Avoid them for primary copy and for above-the-fold
layout, where the swap is visible and costs you layout shift. Anything CSS can
express should stay in CSS, through editableCSS or the style panel.
Reading the viewport directly
For behaviour that depends on the exact width rather than the breakpoint, read the viewport. The same hydration caveat applies.
import React from 'react';
import { registerVevComponent, useViewport } from '@vev/react';
const WidthLabel = () => {
const { width } = useViewport();
return <p>{width}px</p>;
};
registerVevComponent(WidthLabel, { name: 'Width Label' });
export default WidthLabel;Editable CSS
Combine editableCSS with a color prop for a styled component the editor can customize.
import React from 'react';
import { registerVevComponent } from '@vev/react';
import styles from './Callout.module.css';
type Props = {
text: string;
accentColor: string;
};
const Callout = ({ text, accentColor }: Props) => {
return (
<div className={styles.callout} style={{ borderColor: accentColor }}>
<p>{text}</p>
</div>
);
};
registerVevComponent(Callout, {
name: 'Callout',
props: [
{ name: 'text', type: 'string', options: { multiline: true } },
{ name: 'accentColor', type: 'color' },
],
editableCSS: [
{ selector: styles.callout, properties: ['background', 'border-radius', 'padding'] },
],
});
export default Callout;Interactions
A modal that can be opened and closed from the Vev editor's Interactions panel.
import React, { useState } from 'react';
import { registerVevComponent, useVevEvent } from '@vev/react';
enum ModalInteraction {
open = 'open',
close = 'close',
}
type Props = {
title: string;
content: string;
};
const Modal = ({ title, content }: Props) => {
const [isOpen, setIsOpen] = useState(false);
useVevEvent(ModalInteraction.open, () => setIsOpen(true));
useVevEvent(ModalInteraction.close, () => setIsOpen(false));
if (!isOpen) return null;
return (
<div className="modal">
<h2>{title}</h2>
<p>{content}</p>
<button onClick={() => setIsOpen(false)}>Close</button>
</div>
);
};
registerVevComponent(Modal, {
name: 'Modal',
props: [
{ name: 'title', type: 'string' },
{ name: 'content', type: 'string', options: { multiline: true } },
],
interactions: [
{ type: ModalInteraction.open, description: 'Open modal' },
{ type: ModalInteraction.close, description: 'Close modal' },
],
});
export default Modal;Events
A countdown timer that dispatches events when it starts and when it reaches zero.
import React, { useState, useEffect } from 'react';
import { registerVevComponent, useDispatchVevEvent } from '@vev/react';
enum TimerEvent {
onStart = 'onStart',
onComplete = 'onComplete',
}
type Props = {
seconds: number;
};
const Countdown = ({ seconds = 10 }: Props) => {
const [count, setCount] = useState(seconds);
const dispatch = useDispatchVevEvent();
useEffect(() => {
setCount(seconds);
dispatch(TimerEvent.onStart);
const interval = setInterval(() => {
setCount((prev) => {
if (prev <= 1) {
clearInterval(interval);
dispatch(TimerEvent.onComplete);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}, [seconds]);
return <div className="countdown">{count}</div>;
};
registerVevComponent(Countdown, {
name: 'Countdown',
props: [
{
name: 'seconds',
type: 'number',
options: { min: 1, max: 300, display: 'input' },
},
],
events: [
{ type: TimerEvent.onStart, description: 'On start' },
{ type: TimerEvent.onComplete, description: 'On complete' },
],
});
export default Countdown;Track interactions
A slideshow that reports every click to analytics. It emits the same VEV_AI_ELEMENT event
that Element AI components use, so one listener covers this component and all of those — see
tracking for the listener side.
import React, { useCallback, useState } from 'react';
import { registerVevComponent, useEditorState, useTracking } from '@vev/react';
type Slide = { title: string };
type Props = { slides: Slide[] };
const Slideshow = ({ slides = [] }: Props) => {
const [index, setIndex] = useState(0);
const editor = useEditorState();
// Silent while designing; fires in Preview and on published pages.
const track = useTracking(editor.disabled);
const go = useCallback(
(direction: 1 | -1) => {
const next = (index + direction + slides.length) % slides.length;
setIndex(next);
track('VEV_AI_ELEMENT', {
component: 'Slideshow',
action: direction === 1 ? 'nextSlideClicked' : 'prevSlideClicked',
triggerType: 'click',
index: String(next),
});
},
[index, slides.length, track],
);
const handleNext = useCallback(() => go(1), [go]);
const handlePrev = useCallback(() => go(-1), [go]);
if (!slides.length) return <div>Add a slide to get started</div>;
return (
<div className="slideshow">
<h2>{slides[index].title}</h2>
<button onClick={handlePrev} aria-label="Previous slide">
Prev
</button>
<button onClick={handleNext} aria-label="Next slide">
Next
</button>
</div>
);
};
registerVevComponent(Slideshow, {
name: 'Slideshow',
props: [
{
name: 'slides',
type: 'array',
of: 'object',
fields: [{ name: 'title', type: 'string' }],
},
],
});
export default Slideshow;The event reaching the page is:
{
type: 'VEV_AI_ELEMENT',
data: { component: 'Slideshow', action: 'nextSlideClicked', triggerType: 'click', index: '1' },
metaData: { projectKey, pageKey, timestamp },
}