Extending Fluent Video Hub: Carousel Navigation + Ratings with Fluent UI

Overview

In Part 1 we built Fluent Video Hub, a modern SPFx web part that lists and plays videos from a SharePoint document library using Fluent UI.
In this follow-up, we’ll add two features that make the experience feel truly app-like:

1️⃣ A horizontal carousel with smooth scroll buttons.
2️⃣ A rating component that lets users leave quick feedback.

Both are implemented entirely with Fluent UI React v9, without any external libraries.


🧩 1️⃣ Goal

EnhancementBenefit
Carousel scrollLets users browse many videos smoothly without grid overflow.
Rating starsAdds simple engagement feedback and visual polish.
Full Fluent UIKeeps layout light, accessible, and themable.

⚙️ 2️⃣ Setup and Dependencies

From your existing FluentVideoHub solution:

npm install @fluentui/react @fluentui/react-components

If you started from the previous article, you already have these.
No SCSS changes are needed — everything will be inline with Fluent tokens.


🧱 3️⃣ Updated Component (FluentVideoHub.tsx)

Below is the new enhanced component.
Changes from Part 1 are marked with ✨ comments.

import * as React from 'react';
import { useState, useEffect, useRef } from 'react';
import { SPHttpClient } from '@microsoft/sp-http';
import { IFluentVideoHubProps } from './IFluentVideoHubProps';
import {
  Stack, TextField, IconButton, Panel, PanelType,
  Text, Image, Rating, RatingSize
} from '@fluentui/react';

interface VideoItem {
  title: string;
  url: string;
}

const FluentVideoHub: React.FC<IFluentVideoHubProps> = ({ context, libraryName }) => {
  const [videos, setVideos] = useState<VideoItem[]>([]);
  const [filtered, setFiltered] = useState<VideoItem[]>([]);
  const [current, setCurrent] = useState<VideoItem | null>(null);
  const [query, setQuery] = useState('');
  const [loading, setLoading] = useState(false);
  const [open, setOpen] = useState(false);
  const accent = '#FFD700';
  const carouselRef = useRef<HTMLDivElement>(null);   // ✨ new

  // Load video files
  useEffect(() => {
    if (!libraryName) return;
    setLoading(true);
    const endpoint = `${context.pageContext.web.absoluteUrl}/_api/web/lists/getbytitle('${libraryName}')/items?$select=File/Name,File/ServerRelativeUrl&$expand=File`;
    context.spHttpClient.get(endpoint, SPHttpClient.configurations.v1)
      .then(res => res.json())
      .then(data => {
        const sitePath = context.pageContext.site.serverRelativeUrl.toLowerCase();
        const vids = data.value
          .filter((f: any) => f.File && f.File.Name.toLowerCase().endsWith('.mp4'))
          .map((f: any) => {
            let rel = f.File.ServerRelativeUrl as string;
            const pattern = new RegExp(`${sitePath}${sitePath}`, 'i');
            if (pattern.test(rel.toLowerCase())) rel = rel.replace(pattern, sitePath);
            return {
              title: f.File.Name,
              url: `${context.pageContext.site.absoluteUrl}${encodeURI(rel)}`
            };
          });
        setVideos(vids);
        setFiltered(vids);
      })
      .catch(console.error)
      .then(() => setLoading(false));
  }, [libraryName]);

  useEffect(() => {
    const q = query.toLowerCase();
    setFiltered(videos.filter(v => v.title.toLowerCase().includes(q)));
  }, [query, videos]);

  // ✨ Carousel scroll helpers
  const scroll = (dir: 'left' | 'right') => {
    const node = carouselRef.current;
    if (node) node.scrollBy({ left: dir === 'left' ? -300 : 300, behavior: 'smooth' });
  };

  return (
    <Stack tokens={{ childrenGap: 16 }} styles={{ root: { padding: 20, background: '#fff', color: '#000' } }}>
      <Stack horizontal horizontalAlign="space-between" verticalAlign="end">
        <Text variant="xLarge" styles={{ root: { fontWeight: 600 } }}>Fluent Video Hub v2</Text>
        <TextField
          placeholder="Search videos..."
          value={query}
          onChange={(_, v) => setQuery(v || '')}
          styles={{ root: { width: 260 } }}
        />
      </Stack>

      {loading && <Text>Loading videos...</Text>}

      {!loading && (
        <Stack horizontal verticalAlign="center" tokens={{ childrenGap: 8 }}>
          {/* ✨ Carousel buttons */}
          <IconButton iconProps={{ iconName: 'ChevronLeft' }} onClick={() => scroll('left')} />
          <div
            ref={carouselRef}
            style={{
              display: 'flex',
              overflowX: 'auto',
              gap: 12,
              scrollBehavior: 'smooth',
              padding: '8px 0'
            }}
          >
            {filtered.map((v, i) => (
              <Stack
                key={i}
                styles={{
                  root: {
                    minWidth: 200,
                    background: '#fdfdfd',
                    borderRadius: 8,
                    boxShadow: '0 2px 6px rgba(0,0,0,0.1)',
                    border: `2px solid ${accent}`,
                    cursor: 'pointer',
                    padding: 8
                  }
                }}
                onClick={() => { setCurrent(v); setOpen(true); }}
              >
                <Image
                  src={`${context.pageContext.site.absoluteUrl}/_layouts/15/getpreview.ashx?path=${encodeURIComponent(v.url)}`}
                  alt={v.title}
                  height={120}
                  imageFit={3}
                  styles={{ root: { borderRadius: 6, marginBottom: 6 } }}
                />
                <Text styles={{ root: { fontWeight: 600, fontSize: 14 } }}>{v.title}</Text>
              </Stack>
            ))}
          </div>
          <IconButton iconProps={{ iconName: 'ChevronRight' }} onClick={() => scroll('right')} />
        </Stack>
      )}

      {/* ✨ Video Panel with Rating */}
      <Panel
        isOpen={open}
        type={PanelType.large}
        onDismiss={() => setOpen(false)}
        headerText={current?.title}
        isLightDismiss
        styles={{ main: { background: '#fff' } }}
      >
        {current && (
          <Stack tokens={{ childrenGap: 12 }}>
            <video
              src={current.url}
              controls
              style={{ width: '100%', borderRadius: 8, border: `3px solid ${accent}` }}
            />
            <Stack horizontal horizontalAlign="space-between" verticalAlign="center">
              <Text variant="medium">{current.title}</Text>
              {/* ✨ Fluent UI Rating component */}
              <Rating
                max={5}
                size={RatingSize.Large}
                ariaLabelFormat="Select {0} of {1} stars"
                styles={{
                  ratingStarFront: { color: accent },
                  ratingStarBack: { color: '#ddd' }
                }}
              />
            </Stack>
          </Stack>
        )}
      </Panel>
    </Stack>
  );
};

