Responsive, accessible filmstrip for displaying a horizontal list of content frames.

import { CdrFilmstrip } from '@rei/cedar'
Uses: CdrFilmstripEngine

The CdrFilmstrip component is a horizontally scrollable surface used to display a sequence of related content items within layout frames. It supports a variety of interaction patterns including keyboard navigation, touch scrolling, and controls.

Use it to highlight featured content, display product recommendations or other collections of similar frames.

When to use

  • Presenting a horizontally scrollable list of related content, such as products, articles, or features
  • Emphasizing a content sequence where visual preview is important
  • Enabling users to browse multiple frames without navigating away from the current page
  • Supporting keyboard and assistive technology navigation patterns

When not to use

  • Displaying content items that require extensive reading or interaction within each frame
  • Using for unrelated content that doesn’t benefit from visual alignment
  • Relying on autoplay or continuous animation to convey critical information

Anatomy

The CdrFilmstrip consists of five main parts:

Shows the surface scroll content container and the arrow controls in the middle, and the scrollbar at the bottom

  1. Filmstrip controls: User controls that appear when hovering over the filmstrip, allowing navigation through the available frames.
  2. Item content: The content displayed within each frame, such as images, product data, or other visual elements. Each frame should contain similar types of content for consistency.
  3. Frame item: A container component, such as a CdrSurface or a custom card, that holds the frame item content.
  4. Frame layout: An unordered list layout that defines how frames are arranged within the surface scroll, including their size, spacing, and the number of frames shown at once.
  5. Surface scroll: The main container that holds the layout and all frames, managing horizontal overflow. Not all content is visible at rest; overflow frames are clipped. The CdrSurfaceScroll component includes a custom scrollbar for navigation and as a visual indicator, showing the user's position and the total available content.

CdrFilmstrip is highly flexible thanks to its adaptable layout and customizable appearance. No matter what content you choose, the component’s interaction behavior remains consistent because of its established framework.

Framing and layout

  • Use a layout variant to control frame spacing, sizing, and the number of visible frames for optimal alignment and scanning
  • Select the appropriate container component (CdrSurface, CdrSurfaceNavigation, etc.) based on the desired interaction pattern. For example, use CdrSurfaceNavigation if clicking the entire frame should navigate the user, or use a nested interactive element (like a button or link) if only part of the frame should be interactive
  • Ensure a partial frame is visible at rest to indicate additional scrollable content

Content within frames

  • Prioritize imagery or content that benefits from visual preview
  • Keep frame content concise—avoid long-form text or interactive elements
  • Use semantic heading structure and alt text for accessibility
  • Standardize text truncation across all frames for visual alignment and consistency

Interaction and navigation

  • Enable controls for quick navigation
  • Enable scrollbar to indicate focus in the overflow layout
  • Support keyboard focus management for accessibility
  • Avoid using auto-scroll or looping behaviors unless they are user-initiated
Do

Do maintain consistent frame dimensions and spacing.

Have a defined start and end point populated with related content.

Don't

Don't use frames with mixed sizes.

Populate with excessive or infinitely looping content.

Product recommendation example

This variant is an example of what you can create using filmstrip. It displays 6 frames per view. It doesn't need more than 5 clicks or swipes to view all the frames within it.

A filmstrip displaying a set of five product cards with a sixth card peeking out

For more details about the product recommendation variant, visit our Figma Cedar community library.

  • Curate frame content so each frame communicates a clear, scannable idea—use imagery, concise copy, or call-to-actions
  • Keep frames consistent in structure to avoid visual clutter or cognitive overload
  • Avoid overcrowding frames—leverage visual hierarchy, spacing, and alignment
  • Ensure all interactive elements are clearly labeled and accessible

What Cedar provides

  • Semantic roles: The filmstrip and its frames are rendered with appropriate roles and ARIA attributes to convey structure and intent
  • Keyboard navigation: Users can navigate between frames using arrow keys and standard focus navigation
  • Scroll announcements: When the filmstrip scrolls, updates can be announced to screen readers for better context
  • Focus management: Focus is programmatically shifted when using provided controls to maintain usability and accessibility

Development responsibilities

When using this component, here's what you are responsible for:

  • Ensure all content within each frame is meaningful and accessible via keyboard and screen readers
  • Provide alt text for any images within frames
  • Avoid including long-form or complex interactions that require nested focus management
  • Ensure labels or headings are included where necessary to provide context
  • Do not rely solely on scroll position or animation to convey important information

CdrFilmstrip is built around an adapter pattern to decouple data shape from presentation. Instead of requiring consumers to conform to a rigid prop structure, the filmstrip receives a model and an adapter function.

