diff --git a/libs/ui/src/lib/carousel/_carousel-theme.scss. b/libs/ui/src/lib/carousel/_carousel-theme.scss.
new file mode 100644
index 000000000..4c28229a4
--- /dev/null
+++ b/libs/ui/src/lib/carousel/_carousel-theme.scss.
@@ -0,0 +1,16 @@
+@use 'sass:map';
+@use '@angular/material' as mat;
+
+@mixin theme($theme) {
+ $primary: map.get($theme, primary);
+ $accent: map.get($theme, accent);
+ $warn: map.get($theme, warn);
+ $background: map.get($theme, background);
+ $foreground: map.get($theme, foreground);
+
+ .docs-carousel-nav.mat-mdc-mini-fab {
+ background: mat.get-color-from-palette($background, background);
+ color: mat.get-color-from-palette($foreground, secondary-text)
+ }
+
+}
\ No newline at end of file
diff --git a/libs/ui/src/lib/carousel/carousel.html b/libs/ui/src/lib/carousel/carousel.html
new file mode 100644
index 000000000..efc91781c
--- /dev/null
+++ b/libs/ui/src/lib/carousel/carousel.html
@@ -0,0 +1,16 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/libs/ui/src/lib/carousel/carousel.scss b/libs/ui/src/lib/carousel/carousel.scss
new file mode 100644
index 000000000..15a2b0daa
--- /dev/null
+++ b/libs/ui/src/lib/carousel/carousel.scss
@@ -0,0 +1,37 @@
+app-carousel {
+ display: block;
+ position: relative;
+ }
+
+ .docs-carousel-content {
+ display: flex;
+ flex-direction: row;
+ outline: none;
+ transition: transform 0.5s ease-in-out;
+
+ .animations-disabled & {
+ transition: none;
+ }
+ }
+
+ .docs-carousel-content-wrapper {
+ overflow: hidden;
+ }
+
+ [carousel-item] {
+ flex-shrink: 0;
+ }
+
+ button.docs-carousel-nav {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ }
+
+ .docs-carousel-nav-prev {
+ left: -50px;
+ }
+
+ .docs-carousel-nav-next {
+ right: -50px;
+ }
\ No newline at end of file
diff --git a/libs/ui/src/lib/carousel/carousel.ts b/libs/ui/src/lib/carousel/carousel.ts
new file mode 100644
index 000000000..a025c6142
--- /dev/null
+++ b/libs/ui/src/lib/carousel/carousel.ts
@@ -0,0 +1,162 @@
+import {
+ AfterContentInit,
+ Component,
+ ContentChildren,
+ Directive,
+ ElementRef,
+ HostBinding,
+ Inject,
+ Input,
+ Optional,
+ QueryList,
+ ViewChild,
+ ViewEncapsulation,
+ } from '@angular/core';
+ import {FocusableOption, FocusKeyManager} from '@angular/cdk/a11y';
+ import {LEFT_ARROW, RIGHT_ARROW, TAB} from '@angular/cdk/keycodes';
+ import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations';
+ import {MatIconModule} from '@angular/material/icon';
+ import {MatButtonModule} from '@angular/material/button';
+ import {NgIf} from '@angular/common';
+
+ @Directive({
+ selector: '[carousel-item]',
+ standalone: true,
+ })
+ export class CarouselItem implements FocusableOption {
+ @HostBinding('attr.role') readonly role = 'listitem';
+ @HostBinding('tabindex') tabindex = '-1';
+
+ constructor(readonly element: ElementRef) {}
+
+ focus(): void {
+ this.element.nativeElement.focus({preventScroll: true});
+ }
+ }
+
+ @Component({
+ selector: 'app-carousel',
+ templateUrl: './carousel.html',
+ styleUrls: ['./carousel.scss'],
+ encapsulation: ViewEncapsulation.None,
+ standalone: true,
+ imports: [
+ NgIf,
+ MatButtonModule,
+ MatIconModule,
+ ],
+ })
+ export class Carousel implements AfterContentInit {
+ @Input('aria-label') ariaLabel: string | undefined;
+ @ContentChildren(CarouselItem) items!: QueryList;
+ @ViewChild('list') list!: ElementRef;
+ @HostBinding('class.animations-disabled') readonly animationsDisabled: boolean;
+ position = 0;
+ showPrevArrow = false;
+ showNextArrow = true;
+ index = 0;
+ private _keyManager!: FocusKeyManager;
+
+ onKeydown({keyCode}: KeyboardEvent) {
+ const manager = this._keyManager;
+ const previousActiveIndex = manager.activeItemIndex;
+
+ if (keyCode === LEFT_ARROW) {
+ manager.setPreviousItemActive();
+ } else if (keyCode === RIGHT_ARROW) {
+ manager.setNextItemActive();
+ } else if (keyCode === TAB && !manager.activeItem) {
+ manager.setFirstItemActive();
+ }
+
+ if (manager.activeItemIndex != null && manager.activeItemIndex !== previousActiveIndex) {
+ this.index = manager.activeItemIndex;
+ this._updateItemTabIndices();
+
+ if (this._isOutOfView(this.index)) {
+ this._scrollToActiveItem();
+ }
+ }
+ }
+
+ constructor(@Optional() @Inject(ANIMATION_MODULE_TYPE) animationsModule?: string) {
+ this.animationsDisabled = animationsModule === 'NoopAnimations';
+ }
+
+ ngAfterContentInit(): void {
+ this._keyManager = new FocusKeyManager(this.items);
+ }
+
+ /** Goes to the next set of items */
+ next() {
+ for (let i = this.index; i < this.items.length; i++) {
+ if (this._isOutOfView(i)) {
+ this.index = i;
+ this._scrollToActiveItem();
+ break;
+ }
+ }
+ }
+
+ /** Goes to the previous set of items. */
+ previous() {
+ for (let i = this.index; i > -1; i--) {
+ if (this._isOutOfView(i)) {
+ this.index = i;
+ this._scrollToActiveItem();
+ break;
+ }
+ }
+ }
+
+ /** Updates the `tabindex` of each of the items based on their active state. */
+ private _updateItemTabIndices() {
+ this.items.forEach((item: CarouselItem) => {
+ if (this._keyManager != null) {
+ item.tabindex = item === this._keyManager.activeItem ? '0' : '-1';
+ }
+ });
+ }
+
+ /** Scrolls an item into the viewport. */
+ private _scrollToActiveItem() {
+ if (!this._isOutOfView(this.index)) {
+ return;
+ }
+
+ const itemsArray = this.items.toArray();
+ let targetItemIndex = this.index;
+
+ // Only shift the carousel by one if we're going forwards. This
+ // looks better compared to moving the carousel by an entire page.
+ if (this.index > 0 && !this._isOutOfView(this.index - 1)) {
+ targetItemIndex = itemsArray.findIndex((_, i) => !this._isOutOfView(i)) + 1;
+ }
+
+ this.position = itemsArray[targetItemIndex].element.nativeElement.offsetLeft;
+ this.list.nativeElement.style.transform = `translateX(-${this.position}px)`;
+ this.showPrevArrow = this.index > 0;
+ this.showNextArrow = false;
+
+ for (let i = itemsArray.length - 1; i > -1; i--) {
+ if (this._isOutOfView(i, 'end')) {
+ this.showNextArrow = true;
+ break;
+ }
+ }
+ }
+
+ /** Checks whether an item at a specific index is outside of the viewport. */
+ private _isOutOfView(index: number, side?: 'start' | 'end') {
+ const {offsetWidth, offsetLeft} = this.items.toArray()[index].element.nativeElement;
+
+ if ((!side || side === 'start') && offsetLeft - this.position < 0) {
+ return true;
+ }
+
+ return (
+ (!side || side === 'end') &&
+ offsetWidth + offsetLeft - this.position > this.list.nativeElement.clientWidth
+ );
+ }
+ }
\ No newline at end of file