export default FluentVideoHub;


🧠 4️⃣ How the Carousel Works

Instead of importing a third-party slider, we leverage a simple combination:

  • A horizontal Stack (display:flex; overflow-x:auto)
  • Two Fluent IconButtons with ChevronLeft and ChevronRight
  • scrollBy({ left: ±300 }) for smooth motion

This approach is lightweight, responsive, and inherits full keyboard accessibility from the browser.


⭐ 5️⃣ Adding Ratings

Fluent UI provides a native Rating component.
We apply a gold accent and larger size:

<Rating
  max={5}
  size={RatingSize.Large}
  styles={{
    ratingStarFront: { color: '#FFD700' },
    ratingStarBack: { color: '#ddd' }
  }}
/>

You can later persist ratings to a SharePoint list or store them in localStorage.


📊 6️⃣ Feature Summary

FeatureDescription
Carousel scrollHorizontal scrollable gallery with arrow buttons
RatingFluent UI stars for quick feedback
Lightweight UINo extra CSS files or external libs
AccessibleKeyboard + screen reader ready
ReusableWorks in any SharePoint site collection

⚙️ 7️⃣ Build and Deploy

gulp build
gulp bundle --ship
gulp package-solution --ship

Upload .sppkg → App Catalog → Add to page.


💡 8️⃣ Possible Next Steps

  • 🕹️ Add autoplay and next-video queue
  • 📊 Persist ratings to SharePoint list items
  • 🌓 Support dark mode via Fluent theme tokens
  • 🔉 Include audio waveform preview or captions
  • 🧱 Integrate Graph API for training analytics

🏁 Conclusion

With a few lines of Fluent UI, we transformed a static gallery into a dynamic, interactive video hub.
The carousel layout keeps navigation simple and accessible, while the rating stars invite engagement.
Everything remains pure SPFx + Fluent UI — no third-party dependencies, full theming support, and production-ready design for modern SharePoint portals.

Edvaldo Guimrães Filho Avatar

Published by