Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Recipes – Vev Developer
Skip to content

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 },
}