The adapter is responsible for transforming arbitrary input data into a standardized configuration that the filmstrip can render. This includes layout options, accessibility metadata, and a list of frame objects. By externalizing this logic, CdrFilmstrip stays agnostic to the data source and highly reusable across different contexts.

This approach promotes clear separation of concerns: data mapping lives in the adapter, and visual rendering is handled by the filmstrip itself.

The model

The model prop is the raw input passed to the adapter function. It can be any shape—ranging from structured CMS data to hand-authored JSON—depending on your application’s needs.

The adapter is responsible for interpreting this model and extracting the relevant values needed by the filmstrip: frames, layout settings, ARIA metadata, etc.

This abstraction allows authors to work with familiar data shapes while keeping rendering logic encapsulated in the adapter.

The adapter function

An adapter is a function that takes a raw data model and returns a structured configuration that CdrFilmstrip can render. This includes layout metadata, accessibility description, and an array of frame objects.

The adapter must conform to the CdrFilmstripAdapter<T> interface:

interface CdrFilmstripAdapter<T> {
  (modelData: unknown): CdrFilmstripConfig<T>;
}

The returned config must match the following structure:

interface CdrFilmstripConfig<T> {
  component: Component; // Vue component to render each frame
  frames: CdrFilmstripFrame<T>[]; // Array of frames (each has key + props)
  filmstripId: string; // Unique ID for ARIA and targeting
  description: string; // ARIA label for screen readers
  framesGap?: number; // Optional spacing between frames
  framesToShow?: number; // Optional number of visible frames
  focusSelector?: string; // Optional CSS selector to manage focus
}

This pattern gives you full control over how frames are shaped, rendered, and described—without hardcoding structure into the filmstrip component itself.

A minimal adapter might look like this:

import type {
  CdrFilmstripAdapter,
  CdrFilmstripConfig,
  CdrFilmstripFrame,
} from '@rei/cedar';
import FrameComponent, { type Frame } from './FrameComponent.vue';

/**
 * Adapter to transform raw model data into a structured filmstrip config.
 *
 * @param {unknown} model - The raw model data, expected to contain `items`.
 * @returns {CdrFilmstripConfig<Frame>} The filmstrip configuration.
 */
const adapter: CdrFilmstripAdapter<Frame> = (
  model,
): CdrFilmstripConfig<Frame> => {
  const { items = [] } = model as { items?: Frame[] };

  const frames: CdrFilmstripFrame<Frame>[] = items.map((item, index) => ({
    key: `frame-${index}`,
    props: item,
  }));

  const config: CdrFilmstripConfig<Frame> = {
    component: FrameComponent,
    frames,
    filmstripId: 'example',
    description: 'Example filmstrip',
  };

  return config;
};

export default adapter;

The frame

The component defined in the adapter is responsible for rendering each frame in the filmstrip. It receives the props defined in each CdrFilmstripFrame object and is treated like a standalone Vue component.

In the minimal adapter example above, each item from the model is passed as props to the frame. Here's a simplified version of a frame component:

<template>
  <div class="frame">
    <img
      :src="image.src"
      :alt="image.alt"
    />
    <a
      v-if="cta?.text"
      :href="cta.target"
    >
      {{ cta.text }}
    </a>
  </div>
</template>

<script lang="ts" setup>
export interface Frame {
  image: {
    src: string;
    alt: string;
  };
  cta?: {
    text?: string;
    target: string;
  };
}

defineProps<Frame>();
</script>

The frame component can contain any markup or layout needed to present the data. You are free to structure the props and visuals in a way that suits your use case, as long as it aligns with the shape returned from the adapter.

Events

CdrFilmstrip emits several events that allow you to respond to user interaction or layout changes. These events provide contextual data about the filmstrip’s state and are intended to help with analytics, focus control, dynamic updates, or external state management.

Common events

  • arrow-click: Fired when the user clicks one of the navigation arrows. Useful for tracking scroll direction and frame configuration.
  • scroll-navigate: Fired when the filmstrip scrolls to a new frame. Includes the index of the target frame and model context.
  • resize: Fired when the number of visible frames changes due to a screen or container resize. Can be used to dynamically adjust layout behavior.
  • aria-message: Fired with a string message intended for screen readers to announce contextual changes.

Example: Listening to events

<CdrFilmstrip
  :model="model"
  :adapter="adapter"
  @arrow-click="onArrowClick"
  @scroll-navigate="onScrollNavigate"
  @resize="onResize"
  @aria-message="onAriaMessage"
></CdrFilmstrip>
function onArrowClick({ direction, event, model }) {
  console.log('Arrow clicked:', direction, event);
}

function onScrollNavigate({ index, event, model }) {
  console.log('Scrolled to frame index:', index);
}

function onAriaMessage(message: string) {
  console.log('Screen reader message:', message);
}

