diff --git a/README.md b/README.md
index e9068ae..223b759 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,7 @@ Below is a list of the components available in this library. Each component has
- [BBBInput](./src/components/Input/README.md)
- [BBBModal](./src/components/Modal//README.md)
- [BBBNavigation](./src/components/Navigation/README.md)
+- [BBBScrollArea](./src/components/ScrollArea/README.md)
- [BBBSearch](./src/components/Search/README.md)
- [BBBSelect](./src/components/Select/README.md)
- [BBBSpinner](./src/components/Spinner//README.md)
@@ -96,6 +97,10 @@ The following table lists the supported CSS variables for color overriding, extr
| `--color-hover-dark` | No | #0C57A7 |
| `--color-hover-light` | No | #D4E5FA |
| `--color-hover-neutral` | No | #DCE4EC |
+| `--color-border-focus-ring`| No | rgba(29, 101, 212, 0.15) |
+| `--color-icon-default-dark`| No | rgba(255, 255, 255, 0.35) |
+| `--color-overlay` | No | rgba(0, 0, 0, 0.75) |
+| `--color-shadow-default` | No | rgb(0 35 11 / 20%) |
**Example Usage**:
```css
@@ -107,6 +112,28 @@ The following table lists the supported CSS variables for color overriding, extr
If you need to override colors for specific components or add new variables, refer to the component's `styles.ts` file for implementation details.
+### Importing Color Tokens in JS
+
+In addition to CSS variables, the same color tokens used internally by every component are also
+exported as a nested `colors` object, for use directly in JS/TS (e.g. in your own
+styled-components):
+
+```jsx
+// From the package root
+import { colors } from '@bigbluebutton/bbb-ui-components-react';
+
+// Or from the dedicated, tree-shakeable subpath
+import { colors } from '@bigbluebutton/bbb-ui-components-react/colors';
+
+const StyledDiv = styled.div`
+ color: ${colors.text.default};
+ background: ${colors.background.white};
+`;
+```
+
+`colors` is grouped the same way as the table above: `neutral`, `brand`, `semantic`, `background`,
+`border`, `text`, `icon`, `hover`.
+
## Installation
You can install the library directly from npm:
diff --git a/package.json b/package.json
index d10ece2..5537f94 100644
--- a/package.json
+++ b/package.json
@@ -71,6 +71,13 @@
"require": "./dist/components/Navigation.js",
"default": "./dist/components/Navigation.js"
},
+ "./ScrollArea": {
+ "types": "./dist/types/components/ScrollArea/index.d.ts",
+ "node": "./dist/components/ScrollArea.js",
+ "import": "./dist/esm/components/ScrollArea/index.js",
+ "require": "./dist/components/ScrollArea.js",
+ "default": "./dist/components/ScrollArea.js"
+ },
"./Search": {
"types": "./dist/types/components/Search/index.d.ts",
"node": "./dist/components/Search.js",
@@ -120,6 +127,13 @@
"require": "./dist/components/Typography.js",
"default": "./dist/components/Typography.js"
},
+ "./colors": {
+ "types": "./dist/types/stylesheets/colors.d.ts",
+ "node": "./dist/components/colors.js",
+ "import": "./dist/esm/stylesheets/colors.js",
+ "require": "./dist/components/colors.js",
+ "default": "./dist/components/colors.js"
+ },
"./dist/components": {
"types": "./dist/types/index.d.ts",
"require": "./dist/components/index.js",
@@ -188,6 +202,9 @@
"Navigation": [
"dist/types/components/Navigation/index.d.ts"
],
+ "ScrollArea": [
+ "dist/types/components/ScrollArea/index.d.ts"
+ ],
"Search": [
"dist/types/components/Search/index.d.ts"
],
@@ -208,6 +225,9 @@
],
"Typography": [
"dist/types/components/Typography/index.d.ts"
+ ],
+ "colors": [
+ "dist/types/stylesheets/colors.d.ts"
]
}
},
diff --git a/src/components/Accordion/README.md b/src/components/Accordion/README.md
index 3711f94..d110204 100644
--- a/src/components/Accordion/README.md
+++ b/src/components/Accordion/README.md
@@ -38,15 +38,27 @@ import { MdFavorite } from 'react-icons/md';
```
+### Accordion with a right-aligned button header
+
+```jsx
+import { BBBAccordion } from 'bbb-ui-components-react';
+import { MdEdit } from 'react-icons/md';
+
+} buttonHeaderPosition="right">
+
Content for the accordion.
+
+```
+
## Props
-| Property | Type | Default | Description |
-| ------------------ | -------------------------------------- | ----------- | ------------------------------------------------------------------------------ |
-| `title` | `string` | | The text to be displayed in the accordion header. |
-| `tooltipLabel` | `string` | `null` | An optional label for the tooltip that appears on hover. |
-| `tooltipPlacement` | `import('@tippyjs/react').Placement` | `'bottom'` | The placement of the tooltip. |
-| `ariaLabel` | `string` | | The accessible name for the expand button. |
-| `ariaLabelledBy` | `string` | | The ID of the element that labels the expand button. |
-| `ariaDescribedBy` | `string` | | The ID of the element that describes the expand button. |
-| `buttonHeader` | `React.ReactNode` | `null` | Optional content to be rendered inside the button header. |
-| `children` | `React.ReactNode` | | The content to be displayed when the accordion is expanded. |
+| Property | Type | Default | Description |
+| ----------------------- | -------------------------------------- | ----------- | ------------------------------------------------------------------------------ |
+| `title` | `string` | | The text to be displayed in the accordion header. |
+| `tooltipLabel` | `string` | `null` | An optional label for the tooltip that appears on hover. |
+| `tooltipPlacement` | `import('@tippyjs/react').Placement` | `'bottom'` | The placement of the tooltip. |
+| `ariaLabel` | `string` | | The accessible name for the expand button. |
+| `ariaLabelledBy` | `string` | | The ID of the element that labels the expand button. |
+| `ariaDescribedBy` | `string` | | The ID of the element that describes the expand button. |
+| `buttonHeader` | `React.ReactNode` | `null` | Optional content to be rendered inside the button header. |
+| `buttonHeaderPosition` | `'left' \| 'right'` | `'left'` | Position of `buttonHeader` within the header row. |
+| `children` | `React.ReactNode` | | The content to be displayed when the accordion is expanded. |
diff --git a/src/components/Accordion/component.stories.tsx b/src/components/Accordion/component.stories.tsx
index 7b54464..7a05344 100644
--- a/src/components/Accordion/component.stories.tsx
+++ b/src/components/Accordion/component.stories.tsx
@@ -1,7 +1,14 @@
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import BBBAccordion from './component';
-import { TOOLTIP_PLACEMENT_VALUES, DEFAULT_TOOLTIP_PLACEMENT } from './constants';
+import {
+ TOOLTIP_PLACEMENT_VALUES,
+ DEFAULT_TOOLTIP_PLACEMENT,
+ BUTTON_HEADER_POSITIONS,
+ BUTTON_HEADER_POSITION_VALUES,
+ DEFAULT_BUTTON_HEADER_POSITION,
+} from './constants';
+import { MdEdit } from 'react-icons/md';
import Typography from '../Typography/component';
const meta = {
@@ -44,6 +51,14 @@ const meta = {
control: false,
description: 'Optional React node rendered inside the button header.',
},
+ buttonHeaderPosition: {
+ control: 'select',
+ options: BUTTON_HEADER_POSITION_VALUES,
+ description: 'Position of `buttonHeader` within the header row.',
+ table: {
+ defaultValue: { summary: `${DEFAULT_BUTTON_HEADER_POSITION}` },
+ },
+ },
children: {
control: false,
description: 'Content shown when the accordion is expanded.',
@@ -79,3 +94,17 @@ export const WithTooltip: Story = {
),
},
};
+
+/** Shows `buttonHeaderPosition="right"` pushing the button header to the far edge of the header row. */
+export const WithRightAlignedButtonHeader: Story = {
+ args: {
+ title: 'Right-aligned Button Header',
+ buttonHeader: ,
+ buttonHeaderPosition: BUTTON_HEADER_POSITIONS.RIGHT,
+ children: (
+
+ Accordion content goes here.
+
+ ),
+ },
+};
diff --git a/src/components/Accordion/component.tsx b/src/components/Accordion/component.tsx
index 5fc0753..9524240 100644
--- a/src/components/Accordion/component.tsx
+++ b/src/components/Accordion/component.tsx
@@ -4,7 +4,7 @@ import * as Styled from './styles';
import { MdExpandMore } from 'react-icons/md';
import Tippy from '@tippyjs/react';
import 'tippy.js/dist/tippy.css';
-import { DEFAULT_TOOLTIP_PLACEMENT } from './constants';
+import { DEFAULT_TOOLTIP_PLACEMENT, DEFAULT_BUTTON_HEADER_POSITION } from './constants';
/**
* A customizable Accordion component that allows expanding and collapsing content.
@@ -21,6 +21,7 @@ function Accordion({
ariaLabelledBy,
ariaDescribedBy,
buttonHeader = null,
+ buttonHeaderPosition = DEFAULT_BUTTON_HEADER_POSITION,
children,
}: AccordionProps): JSX.Element {
const [isExpanded, setIsExpanded] = useState(false);
@@ -38,7 +39,9 @@ function Accordion({
{title}
- {buttonHeader}
+
+ {buttonHeader}
+
);
diff --git a/src/components/Accordion/constants.ts b/src/components/Accordion/constants.ts
index 79011e3..6dff066 100644
--- a/src/components/Accordion/constants.ts
+++ b/src/components/Accordion/constants.ts
@@ -7,8 +7,18 @@ const TOOLTIP_PLACEMENTS = {
const TOOLTIP_PLACEMENT_VALUES = Object.values(TOOLTIP_PLACEMENTS);
const DEFAULT_TOOLTIP_PLACEMENT = TOOLTIP_PLACEMENTS.TOP;
+const BUTTON_HEADER_POSITIONS = {
+ LEFT: 'left',
+ RIGHT: 'right',
+} as const;
+const BUTTON_HEADER_POSITION_VALUES = Object.values(BUTTON_HEADER_POSITIONS);
+const DEFAULT_BUTTON_HEADER_POSITION = BUTTON_HEADER_POSITIONS.LEFT;
+
export {
TOOLTIP_PLACEMENTS,
TOOLTIP_PLACEMENT_VALUES,
DEFAULT_TOOLTIP_PLACEMENT,
+ BUTTON_HEADER_POSITIONS,
+ BUTTON_HEADER_POSITION_VALUES,
+ DEFAULT_BUTTON_HEADER_POSITION,
}
\ No newline at end of file
diff --git a/src/components/Accordion/styles.ts b/src/components/Accordion/styles.ts
index 721cbb7..0850835 100644
--- a/src/components/Accordion/styles.ts
+++ b/src/components/Accordion/styles.ts
@@ -2,7 +2,7 @@ import styled from 'styled-components';
import { colorBackgroundLight, colorBrand1, colorTextDefault, colorWhite } from '../../stylesheets/palette';
import { fontSizeDefault } from '../../stylesheets/typography';
import { borderRadiusDefault, spacingMedium, spacingSmall } from '../../stylesheets/sizing';
-import { StyledAccordionContent, StyledExpandIcon } from './types';
+import { StyledAccordionContent, StyledExpandIcon, StyledButtonHeaderWrapper } from './types';
export const ExpandButton = styled.button`
display: flex;
@@ -51,6 +51,12 @@ export const ExpandIcon = styled.div`
}
`;
+export const ButtonHeaderWrapper = styled.span`
+ display: flex;
+ align-items: center;
+ margin-left: ${({ $position }) => ($position === 'right' ? 'auto' : '0')};
+`;
+
export const TitleText = styled.span`
font-size: ${fontSizeDefault};
font-weight: 400;
diff --git a/src/components/Accordion/types.ts b/src/components/Accordion/types.ts
index 87b09e4..2e61837 100644
--- a/src/components/Accordion/types.ts
+++ b/src/components/Accordion/types.ts
@@ -1,4 +1,4 @@
-import { TOOLTIP_PLACEMENT_VALUES } from './constants';
+import { TOOLTIP_PLACEMENT_VALUES, BUTTON_HEADER_POSITION_VALUES } from './constants';
import * as React from 'react';
export interface StyledExpandIcon {
@@ -10,7 +10,12 @@ export interface StyledAccordionContent {
$scrollHeight: number;
}
+export interface StyledButtonHeaderWrapper {
+ $position: ButtonHeaderPositionType;
+}
+
type TooltipPlacementType = typeof TOOLTIP_PLACEMENT_VALUES[number];
+type ButtonHeaderPositionType = typeof BUTTON_HEADER_POSITION_VALUES[number];
export interface AccordionProps {
/** The text to be displayed in the accordion header. */
@@ -34,6 +39,9 @@ export interface AccordionProps {
/** Optional React node rendered inside the button header, alongside the title. @default null */
buttonHeader?: React.ReactNode;
+ /** Position of `buttonHeader` within the header row. @default 'left' */
+ buttonHeaderPosition?: ButtonHeaderPositionType;
+
/** Content shown when the accordion is expanded. */
children?: React.ReactNode;
};
diff --git a/src/components/Hint/README.md b/src/components/Hint/README.md
index 33a25ca..c1b1bf5 100644
--- a/src/components/Hint/README.md
+++ b/src/components/Hint/README.md
@@ -26,13 +26,52 @@ import { BBBHint } from 'bbb-ui-components-react';
/>
```
+### Uncontrolled Hint (default)
+
+Without an `open` prop, the hint manages its own visibility and closes itself when the close button is clicked — no external state required.
+
+```jsx
+import { BBBHint } from 'bbb-ui-components-react';
+
+
+```
+
+### Controlled Hint
+
+Pass `open` to drive visibility externally; the hint calls `onRequestClose` instead of hiding itself, leaving the parent in charge of updating `open`.
+
+```jsx
+import { useState } from 'react';
+import { BBBHint } from 'bbb-ui-components-react';
+
+const [open, setOpen] = useState(true);
+
+ setOpen(false)}
+ label="This hint's visibility is controlled externally."
+/>
+```
+
+### Hint Without a Close Button
+
+Pass `hideCloseButton` for hints that shouldn't be manually dismissed — e.g. ones dismissed by interacting with another UI element, or tooltip-style hints with no explicit dismiss action.
+
+```jsx
+import { BBBHint } from 'bbb-ui-components-react';
+
+
+```
+
## Props
-| Property | Type | Default | Description |
-| ---------------- | -------------------------------- | ------- | ------------------------------------------------------------------------------------ |
-| `label` | `string` | | The main text content of the hint. |
-| `title` | `string` | | An optional title for the hint. If provided, a close button will be displayed. |
-| `icon` | `React.ReactNode` | | An optional icon to be displayed next to the title or label. |
-| `onRequestClose` | `() => void` | | A callback function to be called when the close button is clicked. |
-| `children` | `React.ReactNode` | | Optional additional content to be displayed below the label. |
-| `...props` | `HTMLAttributes` | | Any other props will be passed down to the underlying container div. |
+| Property | Type | Default | Description |
+| ----------------- | -------------------------------- | ------- | ------------------------------------------------------------------------------------ |
+| `label` | `string` | | The main text content of the hint. |
+| `title` | `string` | | An optional title shown in the header; when set, `label` renders as a separate line below instead of inline. |
+| `icon` | `React.ReactNode` | | An optional icon to be displayed next to the title or label. |
+| `open` | `boolean` | | Whether the hint is visible. Omit to let the hint manage its own visibility, closing itself when the close button is clicked; pass a boolean to control visibility externally. |
+| `onRequestClose` | `() => void` | | A callback function to be called when the close button is clicked, in both controlled and uncontrolled mode. |
+| `hideCloseButton` | `boolean` | `false` | Hides the close (X) button, for hints that shouldn't be manually dismissed. |
+| `children` | `React.ReactNode` | | Optional additional content to be displayed below the label. |
+| `...props` | `HTMLAttributes` | | Any other props will be passed down to the underlying container div. |
diff --git a/src/components/Hint/component.stories.tsx b/src/components/Hint/component.stories.tsx
index 65a8a14..a8790e9 100644
--- a/src/components/Hint/component.stories.tsx
+++ b/src/components/Hint/component.stories.tsx
@@ -1,5 +1,7 @@
+import React, { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import BBBHint from './component';
+import { BBButton } from '../Button';
const meta = {
title: 'BBBHint',
@@ -12,15 +14,23 @@ const meta = {
},
title: {
control: 'text',
- description: 'Optional title; if provided, shows a close button.',
+ description: 'Optional title shown in the header; when set, label renders as a separate line below instead of inline.',
},
icon: {
control: false,
description: 'Optional icon node displayed next to the title or label.',
},
+ open: {
+ control: 'boolean',
+ description: 'Whether the hint is visible. Omit to let the hint manage its own visibility, closing itself when the close button is clicked; pass a boolean to control visibility externally.',
+ },
onRequestClose: {
control: false,
- description: 'Callback fired when the close button is clicked.',
+ description: 'Callback fired when the close button is clicked, in both controlled and uncontrolled mode.',
+ },
+ hideCloseButton: {
+ control: 'boolean',
+ description: "Hides the close (X) button, for hints that shouldn't be manually dismissed.",
},
children: {
control: false,
@@ -32,9 +42,54 @@ const meta = {
export default meta;
type Story = StoryObj;
+/**
+ * Wrapper component so hooks can be used inside Storybook's render function,
+ * demonstrating the `open` prop driving visibility from outside the hint.
+ */
+const ControlledHintStory: React.FC> = (args) => {
+ const [open, setOpen] = useState(true);
+
+ return (
+
+ setOpen(true)} />
+ setOpen(false)}
+ />
+
+ );
+};
+
/** Basic hint rendering with only a label and the default info icon. */
export const Default: Story = {
args: {
label: 'Helpful hint',
},
};
+
+/** Uncontrolled hint (no `open` prop): it manages its own visibility and closes itself when dismissed. */
+export const Uncontrolled: Story = {
+ args: {
+ title: 'Uncontrolled',
+ label: 'This hint manages its own visibility and closes itself when the close button is clicked.',
+ },
+};
+
+/** Controlled hint: visibility is driven by the `open` prop, so the parent decides when it reappears. */
+export const Controlled: Story = {
+ args: {
+ title: 'Controlled',
+ label: "This hint's visibility is controlled externally via the open prop.",
+ },
+ render: (args) => ,
+};
+
+/** Hint with `hideCloseButton`: no close (X) button, for hints that shouldn't be manually dismissed. */
+export const WithoutCloseButton: Story = {
+ args: {
+ title: 'No close button',
+ label: 'This hint cannot be manually dismissed.',
+ hideCloseButton: true,
+ },
+};
diff --git a/src/components/Hint/component.tsx b/src/components/Hint/component.tsx
index 50bb01b..ee61d3a 100644
--- a/src/components/Hint/component.tsx
+++ b/src/components/Hint/component.tsx
@@ -1,4 +1,4 @@
-import React, { JSX } from 'react';
+import React, { JSX, useState } from 'react';
import * as Styled from './styles';
import { HintProps } from './types';
import { MdClose, MdInfo } from 'react-icons/md';
@@ -9,15 +9,29 @@ import { MdClose, MdInfo } from 'react-icons/md';
* This component provides a small contextual hint used to surface tips, short help text or dismissible messages.
* It can be displayed with a title, an icon, and a close button.
*
+ * Visibility is uncontrolled by default (the hint closes itself when its close button is clicked); pass `open` to control it externally instead.
*/
function Hint({
title,
label,
icon = ,
+ open,
onRequestClose,
+ hideCloseButton = false,
children,
...rest
-}: HintProps): JSX.Element {
+}: HintProps): JSX.Element | null {
+ const isControlled = open !== undefined;
+ const [internalOpen, setInternalOpen] = useState(true);
+ const isOpen = isControlled ? open : internalOpen;
+
+ const handleClose = (): void => {
+ if (!isControlled) setInternalOpen(false);
+ onRequestClose?.();
+ };
+
+ if (!isOpen) return null;
+
const renderedLabel = {label}{children};
return (
{title}}
{!title && renderedLabel}
- {title && (
+ {!hideCloseButton && (
diff --git a/src/components/Hint/types.ts b/src/components/Hint/types.ts
index eddf53e..f2412d3 100644
--- a/src/components/Hint/types.ts
+++ b/src/components/Hint/types.ts
@@ -2,15 +2,21 @@ export interface HintProps extends React.HTMLAttributes {
/** Main text content of the hint. */
label: string;
- /** Optional title; if provided, shows a close button. */
+ /** Optional title shown in the header; when set, `label` renders as a separate line below instead of inline. */
title?: string;
/** Optional icon node displayed next to the title or label. @default */
icon?: React.ReactNode;
- /** Callback fired when the close button is clicked. */
+ /** Whether the hint is visible. Omit to let the hint manage its own visibility, closing itself when the close button is clicked; pass a boolean to control visibility externally. */
+ open?: boolean;
+
+ /** Callback fired when the close button is clicked, in both controlled and uncontrolled mode. */
onRequestClose?: () => void;
+ /** Hides the close (X) button, for hints that shouldn't be manually dismissed. @default false */
+ hideCloseButton?: boolean;
+
/** Optional additional content rendered under the label. */
children?: React.ReactNode;
}
diff --git a/src/components/Input/styles.ts b/src/components/Input/styles.ts
index 47afd8d..0afe933 100644
--- a/src/components/Input/styles.ts
+++ b/src/components/Input/styles.ts
@@ -3,6 +3,7 @@ import {
colorBorderDefault,
colorBorderSelected,
colorBorderError,
+ colorBorderFocusRing,
colorTextDefault,
colorTextLight,
colorError,
@@ -41,7 +42,7 @@ export const FieldContainer = styled.div`
&:focus-within {
border-color: ${colorBorderSelected};
- box-shadow: 0 0 0 3px var(--color-border-focus-ring, rgba(29, 101, 212, 0.15));
+ box-shadow: 0 0 0 3px ${colorBorderFocusRing};
}
${({ $error }) =>
diff --git a/src/components/Modal/styles.ts b/src/components/Modal/styles.ts
index 19c0eb3..708c871 100644
--- a/src/components/Modal/styles.ts
+++ b/src/components/Modal/styles.ts
@@ -2,7 +2,7 @@ import styled from 'styled-components';
import { Styles } from 'react-modal';
import * as React from 'react';
import { spacingLarge, spacingMedium, spacingSmallMedium, borderRadiusDefault } from '../../stylesheets/sizing';
-import { colorWhite } from '../../stylesheets/palette';
+import { colorWhite, colorOverlay } from '../../stylesheets/palette';
import { StyledModalBodyProps, StyledModalFooterProps } from './types';
export const modalStyles: Styles = {
@@ -12,7 +12,7 @@ export const modalStyles: Styles = {
left: 0,
right: 0,
bottom: 0,
- backgroundColor: 'rgba(0, 0, 0, 0.75)',
+ backgroundColor: colorOverlay,
zIndex: 100,
display: 'flex',
alignItems: 'center',
diff --git a/src/components/ScrollArea/README.md b/src/components/ScrollArea/README.md
new file mode 100644
index 0000000..4ea7534
--- /dev/null
+++ b/src/components/ScrollArea/README.md
@@ -0,0 +1,67 @@
+# BBBScrollArea
+
+`BBBScrollArea` is a wrapper that applies standardized scrollbar styling to its content — based on the chat's scrollbar style — so that chat, lists, side panels, and any other scrollable content look consistent throughout the application.
+
+## Usage Example
+
+### Basic usage
+
+```jsx
+import { BBBScrollArea } from 'bbb-ui-components-react';
+
+
+
+
+```
+
+### Horizontal scroll
+
+```jsx
+import { BBBScrollArea } from 'bbb-ui-components-react';
+
+
+
+
+```
+
+### Without edge fade
+
+```jsx
+import { BBBScrollArea } from 'bbb-ui-components-react';
+
+
+
+
+```
+
+### With edge padding
+
+```jsx
+import { BBBScrollArea } from 'bbb-ui-components-react';
+
+
+
+
+```
+
+## Props
+
+| Property | Type | Default | Description |
+| -------------------- | ----------------- | ------- | --------------------------------------------------------------------------------------------------------------- |
+| `children` | `React.ReactNode` | | Scrollable content. |
+| `verticalScroll` | `boolean` | `true` | Enables vertical scrolling. |
+| `horizontalScroll` | `boolean` | `false` | Enables horizontal scrolling. |
+| `maxHeight` | `string` | | Caps the content height; scrolling kicks in past this value (e.g. `'400px'`, `'50vh'`). |
+| `maxWidth` | `string` | | Caps the content width; scrolling kicks in past this value (e.g. `'600px'`, `'100%'`). |
+| `fadeEdges` | `boolean` | `true` | Fades the scrollable edges with a color mask + shadow, matching the chat's scroll style. |
+| `fadeColor` | `string` | | Background color the fade blends into. Required in practice when `fadeEdges` is `true` — if omitted, the fade is silently skipped. |
+| `paddingTop` | `string` | | Space reserved above the content, before the scrollable area's top edge (e.g. `'1rem'`). |
+| `paddingRight` | `string` | | Space reserved to the right of the content, before the scrollable area's right edge (e.g. `'1rem'`). |
+| `paddingBottom` | `string` | | Space reserved below the content, before the scrollable area's bottom edge (e.g. `'1rem'`). |
+| `paddingLeft` | `string` | | Space reserved to the left of the content, before the scrollable area's left edge (e.g. `'1rem'`). |
+
+## Notes
+
+- Without `maxHeight`/`maxWidth`, the area grows with its content — scrolling only kicks in once a parent container or one of these props constrains its size, same as native `overflow: auto`.
+- `fadeColor` must match the background behind `BBBScrollArea` (e.g. the surrounding panel color) so the fade blends in seamlessly instead of showing a mismatched edge.
+- The fade mask overlays directly on top of the content near each active edge; set the matching `padding*` prop (e.g. `paddingTop`/`paddingBottom` for a vertical area) so the first/last item isn't partially covered by it.
diff --git a/src/components/ScrollArea/component.stories.tsx b/src/components/ScrollArea/component.stories.tsx
new file mode 100644
index 0000000..a6184e7
--- /dev/null
+++ b/src/components/ScrollArea/component.stories.tsx
@@ -0,0 +1,130 @@
+import React from 'react';
+import type { Meta, StoryObj } from '@storybook/react';
+import BBBScrollArea from './component';
+import { colorWhite } from '../../stylesheets/palette';
+
+const meta = {
+ title: 'BBBScrollArea',
+ component: BBBScrollArea,
+ tags: ['autodocs'],
+ argTypes: {
+ children: {
+ control: false,
+ description: 'Scrollable content.',
+ },
+ verticalScroll: {
+ control: 'boolean',
+ description: 'Enables vertical scrolling.',
+ table: { defaultValue: { summary: 'true' } },
+ },
+ horizontalScroll: {
+ control: 'boolean',
+ description: 'Enables horizontal scrolling.',
+ table: { defaultValue: { summary: 'false' } },
+ },
+ maxHeight: {
+ control: 'text',
+ description: 'Caps the content height; scrolling kicks in past this value.',
+ },
+ maxWidth: {
+ control: 'text',
+ description: 'Caps the content width; scrolling kicks in past this value.',
+ },
+ fadeEdges: {
+ control: 'boolean',
+ description: "Fades the scrollable edges with a color mask + shadow, matching the chat's scroll style.",
+ table: { defaultValue: { summary: 'true' } },
+ },
+ fadeColor: {
+ control: 'color',
+ description: 'Background color the fade blends into. Required in practice when fadeEdges is true.',
+ },
+ paddingTop: {
+ control: 'text',
+ description: "Space reserved above the content, before the scrollable area's top edge.",
+ },
+ paddingRight: {
+ control: 'text',
+ description: "Space reserved to the right of the content, before the scrollable area's right edge.",
+ },
+ paddingBottom: {
+ control: 'text',
+ description: "Space reserved below the content, before the scrollable area's bottom edge.",
+ },
+ paddingLeft: {
+ control: 'text',
+ description: "Space reserved to the left of the content, before the scrollable area's left edge.",
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const VERTICAL_ITEMS = Array.from({ length: 20 }, (_, index) => `Message ${index + 1}`);
+const HORIZONTAL_ITEMS = Array.from({ length: 12 }, (_, index) => `Card ${index + 1}`);
+
+const VerticalList = () => (
+
+ {VERTICAL_ITEMS.map((item) => (
+
+ {item}
+
+ ))}
+
+);
+
+const HorizontalList = () => (
+
+ {HORIZONTAL_ITEMS.map((item) => (
+
+ {item}
+
+ ))}
+
+);
+
+/** Default vertical scroll area with the chat-style scrollbar and top/bottom edge fade. */
+export const Default: Story = {
+ args: {
+ maxHeight: '250px',
+ fadeColor: colorWhite,
+ children: ,
+ },
+};
+
+/** Vertical list with top/bottom padding, so the fade blends over empty space instead of the first/last item. */
+export const WithEdgePadding: Story = {
+ args: {
+ maxHeight: '250px',
+ fadeColor: colorWhite,
+ paddingTop: '1rem',
+ paddingBottom: '1rem',
+ children: ,
+ },
+};
+
+/** Same vertical list with `fadeEdges` off — only the scrollbar styling remains. */
+export const NoFade: Story = {
+ args: {
+ maxHeight: '250px',
+ fadeEdges: false,
+ children: ,
+ },
+};
+
+/** Horizontal scroll area with left/right edge fade instead of top/bottom. */
+export const Horizontal: Story = {
+ args: {
+ verticalScroll: false,
+ horizontalScroll: true,
+ maxWidth: '400px',
+ fadeColor: colorWhite,
+ children: ,
+ },
+};
diff --git a/src/components/ScrollArea/component.tsx b/src/components/ScrollArea/component.tsx
new file mode 100644
index 0000000..db46434
--- /dev/null
+++ b/src/components/ScrollArea/component.tsx
@@ -0,0 +1,47 @@
+import React, { JSX } from 'react';
+import { ScrollAreaProps } from './types';
+import * as Styled from './styles';
+import { DEFAULT_VERTICAL_SCROLL, DEFAULT_HORIZONTAL_SCROLL, DEFAULT_FADE_EDGES } from './constants';
+
+/**
+ * A wrapper that applies standardized scrollbar styling to its content.
+ *
+ * Use it around any scrollable area (chat, lists, side panels) to get a consistent
+ * scrollbar look, with optional axis control, size capping, and edge fading.
+ *
+ */
+function ScrollArea({
+ children,
+ verticalScroll = DEFAULT_VERTICAL_SCROLL,
+ horizontalScroll = DEFAULT_HORIZONTAL_SCROLL,
+ maxHeight,
+ maxWidth,
+ fadeEdges = DEFAULT_FADE_EDGES,
+ fadeColor,
+ paddingTop,
+ paddingRight,
+ paddingBottom,
+ paddingLeft,
+}: ScrollAreaProps): JSX.Element {
+ const isScrollable = verticalScroll || horizontalScroll;
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default ScrollArea;
diff --git a/src/components/ScrollArea/constants.ts b/src/components/ScrollArea/constants.ts
new file mode 100644
index 0000000..1fb2cfc
--- /dev/null
+++ b/src/components/ScrollArea/constants.ts
@@ -0,0 +1,3 @@
+export const DEFAULT_VERTICAL_SCROLL = true;
+export const DEFAULT_HORIZONTAL_SCROLL = false;
+export const DEFAULT_FADE_EDGES = true;
diff --git a/src/components/ScrollArea/index.ts b/src/components/ScrollArea/index.ts
new file mode 100644
index 0000000..25d0af5
--- /dev/null
+++ b/src/components/ScrollArea/index.ts
@@ -0,0 +1 @@
+export { default as BBBScrollArea } from './component';
diff --git a/src/components/ScrollArea/styles.ts b/src/components/ScrollArea/styles.ts
new file mode 100644
index 0000000..da1ed03
--- /dev/null
+++ b/src/components/ScrollArea/styles.ts
@@ -0,0 +1,84 @@
+import styled, { css } from 'styled-components';
+import { StyledScrollAreaWrapperProps } from './types';
+
+const FADE_MASK_SIZE = '40px';
+const FADE_SHADOW_SIZE = '14px';
+const FADE_SHADOW_COLOR = 'rgba(0, 0, 0, .2)';
+
+interface FadeLayer {
+ image: string;
+ position: string;
+ size: string;
+ attachment: string;
+}
+
+const verticalFadeLayers = (color: string): FadeLayer[] => [
+ { image: `linear-gradient(${color} 30%, transparent)`, position: '0 0', size: `100% ${FADE_MASK_SIZE}`, attachment: 'local' },
+ { image: `linear-gradient(transparent, ${color} 70%)`, position: '0 100%', size: `100% ${FADE_MASK_SIZE}`, attachment: 'local' },
+ { image: `radial-gradient(farthest-side at 50% 0, ${FADE_SHADOW_COLOR}, transparent)`, position: '0 0', size: `100% ${FADE_SHADOW_SIZE}`, attachment: 'scroll' },
+ { image: `radial-gradient(farthest-side at 50% 100%, ${FADE_SHADOW_COLOR}, transparent)`, position: '0 100%', size: `100% ${FADE_SHADOW_SIZE}`, attachment: 'scroll' },
+];
+
+const horizontalFadeLayers = (color: string): FadeLayer[] => [
+ { image: `linear-gradient(to right, ${color} 30%, transparent)`, position: '0 0', size: `${FADE_MASK_SIZE} 100%`, attachment: 'local' },
+ { image: `linear-gradient(to right, transparent, ${color} 70%)`, position: '100% 0', size: `${FADE_MASK_SIZE} 100%`, attachment: 'local' },
+ { image: `radial-gradient(farthest-side at 0 50%, ${FADE_SHADOW_COLOR}, transparent)`, position: '0 0', size: `${FADE_SHADOW_SIZE} 100%`, attachment: 'scroll' },
+ { image: `radial-gradient(farthest-side at 100% 50%, ${FADE_SHADOW_COLOR}, transparent)`, position: '100% 0', size: `${FADE_SHADOW_SIZE} 100%`, attachment: 'scroll' },
+];
+
+const fadeBackground = ({
+ $verticalScroll, $horizontalScroll, $fadeEdges, $fadeColor,
+}: StyledScrollAreaWrapperProps) => {
+ if (!$fadeEdges || !$fadeColor) return '';
+
+ const layers = [
+ ...($verticalScroll ? verticalFadeLayers($fadeColor) : []),
+ ...($horizontalScroll ? horizontalFadeLayers($fadeColor) : []),
+ ];
+ if (!layers.length) return '';
+
+ return css`
+ background-image: ${layers.map((layer) => layer.image).join(', ')};
+ background-position: ${layers.map((layer) => layer.position).join(', ')};
+ background-size: ${layers.map((layer) => layer.size).join(', ')};
+ background-attachment: ${layers.map((layer) => layer.attachment).join(', ')};
+ background-repeat: no-repeat;
+ background-color: transparent;
+ `;
+};
+
+export const ScrollAreaWrapper = styled.div`
+ overflow-y: ${({ $verticalScroll }) => ($verticalScroll ? 'auto' : 'hidden')};
+ overflow-x: ${({ $horizontalScroll }) => ($horizontalScroll ? 'auto' : 'hidden')};
+ ${({ $maxHeight }) => $maxHeight && css`max-height: ${$maxHeight};`}
+ ${({ $maxWidth }) => $maxWidth && css`max-width: ${$maxWidth};`}
+ ${({ $paddingTop }) => $paddingTop && css`padding-top: ${$paddingTop};`}
+ ${({ $paddingRight }) => $paddingRight && css`padding-right: ${$paddingRight};`}
+ ${({ $paddingBottom }) => $paddingBottom && css`padding-bottom: ${$paddingBottom};`}
+ ${({ $paddingLeft }) => $paddingLeft && css`padding-left: ${$paddingLeft};`}
+ ${fadeBackground}
+
+ &::-webkit-scrollbar {
+ width: 5px;
+ height: 5px;
+ }
+ &::-webkit-scrollbar-button {
+ width: 0;
+ height: 0;
+ }
+ &::-webkit-scrollbar-thumb {
+ background: rgba(0, 0, 0, .25);
+ border: none;
+ border-radius: 50px;
+ }
+ &::-webkit-scrollbar-thumb:hover { background: rgba(0, 0, 0, .5); }
+ &::-webkit-scrollbar-thumb:active { background: rgba(0, 0, 0, .25); }
+ &::-webkit-scrollbar-track {
+ background: rgba(0, 0, 0, .25);
+ border: none;
+ border-radius: 50px;
+ }
+ &::-webkit-scrollbar-track:hover { background: rgba(0, 0, 0, .25); }
+ &::-webkit-scrollbar-track:active { background: rgba(0, 0, 0, .25); }
+ &::-webkit-scrollbar-corner { background: 0 0; }
+`;
diff --git a/src/components/ScrollArea/types.ts b/src/components/ScrollArea/types.ts
new file mode 100644
index 0000000..02196c9
--- /dev/null
+++ b/src/components/ScrollArea/types.ts
@@ -0,0 +1,49 @@
+import React from 'react';
+
+export interface StyledScrollAreaWrapperProps {
+ $verticalScroll: boolean;
+ $horizontalScroll: boolean;
+ $maxHeight?: string;
+ $maxWidth?: string;
+ $fadeEdges: boolean;
+ $fadeColor?: string;
+ $paddingTop?: string;
+ $paddingRight?: string;
+ $paddingBottom?: string;
+ $paddingLeft?: string;
+}
+
+export interface ScrollAreaProps {
+ /** Scrollable content. */
+ children: React.ReactNode;
+
+ /** Enables vertical scrolling. @default true */
+ verticalScroll?: boolean;
+
+ /** Enables horizontal scrolling. @default false */
+ horizontalScroll?: boolean;
+
+ /** Caps the content height; scrolling kicks in past this value (e.g. '400px', '50vh'). */
+ maxHeight?: string;
+
+ /** Caps the content width; scrolling kicks in past this value (e.g. '600px', '100%'). */
+ maxWidth?: string;
+
+ /** Fades the scrollable edges with a color mask + shadow, matching the chat's scroll style. @default true */
+ fadeEdges?: boolean;
+
+ /** Background color the fade blends into. Required in practice when `fadeEdges` is `true` — if omitted, the fade is silently skipped. */
+ fadeColor?: string;
+
+ /** Space reserved above the content, before the scrollable area's top edge (e.g. '1rem'). Keeps the first item clear of the top fade instead of butting against it. */
+ paddingTop?: string;
+
+ /** Space reserved to the right of the content, before the scrollable area's right edge (e.g. '1rem'). Keeps the last item clear of the right fade instead of butting against it. */
+ paddingRight?: string;
+
+ /** Space reserved below the content, before the scrollable area's bottom edge (e.g. '1rem'). Keeps the last item clear of the bottom fade instead of butting against it. */
+ paddingBottom?: string;
+
+ /** Space reserved to the left of the content, before the scrollable area's left edge (e.g. '1rem'). Keeps the first item clear of the left fade instead of butting against it. */
+ paddingLeft?: string;
+}
diff --git a/src/components/Search/styles.ts b/src/components/Search/styles.ts
index a076a5a..b8d9788 100644
--- a/src/components/Search/styles.ts
+++ b/src/components/Search/styles.ts
@@ -2,6 +2,7 @@ import styled, { css } from 'styled-components';
import {
colorBorderDefault,
colorBorderSelected,
+ colorBorderFocusRing,
colorTextDefault,
colorTextLight,
colorIconDefault,
@@ -36,7 +37,7 @@ export const Container = styled.div`
&:focus-within {
border-color: ${colorBorderSelected};
- box-shadow: 0 0 0 3px var(--color-border-focus-ring, rgba(29, 101, 212, 0.15));
+ box-shadow: 0 0 0 3px ${colorBorderFocusRing};
}
${({ $disabled }) =>
diff --git a/src/components/TextAreaInput/styles.ts b/src/components/TextAreaInput/styles.ts
index ffd9f2d..31d83fc 100644
--- a/src/components/TextAreaInput/styles.ts
+++ b/src/components/TextAreaInput/styles.ts
@@ -1,5 +1,7 @@
import styled from 'styled-components';
-import { colorBorderDefault, colorBrand1, colorBrand2, colorLightGray, colorWhite } from '../../stylesheets/palette';
+import {
+ colorBorderDefault, colorBrand1, colorBrand2, colorTextDefault, colorTextLight, colorWhite,
+} from '../../stylesheets/palette';
import { borderRadiusDefault } from '../../stylesheets/sizing';
export const TextAreaInput = styled.textarea`
@@ -7,7 +9,7 @@ export const TextAreaInput = styled.textarea`
background: ${colorWhite};
background-clip: padding-box;
margin: 0;
- color: ${colorLightGray};
+ color: ${colorTextDefault};
padding: calc(.3rem* 2.5) calc(.75rem* 1.25);
resize: none;
-webkit-transition: none;
@@ -20,6 +22,11 @@ export const TextAreaInput = styled.textarea`
border: 1px solid ${colorBorderDefault};
overflow-y: hidden;
margin: 0.3em;
+
+ &::placeholder {
+ color: ${colorTextLight};
+ }
+
&:focus {
outline: ${colorBrand1} solid 2px;
box-shadow: 0 0 0 2px ${colorBrand2} inset 0 0 0 1px ${colorBrand1};
diff --git a/src/components/Toggle/styles.ts b/src/components/Toggle/styles.ts
index 5815a1f..9bbf73a 100644
--- a/src/components/Toggle/styles.ts
+++ b/src/components/Toggle/styles.ts
@@ -1,7 +1,10 @@
import { Switch } from '@mui/material';
import { styled as materialStyled } from '@mui/material/styles';
import styled, { css } from 'styled-components';
-import { colorBrand1, colorIconDefault, colorTextDefault, colorTextLight, colorWhite } from '../../stylesheets/palette';
+import {
+ colorBrand1, colorIconDefault, colorTextDefault, colorTextLight, colorWhite,
+ colorShadowDefault, colorIconDefaultDark,
+} from '../../stylesheets/palette';
import { TEXT_POSITIONS } from './constants';
import { StyledTextWrapperProps, StyledToggleWrapperProps } from './types';
import { fontSizeBig, fontSizeDefault } from '../../stylesheets/typography';
@@ -103,7 +106,7 @@ export const MaterialToggle = materialStyled(Switch)(({ theme }) => ({
},
},
'& .MuiSwitch-thumb': {
- boxShadow: '0 2px 4px 0 rgb(0 35 11 / 20%)',
+ boxShadow: `0 2px 4px 0 ${colorShadowDefault}`,
width: '0.6rem',
height: '0.6rem',
borderRadius: '0.5rem',
@@ -118,7 +121,7 @@ export const MaterialToggle = materialStyled(Switch)(({ theme }) => ({
backgroundColor: colorIconDefault,
boxSizing: 'border-box',
...theme.applyStyles('dark', {
- backgroundColor: 'rgba(255,255,255,.35)',
+ backgroundColor: colorIconDefaultDark,
}),
},
}));
\ No newline at end of file
diff --git a/src/components/index.ts b/src/components/index.ts
index b5e6250..230d706 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -6,6 +6,7 @@ export { BBBHint } from './Hint';
export { BBBInput } from './Input';
export { BBBModal } from './Modal';
export { BBBNavigation } from './Navigation';
+export { BBBScrollArea } from './ScrollArea';
export { BBBSearch } from './Search';
export { BBBSelect } from './Select';
export { BBBSpinner } from './Spinner';
diff --git a/src/index.ts b/src/index.ts
index 8d8604e..2ca8450 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,3 +1,4 @@
export * from './components';
export * from './stylesheets/palette';
export * from './stylesheets/sizing';
+export * from './stylesheets/colors';
diff --git a/src/stylesheets/colors.ts b/src/stylesheets/colors.ts
new file mode 100644
index 0000000..84251c7
--- /dev/null
+++ b/src/stylesheets/colors.ts
@@ -0,0 +1,71 @@
+import {
+ colorNeutral2, colorNeutral3, colorNeutral4, colorWhite, colorLightGray, colorGray, colorDarkGray,
+ colorBrand1, colorBrand2, colorBrand3, colorBrandLight, colorBrandAux,
+ colorSuccess, colorWarning, colorError, colorErrorDark,
+ colorBackgroundWhite, colorBackgroundLight, colorBackgroundBlue,
+ colorBorderDefault, colorBorderSelected, colorBorderError, colorBorderFocusRing,
+ colorTextDefault, colorTextLight,
+ colorIconDefault, colorIconBlue, colorIconWhite, colorIconDefaultDark,
+ colorHoverDark, colorHoverLight, colorHoverNeutral,
+ colorOverlay,
+ colorShadowDefault,
+} from './palette';
+
+export const colors = {
+ neutral: {
+ neutral2: colorNeutral2,
+ neutral3: colorNeutral3,
+ neutral4: colorNeutral4,
+ white: colorWhite,
+ lightGray: colorLightGray,
+ gray: colorGray,
+ darkGray: colorDarkGray,
+ },
+ brand: {
+ brand1: colorBrand1,
+ brand2: colorBrand2,
+ brand3: colorBrand3,
+ light: colorBrandLight,
+ aux: colorBrandAux,
+ },
+ semantic: {
+ success: colorSuccess,
+ warning: colorWarning,
+ error: colorError,
+ errorDark: colorErrorDark,
+ },
+ background: {
+ white: colorBackgroundWhite,
+ light: colorBackgroundLight,
+ blue: colorBackgroundBlue,
+ },
+ border: {
+ default: colorBorderDefault,
+ selected: colorBorderSelected,
+ error: colorBorderError,
+ focusRing: colorBorderFocusRing,
+ },
+ text: {
+ default: colorTextDefault,
+ light: colorTextLight,
+ },
+ icon: {
+ default: colorIconDefault,
+ blue: colorIconBlue,
+ white: colorIconWhite,
+ defaultDark: colorIconDefaultDark,
+ },
+ hover: {
+ dark: colorHoverDark,
+ light: colorHoverLight,
+ neutral: colorHoverNeutral,
+ },
+ overlay: {
+ default: colorOverlay,
+ },
+ shadow: {
+ default: colorShadowDefault,
+ },
+} as const;
+
+export type Colors = typeof colors;
diff --git a/src/stylesheets/palette.ts b/src/stylesheets/palette.ts
index deaaa9f..1867278 100644
--- a/src/stylesheets/palette.ts
+++ b/src/stylesheets/palette.ts
@@ -43,6 +43,7 @@ export const colorBorderSelected = `var(--color-border-selected, ${colorBrand1_b
export const colorBorderError = `var(--color-border-error, ${colorError_base})`;
// Mapped to core css vars
export const colorBorderDefault = `var(--default-border, ${colorBorderDefault_base})`;
+export const colorBorderFocusRing = 'var(--color-border-focus-ring, rgba(29, 101, 212, 0.15))';
// Text colors
@@ -53,8 +54,15 @@ export const colorTextLight = `var(--color-text-light, ${colorNeutral2})`;
export const colorIconDefault = `var(--color-icon-default, ${colorNeutral2})`;
export const colorIconBlue = `var(--color-icon-blue, ${colorBrand1_base})`;
export const colorIconWhite = `var(--color-icon-white, ${colorWhite})`;
+export const colorIconDefaultDark = 'var(--color-icon-default-dark, rgba(255, 255, 255, 0.35))';
//Hover colors
export const colorHoverDark = 'var(--color-hover-dark, #0C57A7)';
export const colorHoverLight = 'var(--color-hover-light, #D4E5FA)';
export const colorHoverNeutral = `var(--color-hover-neutral, ${colorNeutral4})`;
+
+//Overlay colors
+export const colorOverlay = 'var(--color-overlay, rgba(0, 0, 0, 0.75))';
+
+//Shadow colors
+export const colorShadowDefault = 'var(--color-shadow-default, rgb(0 35 11 / 20%))';
diff --git a/webpack.config.babel.js b/webpack.config.babel.js
index c51f380..461ae7f 100644
--- a/webpack.config.babel.js
+++ b/webpack.config.babel.js
@@ -10,6 +10,7 @@ export default {
Input: './src/components/Input/index.ts',
Modal: './src/components/Modal/index.ts',
Navigation: './src/components/Navigation/index.ts',
+ ScrollArea: './src/components/ScrollArea/index.ts',
Search: './src/components/Search/index.ts',
Select: './src/components/Select/index.ts',
Spinner: './src/components/Spinner/index.ts',
@@ -17,6 +18,7 @@ export default {
TextInput: './src/components/TextInput/index.ts',
Toggle: './src/components/Toggle/index.ts',
Typography: './src/components/Typography/index.ts',
+ colors: './src/stylesheets/colors.ts',
index: './src/index.ts',
},
output: {