diff --git a/addon/components/o-s-s/scrollable-bar.hbs b/addon/components/o-s-s/scrollable-bar.hbs
new file mode 100644
index 000000000..de807a3d1
--- /dev/null
+++ b/addon/components/o-s-s/scrollable-bar.hbs
@@ -0,0 +1,47 @@
+
\ No newline at end of file
diff --git a/addon/components/o-s-s/scrollable-bar.stories.js b/addon/components/o-s-s/scrollable-bar.stories.js
new file mode 100644
index 000000000..3ef60ff5d
--- /dev/null
+++ b/addon/components/o-s-s/scrollable-bar.stories.js
@@ -0,0 +1,53 @@
+import { hbs } from 'ember-cli-htmlbars';
+
+export default {
+ title: 'Components/OSS::ScrollableBar',
+ component: 'scrollable-bar',
+ argTypes: {
+ buttonStyle: {
+ description: 'Updates the style of scroll buttons. Defaults to "button"',
+ table: {
+ type: { summary: 'button | icon' },
+ defaultValue: { summary: 'undefined' }
+ },
+ control: {
+ type: 'text'
+ }
+ }
+ },
+ parameters: {
+ docs: {
+ description: {
+ component: 'Container which automatically handle horizontal scroll behavior with left and right buttons'
+ }
+ }
+ }
+};
+
+const defaultArgs = {
+ buttonStyle: undefined
+};
+
+const Template = (args) => ({
+ template: hbs`
+
+
+ <:content as |callbacks|>
+ All
+ Replies
+ Applications
+ Emails
+ Payments
+ Applications2
+ Emails2
+ Payments2
+
+
+
+ `,
+
+ context: args
+});
+
+export const BasicUsage = Template.bind({});
+BasicUsage.args = defaultArgs;
diff --git a/addon/components/o-s-s/scrollable-bar.ts b/addon/components/o-s-s/scrollable-bar.ts
new file mode 100644
index 000000000..b91b4f3ba
--- /dev/null
+++ b/addon/components/o-s-s/scrollable-bar.ts
@@ -0,0 +1,102 @@
+import { action } from '@ember/object';
+import { guidFor } from '@ember/object/internals';
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+
+interface OSSScrollableBarComponentSignature {
+ buttonStyle?: 'button' | 'icon';
+}
+
+export default class extends Component {
+ @tracked filterEntries: IntersectionObserverEntry[] = [];
+
+ scrollableBarId: string = guidFor(this);
+ declare intersectionObserver: IntersectionObserver;
+
+ constructor(owner: unknown, args: OSSScrollableBarComponentSignature) {
+ super(owner, args);
+
+ this.intersectionObserver = new IntersectionObserver(this.intersectionObserverCallback.bind(this), {
+ root: null,
+ rootMargin: '0px',
+ threshold: [0, 1]
+ });
+ }
+
+ get containerElement(): HTMLElement {
+ return document.querySelector(`#${this.scrollableBarId} .inner-container`) as HTMLElement;
+ }
+
+ get scrollWidth(): number {
+ return this.containerElement.clientWidth / 3;
+ }
+
+ get isLeftScrollable(): boolean {
+ if (this.filterEntries.length === 0 || this.getFilterNodes().length === 0) return false;
+ const entry = this.filterEntries.find((f) => f.target === this.getFilterNodes()[0]);
+ return entry !== undefined && entry.intersectionRatio < 1;
+ }
+
+ get isRightScrollable(): boolean {
+ if (this.filterEntries.length === 0 || this.getFilterNodes().length === 0) return false;
+ const entry = this.filterEntries.find((f) => f.target === this.getFilterNodes()[this.getFilterNodes().length - 1]);
+ return entry !== undefined && entry.intersectionRatio < 1;
+ }
+
+ get buttonStyle(): string {
+ return this.args.buttonStyle === 'icon' ? 'icon' : 'button';
+ }
+
+ get innerContainerStyle(): string {
+ const baseStyle = this.args.buttonStyle === 'icon' ? 'inner-container-icon' : 'inner-container-btn';
+ let innerContainerStyle = 'inner-container';
+ if (this.isLeftScrollable) {
+ innerContainerStyle += ` ${baseStyle}--scrollable-left`;
+ }
+ if (this.isRightScrollable) {
+ innerContainerStyle += ` ${baseStyle}--scrollable-right`;
+ }
+ return innerContainerStyle;
+ }
+
+ @action
+ observeIntersection(element: HTMLElement): void {
+ this.intersectionObserver.observe(element);
+ if (this.containerElement) {
+ this.containerElement.scrollLeft = this.containerElement.scrollWidth;
+ }
+ }
+
+ @action
+ unobserveIntersection(element: HTMLElement): void {
+ this.filterEntries = this.filterEntries.filter((e) => e.target !== element);
+ this.intersectionObserver.unobserve(element);
+ }
+
+ @action
+ scrollToRight(): void {
+ this.containerElement.scrollBy({ left: this.scrollWidth, behavior: 'smooth' });
+ }
+
+ @action
+ scrollToLeft(): void {
+ this.containerElement.scrollBy({ left: -this.scrollWidth, behavior: 'smooth' });
+ }
+
+ private getFilterNodes(): HTMLElement[] {
+ return Array.from(this.containerElement?.childNodes || []).filter(
+ (node): node is HTMLElement => node instanceof HTMLElement
+ );
+ }
+
+ private intersectionObserverCallback(entries: IntersectionObserverEntry[]): void {
+ entries.forEach((entry) => {
+ if (entry.rootBounds) {
+ this.filterEntries = [...this.filterEntries.filter((e) => e.target !== entry.target), entry];
+ } else {
+ this.filterEntries = [...this.filterEntries.filter((e) => e.target !== entry.target)];
+ }
+ });
+ }
+}
diff --git a/app/components/o-s-s/scrollable-bar.js b/app/components/o-s-s/scrollable-bar.js
new file mode 100644
index 000000000..48d0534a0
--- /dev/null
+++ b/app/components/o-s-s/scrollable-bar.js
@@ -0,0 +1 @@
+export { default } from '@upfluence/oss-components/components/o-s-s/scrollable-bar';
diff --git a/app/styles/organisms/scrollable-bar.less b/app/styles/organisms/scrollable-bar.less
new file mode 100644
index 000000000..e12920f87
--- /dev/null
+++ b/app/styles/organisms/scrollable-bar.less
@@ -0,0 +1,120 @@
+.scrollable-bar-container {
+ display: flex;
+ overflow: hidden;
+ position: relative;
+ flex: 1;
+
+ .scroll-to-left-btn,
+ .scroll-to-left-icon {
+ position: absolute;
+ left: 0;
+ z-index: 1;
+ }
+
+ .scroll-to-left-icon,
+ .scroll-to-right-icon {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background-color: white;
+ height: 100%;
+ width: 12px;
+ border: none;
+
+ &:hover {
+ color: var(--color-gray-500);
+ }
+ }
+
+ .scroll-to-right-btn,
+ .scroll-to-right-icon {
+ position: absolute;
+ right: 0;
+ z-index: 1;
+ }
+
+ .scroll-to-left-btn,
+ .scroll-to-right-btn {
+ top: var(--spacing-px-9);
+ visibility: hidden;
+ opacity: 0;
+ transition: all 300ms ease-in-out;
+ }
+
+ .scroll-to-left-icon,
+ .scroll-to-right-icon {
+ visibility: visible;
+ opacity: 1;
+ }
+
+ &:hover {
+ .scroll-to-left-btn,
+ .scroll-to-right-btn {
+ visibility: visible;
+ opacity: 1;
+ }
+ }
+
+ .inner-container {
+ display: inline-flex;
+ flex-wrap: nowrap;
+ justify-content: flex-start;
+ align-items: center;
+ gap: var(--spacing-px-9);
+ overflow: auto;
+ padding: var(--spacing-px-9) var(--spacing-px-3) var(--spacing-px-9) var(--spacing-px-3);
+
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+ }
+
+ .inner-container-btn {
+ &--scrollable-right::after {
+ position: absolute;
+ content: ' ';
+ right: 0;
+ height: 100%;
+ width: 36px;
+ background: linear-gradient(180deg, rgba(249, 250, 251, 0.11323) 0%, var(--color-gray-50) 100%);
+ transform: rotate(-90deg);
+ }
+
+ &--scrollable-left::before {
+ position: absolute;
+ content: ' ';
+ left: 0;
+ height: 100%;
+ width: 36px;
+ background: linear-gradient(180deg, rgba(249, 250, 251, 0.11323) 0%, var(--color-gray-50) 100%);
+ transform: rotate(90deg);
+ }
+ }
+
+ .inner-container-icon {
+ &--scrollable-right::after {
+ position: absolute;
+ content: ' ';
+ right: 12px;
+ height: 100%;
+ width: 36px;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, var(--color-white) 100%);
+
+ transform: rotate(-90deg);
+ }
+
+ &--scrollable-left::before {
+ position: absolute;
+ content: ' ';
+ left: 12px;
+ height: 100%;
+ width: 36px;
+ background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, var(--color-white) 100%);
+
+ transform: rotate(90deg);
+ }
+ }
+}
diff --git a/app/styles/oss-components.less b/app/styles/oss-components.less
index 3ca48c95f..0dde3ab2d 100644
--- a/app/styles/oss-components.less
+++ b/app/styles/oss-components.less
@@ -78,6 +78,7 @@
@import 'organisms/modal-dialog';
@import 'organisms/split-modal';
@import 'organisms/content-panel';
+@import 'organisms/scrollable-bar';
@import 'organisms/scrollable-panel';
@import 'organisms/sidebar';
@import 'organisms/access-panel';
diff --git a/tests/dummy/app/templates/visual.hbs b/tests/dummy/app/templates/visual.hbs
index 0d9f5a749..8437ff520 100644
--- a/tests/dummy/app/templates/visual.hbs
+++ b/tests/dummy/app/templates/visual.hbs
@@ -903,4 +903,48 @@
+
+
+
+ Scrollable bar
+
+
+
+ With default "button" style:
+
+
+ <:content as |callbacks|>
+ All
+ Replies
+ Applications
+ Emails
+ Payments
+ Applications2
+ Emails2
+ Payments2
+
+
+
+
+
+ With "icon" style:
+
+
+ <:content as |callbacks|>
+ All
+ Replies
+ Applications
+ Emails
+ Payments
+ Applications2
+ Emails2
+ Payments2
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/integration/components/o-s-s/scrollable-bar-test.ts b/tests/integration/components/o-s-s/scrollable-bar-test.ts
new file mode 100644
index 000000000..9081924b9
--- /dev/null
+++ b/tests/integration/components/o-s-s/scrollable-bar-test.ts
@@ -0,0 +1,105 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'ember-qunit';
+import { click, render, settled, waitUntil, type TestContext } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+
+module('Integration | Component | o-s-s/scrollable-bar', function (hooks) {
+ setupRenderingTest(hooks);
+ hooks.beforeEach(function () {
+ this.items = ['All', 'Replies', 'Applications', 'Emails', 'Payment', 'Drafts'];
+ });
+
+ module('With default button', function () {
+ test('it renders only the left button if the scroll position is already fully at the right', async function (this: TestContext, assert) {
+ await renderComponentDefaultButton();
+ await waitUntil(
+ function () {
+ return Boolean(document.querySelector('.scroll-to-left-btn'));
+ },
+ { timeout: 2000 }
+ );
+ assert.dom('.scroll-to-left-btn').exists();
+ assert.dom('.scroll-to-right-btn').doesNotExist();
+ });
+
+ test('it renders only the right button if the scroll position is already fully at the left', async function (this: TestContext, assert) {
+ await renderComponentDefaultButton();
+ await settled();
+ await waitUntil(
+ function () {
+ return Boolean(document.querySelector('.scroll-to-left-btn'));
+ },
+ { timeout: 2000 }
+ );
+ await click('.scroll-to-left-btn');
+ await waitUntil(
+ function () {
+ return !document.querySelector('.scroll-to-left-btn');
+ },
+ { timeout: 2000 }
+ );
+
+ assert.dom('.scroll-to-left-btn').doesNotExist();
+ assert.dom('.scroll-to-right-btn').exists();
+ });
+ });
+
+ module('With icon button', function () {
+ test('it renders only the left button if the scroll position is already fully at the right', async function (this: TestContext, assert) {
+ await renderComponentWithIcon();
+ await waitUntil(
+ function () {
+ return Boolean(document.querySelector('.scroll-to-left-icon'));
+ },
+ { timeout: 2000 }
+ );
+ assert.dom('.scroll-to-left-icon').exists();
+ assert.dom('.scroll-to-right-icon').doesNotExist();
+ });
+
+ test('it renders only the right button if the scroll position is already fully at the left', async function (this: TestContext, assert) {
+ await renderComponentWithIcon();
+ await settled();
+ await waitUntil(
+ function () {
+ return Boolean(document.querySelector('.scroll-to-left-icon'));
+ },
+ { timeout: 2000 }
+ );
+ await click('.scroll-to-left-icon');
+ await waitUntil(
+ function () {
+ return !document.querySelector('.scroll-to-left-icon');
+ },
+ { timeout: 2000 }
+ );
+
+ assert.dom('.scroll-to-left-icon').doesNotExist();
+ assert.dom('.scroll-to-right-icon').exists();
+ });
+ });
+
+ async function renderComponentDefaultButton() {
+ await render(hbs`
+
+ <:content as |callbacks|>
+ {{#each this.items as |item|}}
+ {{item}}
+ {{/each}}
+
+
+ `);
+ }
+
+ async function renderComponentWithIcon() {
+ await render(hbs`
+
+ <:content as |callbacks|>
+ {{#each this.items as |item|}}
+ {{item}}
+ {{/each}}
+
+
+ `);
+ }
+});