diff --git a/packages/design-system/lib/components/VideoPlayer/Button/button.module.css b/packages/design-system/lib/components/VideoPlayer/Button/button.module.css
new file mode 100644
index 000000000..185f21523
--- /dev/null
+++ b/packages/design-system/lib/components/VideoPlayer/Button/button.module.css
@@ -0,0 +1,53 @@
+.videoPlayerButton {
+ border-radius: var(--Corner-radius-Rounded);
+ box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.1);
+ height: 56px;
+ width: 56px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ overflow: hidden;
+ cursor: pointer;
+ z-index: 0;
+ border-width: 0;
+ border-style: solid;
+ border-color: var(--Base-Border-Subtle);
+ background-color: transparent;
+ color: var(--Component-Button-Inverted-On-fill-Default);
+
+ @media (hover: hover) {
+ &:hover .iconWrapper {
+ background-color: var(--Component-Button-Inverted-Fill-Hover);
+ color: var(--Component-Button-Inverted-On-fill-Hover);
+ }
+ }
+
+ &:focus-visible {
+ border-width: 2px;
+ outline: 2px solid var(--Border-Inverted);
+
+ .transparentBackground {
+ background-color: var(--Base-Border-Subtle);
+ }
+ }
+}
+
+.transparentBackground {
+ background-color: var(--Base-Surface-Primary-light-Normal);
+ border-radius: var(--Corner-radius-Rounded);
+ opacity: 0.5;
+ position: absolute;
+ inset: 0;
+ z-index: -1;
+}
+
+.iconWrapper {
+ background: var(--Base-Surface-Primary-light-Normal);
+ border: 1px solid var(--Base-Border-Subtle);
+ border-radius: var(--Corner-radius-Rounded);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--Space-x05);
+}
diff --git a/packages/design-system/lib/components/VideoPlayer/Button/index.tsx b/packages/design-system/lib/components/VideoPlayer/Button/index.tsx
new file mode 100644
index 000000000..24d759ff4
--- /dev/null
+++ b/packages/design-system/lib/components/VideoPlayer/Button/index.tsx
@@ -0,0 +1,39 @@
+'use client'
+
+import { Button as ButtonRAC } from 'react-aria-components'
+import { MaterialIcon } from '../../Icons/MaterialIcon'
+import styles from './button.module.css'
+
+interface VideoPlayerButtonProps {
+ onPress: () => void
+ iconName: 'play_arrow' | 'pause' | 'volume_up' | 'volume_off'
+ ariaLabel: string
+ className?: string
+}
+
+export function VideoPlayerButton({
+ onPress,
+ ariaLabel,
+ iconName,
+ className,
+}: VideoPlayerButtonProps) {
+ return (
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/design-system/lib/components/VideoPlayer/VideoPlayer.stories.tsx b/packages/design-system/lib/components/VideoPlayer/VideoPlayer.stories.tsx
new file mode 100644
index 000000000..9d3e32bb3
--- /dev/null
+++ b/packages/design-system/lib/components/VideoPlayer/VideoPlayer.stories.tsx
@@ -0,0 +1,136 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+
+import { Lang } from '@scandic-hotels/common/constants/language'
+import { VideoPlayer } from '.'
+import { config as videoPlayerConfig } from './variants'
+
+const meta: Meta = {
+ title: 'Components/🚧 VideoPlayer 🚧',
+ component: VideoPlayer,
+
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'This component is not ready for production use. It is still under development and may undergo significant changes.',
+ },
+ },
+ },
+
+ argTypes: {
+ className: {
+ table: {
+ disable: true,
+ },
+ },
+ src: {
+ table: {
+ type: { summary: 'string' },
+ },
+ description: 'The source URL of the video.',
+ },
+ captions: {
+ table: {
+ type: {
+ summary: 'Caption[]',
+ detail: '{ src: string; srcLang: Lang; isDefault: boolean }[]',
+ },
+ },
+ description:
+ 'An array of caption objects for the video. Since this functionality only works when the controls are visible, captions are only supported in the inline variant.',
+ },
+ variant: {
+ control: 'select',
+ options: Object.keys(videoPlayerConfig.variants.variant),
+ table: {
+ defaultValue: {
+ summary: videoPlayerConfig.defaultVariants.variant,
+ },
+ type: {
+ summary: 'string',
+ detail: Object.keys(videoPlayerConfig.variants.variant).join(' | '),
+ },
+ },
+ description:
+ 'The variant of the video player, which determines its style and behavior. The hero variant is typically used for large, prominent video displays and defaults to autoplay and muted playback.',
+ },
+ focalPoint: {
+ table: {
+ type: { summary: 'FocalPoint', detail: '{ x: number; y: number }' },
+ defaultValue: { summary: '{ x: 50, y: 50 }' },
+ },
+ description: 'The focal point of the video thumbnail.',
+ },
+ autoPlay: {
+ table: {
+ type: { summary: 'boolean' },
+ defaultValue: { summary: 'false' },
+ },
+ description:
+ 'Whether the video should autoplay. Note that autoplay might be ignored by browsers unless the video is muted, which is the default behavior for this component.',
+ },
+ },
+}
+
+export default meta
+
+type Story = StoryObj
+
+const defaultArgs = {
+ src: 'https://eu-assets.contentstack.com/v3/assets/bltfd73aa2de3a5c4e3/bltad0fe3c2ce340947/68eced6c14e5a8150ebba18c/Scandic_EB_Master.mp4',
+ captions: [
+ {
+ src: './video/captions_en.vtt',
+ srcLang: Lang.en,
+ isDefault: false,
+ },
+ {
+ src: './video/captions_sv.vtt',
+ srcLang: Lang.sv,
+ isDefault: false,
+ },
+ ],
+}
+
+export const Default: Story = {
+ args: { ...defaultArgs },
+}
+
+export const Inline: Story = {
+ args: { ...Default.args, variant: 'inline' },
+}
+
+export const BareHero: Story = {
+ args: {
+ ...Default.args,
+ variant: 'hero',
+ },
+ name: 'Hero (barebones)',
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'The Hero variant is intended for use as a large video player, typically placed at the top of a page or section. It features autoplay and muted playback. It is pretty bare bones and requires a "wrapper" to override its size and aspect ratio. See the example below.',
+ },
+ },
+ },
+}
+
+export const Hero: Story = {
+ args: {
+ ...BareHero.args,
+ },
+ render: (args) => (
+
+
+
+ ),
+}
diff --git a/packages/design-system/lib/components/VideoPlayer/index.tsx b/packages/design-system/lib/components/VideoPlayer/index.tsx
new file mode 100644
index 000000000..41d07f24d
--- /dev/null
+++ b/packages/design-system/lib/components/VideoPlayer/index.tsx
@@ -0,0 +1,179 @@
+'use client'
+
+import { cx, VariantProps } from 'class-variance-authority'
+import { useRef, useState, VideoHTMLAttributes } from 'react'
+
+import { Lang, languages } from '@scandic-hotels/common/constants/language'
+import { FocalPoint } from '@scandic-hotels/common/utils/imageVault'
+import { useIntl } from 'react-intl'
+import { VideoPlayerButton } from './Button'
+import { variants } from './variants'
+import styles from './videoPlayer.module.css'
+
+interface Caption {
+ src: string
+ srcLang: Lang
+ isDefault: boolean
+}
+
+interface VideoPlayerProps extends VariantProps {
+ src: string
+ className?: string
+ captions?: Caption[]
+ focalPoint?: FocalPoint
+ autoPlay?: boolean
+}
+
+export function VideoPlayer({
+ src,
+ captions,
+ focalPoint = { x: 50, y: 50 },
+ className,
+ variant = 'inline',
+ autoPlay,
+}: VideoPlayerProps) {
+ const intl = useIntl()
+ const videoRef = useRef(null)
+ const [isActivated, setIsActivated] = useState(
+ (variant === 'hero' && (autoPlay ?? true)) || !!autoPlay
+ )
+ const [isPlaying, setIsPlaying] = useState(autoPlay ?? false)
+ const [isMuted, setIsMuted] = useState(true)
+ const defaultProps = getVideoPropsByVariant(variant, isActivated, autoPlay)
+
+ const classNames = variants({
+ className,
+ variant,
+ })
+
+ function togglePlay() {
+ const videoElement = videoRef.current
+ if (videoElement) {
+ if (variant === 'hero') {
+ if (videoElement.paused) {
+ videoElement.play()
+ } else {
+ videoElement.pause()
+ }
+ } else {
+ setIsActivated(true)
+ videoElement.play()
+ }
+ }
+ }
+
+ function handleMuteToggle() {
+ const videoElement = videoRef.current
+ if (videoElement) {
+ const currentlyMuted = videoElement.muted
+ videoElement.muted = !currentlyMuted
+ setIsMuted(!currentlyMuted)
+ }
+ }
+
+ function handleVolumeChangeEvent(event: React.UIEvent) {
+ if (event.currentTarget.muted && !isMuted) {
+ setIsMuted(true)
+ } else if (!event.currentTarget.muted && isMuted) {
+ setIsMuted(false)
+ }
+ }
+
+ const showPlayButton =
+ variant === 'hero' || (variant === 'inline' && !isActivated)
+ const showMuteButton = variant === 'inline' && isActivated
+
+ return (
+
+
+ {showPlayButton ? (
+
+ ) : null}
+ {showMuteButton ? (
+
+ ) : null}
+
+ )
+}
+
+function getVideoPropsByVariant(
+ variant: VideoPlayerProps['variant'],
+ isActive: boolean,
+ autoPlay?: boolean
+): VideoHTMLAttributes {
+ switch (variant) {
+ case 'hero':
+ return {
+ controls: false,
+ controlsList: 'nodownload nofullscreen noremoteplayback',
+ autoPlay: autoPlay ?? true,
+ muted: true,
+ loop: true,
+ }
+ case 'inline':
+ default:
+ return {
+ controls: isActive,
+ controlsList: 'nodownload noremoteplayback',
+ autoPlay: autoPlay ?? isActive,
+ muted: true,
+ loop: false,
+ }
+ }
+}
diff --git a/packages/design-system/lib/components/VideoPlayer/variants.ts b/packages/design-system/lib/components/VideoPlayer/variants.ts
new file mode 100644
index 000000000..fc47e05ec
--- /dev/null
+++ b/packages/design-system/lib/components/VideoPlayer/variants.ts
@@ -0,0 +1,17 @@
+import { cva } from 'class-variance-authority'
+
+import styles from './videoPlayer.module.css'
+
+export const config = {
+ variants: {
+ variant: {
+ inline: styles.inline,
+ hero: styles.hero,
+ },
+ },
+ defaultVariants: {
+ variant: 'inline',
+ },
+} as const
+
+export const variants = cva(styles.videoPlayer, config)
diff --git a/packages/design-system/lib/components/VideoPlayer/videoPlayer.module.css b/packages/design-system/lib/components/VideoPlayer/videoPlayer.module.css
new file mode 100644
index 000000000..f881c5a98
--- /dev/null
+++ b/packages/design-system/lib/components/VideoPlayer/videoPlayer.module.css
@@ -0,0 +1,48 @@
+.videoPlayer {
+ position: relative;
+ width: 100%;
+ aspect-ratio: auto;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ &.inline {
+ border-radius: var(--Corner-radius-md);
+ overflow: hidden;
+ }
+
+ &.hero {
+ height: 100%;
+ .overlay {
+ display: contents;
+ }
+ .playButton {
+ position: absolute;
+ bottom: var(--Space-x2);
+ right: var(--Space-x2);
+ }
+ }
+}
+
+.video {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.playButton {
+ position: absolute;
+}
+
+.muteButton {
+ position: absolute;
+ top: var(--Space-x2);
+ right: var(--Space-x2);
+}
+
+@media screen and (min-width: 768px) {
+ .videoPlayer.hero .playButton {
+ bottom: var(--Space-x4);
+ right: var(--Space-x4);
+ }
+}
diff --git a/packages/design-system/lib/fonts.css b/packages/design-system/lib/fonts.css
index feb992639..5a43f5b3d 100644
--- a/packages/design-system/lib/fonts.css
+++ b/packages/design-system/lib/fonts.css
@@ -276,7 +276,7 @@
font-style: normal;
font-weight: 400;
font-display: block;
- src: url(/_static/shared/fonts/material-symbols/rounded-3e9207ba.woff2)
+ src: url(/_static/shared/fonts/material-symbols/rounded-fa2883c6.woff2)
format('woff2');
}
diff --git a/packages/design-system/public/video/captions_en.vtt b/packages/design-system/public/video/captions_en.vtt
new file mode 100644
index 000000000..d849f4343
--- /dev/null
+++ b/packages/design-system/public/video/captions_en.vtt
@@ -0,0 +1,16 @@
+WEBVTT
+
+00:00:01.000 --> 00:00:10.000
+EN: Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+
+00:00:13.000 --> 00:00:25.000
+EN: Curabitur vitae neque non ipsum efficitur hendrerit at ut nulla.
+
+00:00:28.000 --> 00:00:48.000
+EN: Cras in tellus et ligula posuere ullamcorper.
+
+00:00:51.000 --> 00:01:18.000
+EN: Praesent pulvinar rutrum metus ut gravida.
+
+00:01:25.000 --> 00:02:10.000
+The end...
diff --git a/packages/design-system/public/video/captions_sv.vtt b/packages/design-system/public/video/captions_sv.vtt
new file mode 100644
index 000000000..53070f4e5
--- /dev/null
+++ b/packages/design-system/public/video/captions_sv.vtt
@@ -0,0 +1,16 @@
+WEBVTT
+
+00:00:01.000 --> 00:00:10.000
+SV: Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+
+00:00:13.000 --> 00:00:25.000
+SV: Curabitur vitae neque non ipsum efficitur hendrerit at ut nulla.
+
+00:00:28.000 --> 00:00:48.000
+SV: Cras in tellus et ligula posuere ullamcorper.
+
+00:00:51.000 --> 00:01:18.000
+SV: Praesent pulvinar rutrum metus ut gravida.
+
+00:01:25.000 --> 00:02:10.000
+Slut...
diff --git a/scripts/material-symbols-update.mts b/scripts/material-symbols-update.mts
index 748b8654a..ecdcb1ab5 100644
--- a/scripts/material-symbols-update.mts
+++ b/scripts/material-symbols-update.mts
@@ -1,7 +1,7 @@
import crypto from "node:crypto";
-import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { createWriteStream } from "node:fs";
-import { resolve, join } from "node:path";
+import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import { join, resolve } from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
@@ -168,11 +168,13 @@ const icons = [
"open_in_new",
"pan_zoom",
"panorama",
+ "pause",
"pedal_bike",
"person",
"pets",
"phone",
"photo_camera",
+ "play_arrow",
"pool",
"print",
"radio",
@@ -220,6 +222,8 @@ const icons = [
"upload",
"visibility_off",
"visibility",
+ "volume_off",
+ "volume_up",
"ward",
"warning",
"water_full",
diff --git a/shared/fonts/material-symbols/.auto-generated b/shared/fonts/material-symbols/.auto-generated
index 0fc4617e0..640087447 100644
--- a/shared/fonts/material-symbols/.auto-generated
+++ b/shared/fonts/material-symbols/.auto-generated
@@ -1,3 +1,3 @@
Auto-generated, do not edit. Use scripts/material-symbols-update.mts to update.
-hash=3e9207ba
-created=2025-11-25T08:52:11.965Z
+hash=fa2883c6
+created=2025-12-01T10:31:09.165Z
diff --git a/shared/fonts/material-symbols/rounded-3e9207ba.woff2 b/shared/fonts/material-symbols/rounded-3e9207ba.woff2
deleted file mode 100644
index e6a4d619d..000000000
Binary files a/shared/fonts/material-symbols/rounded-3e9207ba.woff2 and /dev/null differ
diff --git a/shared/fonts/material-symbols/rounded-fa2883c6.woff2 b/shared/fonts/material-symbols/rounded-fa2883c6.woff2
new file mode 100644
index 000000000..16e34efb3
Binary files /dev/null and b/shared/fonts/material-symbols/rounded-fa2883c6.woff2 differ