For dynamic frame layout, the resize event can be used to set framesToShow and framesToScroll based on screen width or breakpoint tokens. This enables responsive control while keeping layout logic outside the component.

import { CdrBreakpointMd } from '@rei/cedar';
import type { CdrFilmstripResizePayload } from '@rei/cedar';

function onResize(payload: unknown) {
  const {
    framesToScroll,
    framesToShow,
    model = {},
  } = payload as CdrFilmstripResizePayload;

  const width = document.body.clientWidth;

  if (width >= Number(CdrBreakpointMd)) {
    framesToShow.value = 3;
    framesToScroll.value = 2;
  }
}

Custom events

CdrFilmstrip supports custom events using Vue’s provide/inject API to enable communication across deeply nested component trees. This allows consumers to listen to or emit internal events without requiring direct parent-child relationships.

To emit a custom event from within a child component of the filmstrip, use the injected event emitter:

<template>
  <div
    class="frame"
    @click.prevent="onFrameClick"
  >
    <img
      :src="image.src"
      :alt="image.alt"
    />
    <a
      v-if="cta?.text"
      :href="cta.target"
    >
      {{ cta.text }}
    </a>
  </div>
</template>

<script lang="ts" setup>
import { inject } from 'vue';
import {
  CdrFilmstripEventEmitterKey,
  type CdrFilmstripEventEmitter,
} from '@rei/cedar';

export interface Frame {
  image: {
    src: string;
    alt: string;
  };
  cta?: {
    text?: string;
    target: string;
  };
}

const props = defineProps<Frame>();
const emitEvent = inject(CdrFilmstripEventKey) as CdrFilmstripEventEmitter;

const onFrameClick = (event: Event) => {
  emitEvent?.('frameClick', {
    event,
    item: props,
  });
};
</script>

This pattern gives users flexibility to coordinate behaviors across filmstrip descendants or integrate with external systems while maintaining encapsulation.

Example: Listening to a custom event

To handle a custom event like frameClick, add a listener directly on the CdrFilmstrip component and define the handler accordingly. Here's an example using the frameClick event emitted from a frame:

<template>
  <CdrFilmstrip
    :model="model"
    :adapter="adapter"
    @frame-click="onFrameClick"
  />
</template>

<script setup lang="ts">
function onFrameClick({ event, item }) {
  console.log('Custom frame clicked:', item);
}
</script>

Bringing it all together

A typical implementation might organize its filmstrip logic into a dedicated directory with the following structure:

custom-filmstrip/
├── Main.vue          # Entry point that imports adapter, frame, handlers
├── Frame.vue         # Vue component used to render individual frames
├── adapter.ts        # Transforms raw model data into filmstrip config
├── handlers.ts       # Event handlers like onFrameClick, onResize
├── index.d.ts        # Type declarations for frame props and model

This setup promotes a clean and modular architecture:

  • Main.vue acts as the primary integration layer, connecting data, layout, and events.
  • Frame.vue defines the UI and props for individual filmstrip items.
  • adapter.ts centralizes transformation logic, abstracting data shape concerns.
  • handlers.ts isolates side effects and shared event logic.
  • index.d.ts keeps local types scoped and maintainable.

Example: Main.vue

Here’s a minimal example of how Main.vue might look, using the adapter, frame component, and event handlers:

<template>
  <CdrFilmstrip
    class="custom-filmstrip"
    :model="model"
    :adapter="adapter"
    @frame-click="onFrameClick"
  />
</template>

<script setup lang="ts">
import { CdrFilmstrip } from '@rei/cedar';
import adapter from './adapter';
import { onFrameClick } from './handlers';
import model from './mock.json';
</script>

This file acts as the entry point for rendering the filmstrip with custom data, components, and behavior. It ties together the core parts of your implementation while keeping responsibilities cleanly separated.

CdrFilmstrip

Props

NameTypeDefault
adapter
Required

CdrFilmstripAdapter(): CdrFilmstripAdapter<Record<string, unknown>> => { return (_modelData: unknown): CdrFilmstripConfig<Record<string, unknown>> => { console.warn(`No adapter provided for CdrFilmstrip`); return { frames: [], filmstripId: 'empty-filmstrip', component: h('div'), description: 'An empty filmstrip', }; }; }
model
Required

T(): Record<string, unknown> => ({})

Slots

Name
heading

Optional injection of a heading element for the filmstrip

Events

NameParameters
ariaMessage

Emitted to update screen readers with the current frame information.

payload
arrowClick

Emitted when a user clicks the navigation arrows.

payload
scrollNavigate

Emitted when the filmstrip scrolls to a new frame.

payload
resize

Emitted when the layout changes due to screen or container resize.

payload