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
| Enhancement | Benefit |
|---|---|
| Carousel scroll | Lets users browse many videos smoothly without grid overflow. |
| Rating stars | Adds simple engagement feedback and visual polish. |
| Full Fluent UI | Keeps 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
| Feature | Description |
|---|---|
| Carousel scroll | Horizontal scrollable gallery with arrow buttons |
| Rating | Fluent UI stars for quick feedback |
| Lightweight UI | No extra CSS files or external libs |
| Accessible | Keyboard + screen reader ready |
| Reusable | Works 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.
