diff --git a/.nx/version-plans/version-plan-1777911187465-react.md b/.nx/version-plans/version-plan-1777911187465-react.md
new file mode 100644
index 000000000..a1ff5594d
--- /dev/null
+++ b/.nx/version-plans/version-plan-1777911187465-react.md
@@ -0,0 +1,5 @@
+---
+'@ledgerhq/lumen-ui-react': patch
+---
+
+feat(ui-react): add Trend component
diff --git a/.nx/version-plans/version-plan-1777911187465-rnative.md b/.nx/version-plans/version-plan-1777911187465-rnative.md
new file mode 100644
index 000000000..5904533f6
--- /dev/null
+++ b/.nx/version-plans/version-plan-1777911187465-rnative.md
@@ -0,0 +1,5 @@
+---
+'@ledgerhq/lumen-ui-rnative': patch
+---
+
+feat(ui-rnative): add Trend component
diff --git a/apps/app-sandbox-rnative/src/app/App.tsx b/apps/app-sandbox-rnative/src/app/App.tsx
index 98b4f7cbd..55c3cd335 100644
--- a/apps/app-sandbox-rnative/src/app/App.tsx
+++ b/apps/app-sandbox-rnative/src/app/App.tsx
@@ -28,21 +28,23 @@ import {
Checkboxes,
ContentBanners,
Dividers,
+ DotCounts,
DotIcons,
+ DotIndicators,
DotSymbols,
- MediaImages,
ExampleTabBar,
Gradients,
IconButtons,
InteractiveIcons,
LineCharts,
- OptionLists,
Links,
ListItems,
MediaBanners,
MediaButtons,
MediaCards,
+ MediaImages,
NavBars,
+ OptionLists,
PageIndicators,
SegmentedControls,
Selects,
@@ -57,8 +59,7 @@ import {
ToggleLocaleSwitch,
ToggleThemeSwitch,
Tooltips,
- DotCounts,
- DotIndicators,
+ Trends,
} from './blocks';
import { SandboxBlock } from './SandboxBlock';
import { SandboxProvider } from './SandboxContext';
@@ -268,6 +269,9 @@ const AppContent = ({
+
+
+
diff --git a/apps/app-sandbox-rnative/src/app/blocks/Trends.tsx b/apps/app-sandbox-rnative/src/app/blocks/Trends.tsx
new file mode 100644
index 000000000..ef3d6eace
--- /dev/null
+++ b/apps/app-sandbox-rnative/src/app/blocks/Trends.tsx
@@ -0,0 +1,24 @@
+import { Box, Trend } from '@ledgerhq/lumen-ui-rnative';
+
+export function Trends() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/app-sandbox-rnative/src/app/blocks/index.ts b/apps/app-sandbox-rnative/src/app/blocks/index.ts
index ad2c53926..e67a5bcd2 100644
--- a/apps/app-sandbox-rnative/src/app/blocks/index.ts
+++ b/apps/app-sandbox-rnative/src/app/blocks/index.ts
@@ -2,21 +2,31 @@ export * from './AmountDisplays';
export * from './AmountInputs';
export * from './Animations';
export * from './Avatars';
-export * from './DotCounts';
-export * from './DotIndicators';
+export * from './Banners';
export * from './BottomSheets';
export * from './Buttons';
-export * from './Checkboxes';
+export * from './CardButtons';
export * from './Cards';
+export * from './Checkboxes';
+export * from './ContentBanners';
export * from './Dividers';
+export * from './DotCounts';
export * from './DotIcons';
+export * from './DotIndicators';
export * from './DotSymbols';
-export * from './MediaButtons';
-export * from './MediaImages';
+export * from './Gradients';
export * from './IconButtons';
+export * from './InteractiveIcons';
+export * from './LineCharts';
export * from './Links';
+export * from './ListItems';
export * from './MediaBanners';
+export * from './MediaButtons';
+export * from './MediaCards';
+export * from './MediaImages';
export * from './NavBars';
+export * from './OptionLists';
+export * from './PageIndicators';
export * from './SegmentedControls';
export * from './Selects';
export * from './Skeletons';
@@ -28,16 +38,7 @@ export * from './TabBars';
export * from './Tags';
export * from './TextInputs';
export * from './Tiles';
-export * from './ListItems';
-export * from './PageIndicators';
export * from './ToggleLocaleSwitch';
export * from './ToggleThemeSwitch';
-export * from './InteractiveIcons';
-export * from './Banners';
-export * from './CardButtons';
-export * from './ContentBanners';
-export * from './MediaCards';
export * from './Tooltips';
-export * from './Gradients';
-export * from './LineCharts';
-export * from './OptionLists';
+export * from './Trends';
diff --git a/libs/ui-react/src/lib/Components/Trend/Trend.figma.tsx b/libs/ui-react/src/lib/Components/Trend/Trend.figma.tsx
new file mode 100644
index 000000000..29b26cd94
--- /dev/null
+++ b/libs/ui-react/src/lib/Components/Trend/Trend.figma.tsx
@@ -0,0 +1,28 @@
+import figma from '@figma/code-connect';
+import { Trend } from './Trend';
+
+figma.connect(
+ Trend,
+ 'https://www.figma.com/design/JxaLVMTWirCpU0rsbZ30k7?node-id=892%3A5393',
+ {
+ imports: ["import { Trend } from '@ledgerhq/lumen-ui-react'"],
+ props: {
+ type: figma.enum('type', {
+ increasing: 5.25,
+ decreasing: -3.14,
+ neutral: 0,
+ }),
+ size: figma.enum('size', {
+ md: 'md',
+ sm: 'sm',
+ }),
+ disabled: figma.enum('state', {
+ enabled: false,
+ disabled: true,
+ }),
+ },
+ example: (props) => (
+
+ ),
+ },
+);
diff --git a/libs/ui-react/src/lib/Components/Trend/Trend.mdx b/libs/ui-react/src/lib/Components/Trend/Trend.mdx
new file mode 100644
index 000000000..8cdf790ee
--- /dev/null
+++ b/libs/ui-react/src/lib/Components/Trend/Trend.mdx
@@ -0,0 +1,106 @@
+import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks';
+import * as TrendStories from './Trend.stories';
+import { CustomTabs, Tab } from '../../../../.storybook/components';
+import { DoVsDontRow, DoBlockItem, DontBlockItem } from '../../../../.storybook/components/DoVsDont';
+import CommonRulesDoAndDont from '../../../../.storybook/components/DoVsDont/CommonRulesDoAndDont.mdx';
+
+
+
+# Trend
+
+
+
+
+## Introduction
+
+A compact indicator used to communicate directional change in a numeric value, typically a percentage. It combines an icon and a formatted label to convey whether a value has gone up, down, or stayed flat.
+
+> View in [Figma](https://www.figma.com/design/JxaLVMTWirCpU0rsbZ30k7/2.-Components-Library?node-id=892-5393).
+
+## Anatomy
+
+- **Icon:** A directional triangle (up or down) or a minus symbol that visually reinforces the direction of change at a glance.
+- **Value:** The numeric percentage formatted to exactly two decimal places, ensuring consistent alignment across lists and tables.
+
+## Properties
+
+### Overview
+
+
+
+
+### Variants
+
+The variant is derived automatically from the `value` prop. No manual selection needed! This ensures the color and icon always stay in sync with the actual data.
+
+- **Positive** - value greater than 0: green color, triangle up icon
+- **Negative** - value less than 0: red color, triangle down icon
+- **Neutral** - value equal to 0: muted color, minus icon
+
+
+
+---
+
+### Size
+
+Two sizes are available to accommodate different layout densities. Use `md` in most contexts and `sm` when space is constrained, such as inside a table cell or a compact list item.
+
+- **md** (default) - standard body text and icon sizes
+- **sm** - smaller text and icon, suitable for dense UIs
+
+
+
+---
+
+### Disabled
+
+When `disabled` is `true`, the icon and text both render in a muted, non-interactive style regardless of the underlying value. Use this when the data is unavailable, loading, or the surrounding context is non-interactive.
+
+
+
+
+
+
+## Setup
+
+Install and set up the library with our [Setup Guide →](?path=/docs/getting-started-setup--docs).
+
+### Basic Usage
+
+Pass any numeric value - the component handles color, icon, and formatting automatically.
+
+```tsx
+import { Trend } from '@ledgerhq/lumen-ui-react';
+
+function MyComponent() {
+ return ;
+}
+```
+
+### Sizes
+
+Choose `sm` for dense layouts like tables or compact list rows, and `md` for standard content areas.
+
+```tsx
+
+
+```
+
+### Disabled State
+
+Wrap or pass `disabled` when the value is unavailable or the parent context is non-interactive. The component style updates automatically - no extra styling needed.
+
+```tsx
+
+```
+
+### Layout Adjustments
+
+Use `className` for layout only - not for overriding colors or typography. The component's visual appearance is controlled exclusively via props.
+
+```tsx
+
+```
+
+
+
diff --git a/libs/ui-react/src/lib/Components/Trend/Trend.stories.tsx b/libs/ui-react/src/lib/Components/Trend/Trend.stories.tsx
new file mode 100644
index 000000000..62187c572
--- /dev/null
+++ b/libs/ui-react/src/lib/Components/Trend/Trend.stories.tsx
@@ -0,0 +1,60 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { Trend } from './Trend';
+
+const meta: Meta = {
+ component: Trend,
+ title: 'Communication/Trend',
+ argTypes: {
+ value: {
+ control: 'number',
+ },
+ size: {
+ control: 'radio',
+ options: ['sm', 'md'],
+ },
+ disabled: {
+ control: 'boolean',
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Base: Story = {
+ args: {
+ value: 5.25,
+ size: 'md',
+ disabled: false,
+ },
+};
+
+export const VariantShowcase: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
+
+export const SizeShowcase: Story = {
+ render: () => (
+
+
+
+
+ ),
+};
+
+export const DisabledShowcase: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
diff --git a/libs/ui-react/src/lib/Components/Trend/Trend.test.tsx b/libs/ui-react/src/lib/Components/Trend/Trend.test.tsx
new file mode 100644
index 000000000..33a00db2a
--- /dev/null
+++ b/libs/ui-react/src/lib/Components/Trend/Trend.test.tsx
@@ -0,0 +1,125 @@
+import { render, screen } from '@testing-library/react';
+import { createRef } from 'react';
+import { describe, it, expect } from 'vitest';
+import '@testing-library/jest-dom';
+import { Trend } from './Trend';
+
+describe('Trend Component', () => {
+ it('should render positive value', () => {
+ render();
+ expect(screen.getByText('5.50%')).toBeInTheDocument();
+ });
+
+ it('should render negative value', () => {
+ render();
+ expect(screen.getByText('-3.20%')).toBeInTheDocument();
+ });
+
+ it('should render neutral when value is zero', () => {
+ render();
+ expect(screen.getByText('0.00%')).toBeInTheDocument();
+ });
+
+ it('should render with sm size', () => {
+ render();
+ expect(screen.getByText('1.50%')).toBeInTheDocument();
+ });
+
+ it('should render with md size', () => {
+ render();
+ expect(screen.getByText('1.50%')).toBeInTheDocument();
+ });
+
+ it('should render in disabled state', () => {
+ render();
+ expect(screen.getByText('5.00%')).toBeInTheDocument();
+ });
+
+ it('should pass data-testid', () => {
+ render();
+ expect(screen.getByTestId('trend-id')).toBeInTheDocument();
+ });
+
+ it('should format value to 2 decimal places', () => {
+ render();
+ expect(screen.getByText('1.12%')).toBeInTheDocument();
+ });
+
+ it('should render all variants side by side', () => {
+ render(
+ <>
+
+
+
+ >,
+ );
+ expect(screen.getByText('10.00%')).toBeInTheDocument();
+ expect(screen.getByText('-10.00%')).toBeInTheDocument();
+ expect(screen.getByText('0.00%')).toBeInTheDocument();
+ });
+
+ it('should apply positive color class', () => {
+ render();
+ expect(screen.getByTestId('trend')).toHaveClass('text-success');
+ });
+
+ it('should apply negative color class', () => {
+ render();
+ expect(screen.getByTestId('trend')).toHaveClass('text-error');
+ });
+
+ it('should apply neutral color class', () => {
+ render();
+ expect(screen.getByTestId('trend')).toHaveClass('text-muted');
+ });
+
+ it('should apply disabled color class', () => {
+ render();
+ expect(screen.getByTestId('trend')).toHaveClass('text-disabled');
+ });
+
+ it('should apply md typography class', () => {
+ render();
+ expect(screen.getByTestId('trend')).toHaveClass('body-2');
+ });
+
+ it('should apply sm typography class', () => {
+ render();
+ expect(screen.getByTestId('trend')).toHaveClass('body-3');
+ });
+
+ it('should merge custom className', () => {
+ render();
+ expect(screen.getByTestId('trend')).toHaveClass('mt-4');
+ });
+
+ it('should set aria-label for positive variant', () => {
+ render();
+ expect(screen.getByTestId('trend')).toHaveAttribute(
+ 'aria-label',
+ 'components.trend.positiveAriaLabel',
+ );
+ });
+
+ it('should set aria-label for negative variant', () => {
+ render();
+ expect(screen.getByTestId('trend')).toHaveAttribute(
+ 'aria-label',
+ 'components.trend.negativeAriaLabel',
+ );
+ });
+
+ it('should set aria-label for neutral variant', () => {
+ render();
+ expect(screen.getByTestId('trend')).toHaveAttribute(
+ 'aria-label',
+ 'components.trend.neutralAriaLabel',
+ );
+ });
+
+ it('should forward ref', () => {
+ const ref = createRef();
+ render();
+ expect(ref.current).toBeInstanceOf(HTMLSpanElement);
+ });
+});
diff --git a/libs/ui-react/src/lib/Components/Trend/Trend.tsx b/libs/ui-react/src/lib/Components/Trend/Trend.tsx
new file mode 100644
index 000000000..f8b045623
--- /dev/null
+++ b/libs/ui-react/src/lib/Components/Trend/Trend.tsx
@@ -0,0 +1,68 @@
+import { cn, useDisabledContext } from '@ledgerhq/lumen-utils-shared';
+import { useCommonTranslation } from '../../../i18n';
+import { Minus, TriangleDown, TriangleUp } from '../../Symbols';
+import type { TrendProps } from './types';
+
+type TrendVariant = 'positive' | 'negative' | 'neutral';
+
+function getVariant(value: number): TrendVariant {
+ if (value === 0) return 'neutral';
+ return value > 0 ? 'positive' : 'negative';
+}
+
+const variantColor: Record = {
+ positive: 'text-success',
+ negative: 'text-error',
+ neutral: 'text-muted',
+};
+
+const sizeClass: Record, string> = {
+ md: 'body-2 gap-2',
+ sm: 'body-3 gap-2',
+};
+
+const iconSize: Record, 12 | 16> = {
+ md: 16,
+ sm: 12,
+};
+
+export function Trend({
+ value,
+ size = 'md',
+ disabled: disabledProp = false,
+ className,
+ ...props
+}: TrendProps) {
+ const variant = getVariant(value);
+ const disabled = useDisabledContext({
+ consumerName: 'Trend',
+ mergeWith: { disabled: disabledProp },
+ });
+ const { t } = useCommonTranslation();
+
+ const Icon = { positive: TriangleUp, negative: TriangleDown, neutral: Minus }[
+ variant
+ ];
+
+ const absoluteFormattedValue = `${Math.abs(value).toFixed(2)}%`;
+ const formattedValue =
+ value < 0 ? `-${absoluteFormattedValue}` : absoluteFormattedValue;
+
+ return (
+
+
+ {formattedValue}
+
+ );
+}
diff --git a/libs/ui-react/src/lib/Components/Trend/index.ts b/libs/ui-react/src/lib/Components/Trend/index.ts
new file mode 100644
index 000000000..723f0d45b
--- /dev/null
+++ b/libs/ui-react/src/lib/Components/Trend/index.ts
@@ -0,0 +1,2 @@
+export * from './Trend';
+export * from './types';
diff --git a/libs/ui-react/src/lib/Components/Trend/types.ts b/libs/ui-react/src/lib/Components/Trend/types.ts
new file mode 100644
index 000000000..9bd8a6638
--- /dev/null
+++ b/libs/ui-react/src/lib/Components/Trend/types.ts
@@ -0,0 +1,20 @@
+import type { ComponentPropsWithRef } from 'react';
+
+export type TrendProps = {
+ /**
+ * The value to display in the trend. This value affects the appearance of the component in terms of color and icon.
+ * @required
+ */
+ value: number;
+ /**
+ * The size of the trend component.
+ * @default md
+ */
+ size?: 'sm' | 'md';
+ /**
+ * When `true`, shows a muted appearance on the trend, regardless of value.
+ *
+ * @default false
+ */
+ disabled?: boolean;
+} & Omit, 'children'>;
diff --git a/libs/ui-react/src/lib/Components/index.ts b/libs/ui-react/src/lib/Components/index.ts
index 4e38885b4..ef71e39fa 100644
--- a/libs/ui-react/src/lib/Components/index.ts
+++ b/libs/ui-react/src/lib/Components/index.ts
@@ -44,4 +44,5 @@ export * from './TextInput';
export * from './Tile';
export * from './TileButton';
export * from './Tooltip';
+export * from './Trend';
export * from './ThemeProvider';
diff --git a/libs/ui-rnative/src/i18n/i18n.ts b/libs/ui-rnative/src/i18n/i18n.ts
index 80182a5dc..2bcf483b0 100644
--- a/libs/ui-rnative/src/i18n/i18n.ts
+++ b/libs/ui-rnative/src/i18n/i18n.ts
@@ -34,10 +34,21 @@ const loadedLocales = new Set();
const initializeI18n = (): I18nInstance => {
const instance = i18next.createInstance();
+ const resources = Object.fromEntries(
+ Object.entries(localeResources).map(([locale, translations]) => [
+ locale,
+ { [I18N_DEFAULT_NAMESPACE]: translations },
+ ]),
+ );
+
+ Object.keys(localeResources).forEach((locale) =>
+ loadedLocales.add(locale as SupportedLocale),
+ );
+
instance
.use(initReactI18next)
.init({
- resources: {},
+ resources,
lng: DEFAULT_LANGUAGE,
defaultNS: I18N_DEFAULT_NAMESPACE,
fallbackLng: DEFAULT_LANGUAGE,
diff --git a/libs/ui-rnative/src/i18n/locales/en.json b/libs/ui-rnative/src/i18n/locales/en.json
index f488514ee..6c0f5437a 100644
--- a/libs/ui-rnative/src/i18n/locales/en.json
+++ b/libs/ui-rnative/src/i18n/locales/en.json
@@ -31,6 +31,11 @@
},
"stepper": {
"progressAriaLabel": "Step {{currentStep}} of {{totalSteps}}"
+ },
+ "trend": {
+ "positiveAriaLabel": "Trending up by {{value}}",
+ "negativeAriaLabel": "Trending down by {{value}}",
+ "neutralAriaLabel": "No change, {{value}}"
}
}
}
diff --git a/libs/ui-rnative/src/lib/Components/Trend/Trend.mdx b/libs/ui-rnative/src/lib/Components/Trend/Trend.mdx
new file mode 100644
index 000000000..614405d63
--- /dev/null
+++ b/libs/ui-rnative/src/lib/Components/Trend/Trend.mdx
@@ -0,0 +1,106 @@
+import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks';
+import * as TrendStories from './Trend.stories';
+import { CustomTabs, Tab } from '../../../../.storybook/components';
+import { DoVsDontRow, DoBlockItem, DontBlockItem } from '../../../../.storybook/components/DoVsDont';
+import CommonRulesDoAndDont from '../../../../.storybook/components/DoVsDont/CommonRulesDoAndDont.mdx';
+
+
+
+# Trend
+
+
+
+
+## Introduction
+
+A compact indicator used to communicate directional change in a numeric value, typically a percentage. It combines an icon and a formatted label to convey whether a value has gone up, down, or stayed flat.
+
+> View in [Figma](https://www.figma.com/design/JxaLVMTWirCpU0rsbZ30k7/2.-Components-Library?node-id=892-5393).
+
+## Anatomy
+
+- **Icon:** A directional triangle (up or down) or a minus symbol that visually reinforces the direction of change at a glance.
+- **Value:** The numeric percentage formatted to exactly two decimal places, ensuring consistent alignment across lists and tables.
+
+## Properties
+
+### Overview
+
+
+
+
+### Variants
+
+The variant is derived automatically from the `value` prop. No manual selection needed! This ensures the color and icon always stay in sync with the actual data.
+
+- **Positive** - value greater than 0: green color, triangle up icon
+- **Negative** - value less than 0: red color, triangle down icon
+- **Neutral** - value equal to 0: muted color, minus icon
+
+
+
+---
+
+### Size
+
+Two sizes are available to accommodate different layout densities. Use `md` in most contexts and `sm` when space is constrained, such as inside a table cell or a compact list item.
+
+- **md** (default) - standard body text and icon sizes
+- **sm** - smaller text and icon, suitable for dense UIs
+
+
+
+---
+
+### Disabled
+
+When `disabled` is `true`, the icon and text both render in a muted, non-interactive style regardless of the underlying value. Use this when the data is unavailable, loading, or the surrounding context is non-interactive.
+
+
+
+
+
+
+## Setup
+
+Install and set up the library with our [Setup Guide →](?path=/docs/getting-started-setup--docs).
+
+### Basic Usage
+
+Pass any numeric value - the component handles color, icon, and formatting automatically.
+
+```tsx
+import { Trend } from '@ledgerhq/lumen-ui-rnative';
+
+function MyComponent() {
+ return ;
+}
+```
+
+### Sizes
+
+Choose `sm` for dense layouts like tables or compact list rows, and `md` for standard content areas.
+
+```tsx
+
+
+```
+
+### Disabled State
+
+Wrap or pass `disabled` when the value is unavailable or the parent context is non-interactive. The component style updates automatically - no extra styling needed.
+
+```tsx
+
+```
+
+### Layout Adjustments
+
+Use `lx` for layout only - not for overriding colors or typography. The component's visual appearance is controlled exclusively via props.
+
+```tsx
+
+```
+
+
+
diff --git a/libs/ui-rnative/src/lib/Components/Trend/Trend.stories.tsx b/libs/ui-rnative/src/lib/Components/Trend/Trend.stories.tsx
new file mode 100644
index 000000000..d0b5915a3
--- /dev/null
+++ b/libs/ui-rnative/src/lib/Components/Trend/Trend.stories.tsx
@@ -0,0 +1,61 @@
+import type { Meta, StoryObj } from '@storybook/react-native-web-vite';
+import { Box } from '../Utility/Box';
+import { Trend } from './Trend';
+
+const meta: Meta = {
+ component: Trend,
+ title: 'Communication/Trend',
+ argTypes: {
+ value: {
+ control: 'number',
+ },
+ size: {
+ control: 'radio',
+ options: ['sm', 'md'],
+ },
+ disabled: {
+ control: 'boolean',
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Base: Story = {
+ args: {
+ value: 5.25,
+ size: 'md',
+ disabled: false,
+ },
+};
+
+export const VariantShowcase: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
+
+export const SizeShowcase: Story = {
+ render: () => (
+
+
+
+
+ ),
+};
+
+export const DisabledShowcase: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
diff --git a/libs/ui-rnative/src/lib/Components/Trend/Trend.test.tsx b/libs/ui-rnative/src/lib/Components/Trend/Trend.test.tsx
new file mode 100644
index 000000000..3af75205d
--- /dev/null
+++ b/libs/ui-rnative/src/lib/Components/Trend/Trend.test.tsx
@@ -0,0 +1,125 @@
+import { describe, it, expect } from '@jest/globals';
+import { ledgerLiveThemes } from '@ledgerhq/lumen-design-core';
+import { render } from '@testing-library/react-native';
+import { ThemeProvider } from '../ThemeProvider/ThemeProvider';
+import { Trend } from './Trend';
+
+const TestWrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+);
+
+describe('Trend Component', () => {
+ it('should render positive value', () => {
+ const { getByText } = render(
+
+
+ ,
+ );
+ expect(getByText('5.50%')).toBeTruthy();
+ });
+
+ it('should render negative value', () => {
+ const { getByText } = render(
+
+
+ ,
+ );
+ expect(getByText('-3.20%')).toBeTruthy();
+ });
+
+ it('should render neutral when value is zero', () => {
+ const { getByText } = render(
+
+
+ ,
+ );
+ expect(getByText('0.00%')).toBeTruthy();
+ });
+
+ it('should render with sm size', () => {
+ const { getByText } = render(
+
+
+ ,
+ );
+ expect(getByText('1.50%')).toBeTruthy();
+ });
+
+ it('should render with md size', () => {
+ const { getByText } = render(
+
+
+ ,
+ );
+ expect(getByText('1.50%')).toBeTruthy();
+ });
+
+ it('should render in disabled state', () => {
+ const { getByText } = render(
+
+
+ ,
+ );
+ expect(getByText('5.00%')).toBeTruthy();
+ });
+
+ it('should pass testID to root element', () => {
+ const { getByTestId } = render(
+
+
+ ,
+ );
+ expect(getByTestId('trend-id')).toBeTruthy();
+ });
+
+ it('should format value to 2 decimal places', () => {
+ const { getByText } = render(
+
+
+ ,
+ );
+ expect(getByText('1.12%')).toBeTruthy();
+ });
+
+ it('should render all variants side by side', () => {
+ const { getByText } = render(
+
+
+
+
+ ,
+ );
+ expect(getByText('10.00%')).toBeTruthy();
+ expect(getByText('-10.00%')).toBeTruthy();
+ expect(getByText('0.00%')).toBeTruthy();
+ });
+
+ it('should expose positive accessibility label', () => {
+ const { getByLabelText } = render(
+
+
+ ,
+ );
+ expect(getByLabelText('Trending up by 5.50%')).toBeTruthy();
+ });
+
+ it('should expose negative accessibility label with absolute value', () => {
+ const { getByLabelText } = render(
+
+
+ ,
+ );
+ expect(getByLabelText('Trending down by 3.20%')).toBeTruthy();
+ });
+
+ it('should expose neutral accessibility label', () => {
+ const { getByLabelText } = render(
+
+
+ ,
+ );
+ expect(getByLabelText('No change, 0.00%')).toBeTruthy();
+ });
+});
diff --git a/libs/ui-rnative/src/lib/Components/Trend/Trend.tsx b/libs/ui-rnative/src/lib/Components/Trend/Trend.tsx
new file mode 100644
index 000000000..0a13a4450
--- /dev/null
+++ b/libs/ui-rnative/src/lib/Components/Trend/Trend.tsx
@@ -0,0 +1,118 @@
+import { useDisabledContext } from '@ledgerhq/lumen-utils-shared';
+import { StyleSheet } from 'react-native';
+import { useCommonTranslation } from '../../../i18n';
+import type { LumenTextStyle } from '../../../styles';
+import { useStyleSheet } from '../../../styles';
+import { Minus, TriangleDown, TriangleUp } from '../../Symbols';
+import type { IconSize } from '../Icon';
+import { Box, Text } from '../Utility';
+import type { TrendProps } from './types';
+
+type TrendVariant = 'positive' | 'negative' | 'neutral';
+
+function getVariant(value: number): TrendVariant {
+ if (value === 0) {
+ return 'neutral';
+ }
+ return value > 0 ? 'positive' : 'negative';
+}
+
+export function Trend({
+ value,
+ size = 'md',
+ lx = {},
+ disabled: disabledProp = false,
+ style,
+ ...props
+}: TrendProps) {
+ const variant = getVariant(value);
+
+ const disabled = useDisabledContext({
+ consumerName: 'Trend',
+ mergeWith: { disabled: disabledProp },
+ });
+ const { t } = useCommonTranslation();
+
+ const styles = useStyles({ size, variant, disabled });
+
+ const Icon = {
+ positive: TriangleUp,
+ negative: TriangleDown,
+ neutral: Minus,
+ }[variant];
+
+ const iconSize = (
+ {
+ md: 16,
+ sm: 12,
+ } as const
+ )[size] as IconSize;
+
+ const iconColor = (
+ {
+ positive: 'success',
+ negative: 'error',
+ neutral: 'muted',
+ } as const
+ )[variant] as LumenTextStyle['color'];
+
+ const absoluteFormattedValue = `${Math.abs(value).toFixed(2)}%`;
+ const formattedValue =
+ value < 0 ? `-${absoluteFormattedValue}` : absoluteFormattedValue;
+
+ return (
+
+
+ {formattedValue}
+
+ );
+}
+
+const useStyles = ({
+ size,
+ variant,
+ disabled,
+}: {
+ size: NonNullable;
+ variant: TrendVariant;
+ disabled: boolean;
+}) =>
+ useStyleSheet(
+ (t) => {
+ const color = {
+ positive: t.colors.text.success,
+ negative: t.colors.text.error,
+ neutral: t.colors.text.muted,
+ }[variant];
+
+ const sizeMap = {
+ sm: t.typographies.body3,
+ md: t.typographies.body2,
+ }[size];
+
+ return {
+ container: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: t.spacings.s2,
+ },
+ text: StyleSheet.flatten([
+ {
+ ...sizeMap,
+ color,
+ },
+ disabled && { color: t.colors.text.disabled },
+ ]),
+ };
+ },
+ [size, variant, disabled],
+ );
diff --git a/libs/ui-rnative/src/lib/Components/Trend/index.ts b/libs/ui-rnative/src/lib/Components/Trend/index.ts
new file mode 100644
index 000000000..723f0d45b
--- /dev/null
+++ b/libs/ui-rnative/src/lib/Components/Trend/index.ts
@@ -0,0 +1,2 @@
+export * from './Trend';
+export * from './types';
diff --git a/libs/ui-rnative/src/lib/Components/Trend/types.ts b/libs/ui-rnative/src/lib/Components/Trend/types.ts
new file mode 100644
index 000000000..7a976de49
--- /dev/null
+++ b/libs/ui-rnative/src/lib/Components/Trend/types.ts
@@ -0,0 +1,20 @@
+import type { BoxProps } from '../Utility';
+
+export type TrendProps = {
+ /**
+ * The value to display in the trend. This value affects the appearance of the component in terms of color and icon.
+ * @required
+ */
+ value: number;
+ /**
+ * The size of the trend component.
+ * @default md
+ */
+ size?: 'sm' | 'md';
+ /**
+ * When `true`, shows a muted appearance on the trend, regardless of value.
+ *
+ * @default false
+ */
+ disabled?: boolean;
+} & Omit;
diff --git a/libs/ui-rnative/src/lib/Components/index.ts b/libs/ui-rnative/src/lib/Components/index.ts
index 1d3d41596..02f5274d6 100644
--- a/libs/ui-rnative/src/lib/Components/index.ts
+++ b/libs/ui-rnative/src/lib/Components/index.ts
@@ -4,15 +4,15 @@ export * from './AmountInput';
export * from './Avatar';
export * from './Banner';
export * from './BottomSheet';
-export * from './DotCount';
-export * from './DotIndicator';
export * from './Button';
export * from './Card';
export * from './CardButton';
-export * from './ContentBanner';
export * from './Checkbox';
+export * from './ContentBanner';
export * from './Divider';
+export * from './DotCount';
export * from './DotIcon';
+export * from './DotIndicator';
export * from './DotSymbol';
export * from './Icon';
export * from './IconButton';
@@ -37,9 +37,10 @@ export * from './Subheader';
export * from './Switch';
export * from './TabBar';
export * from './Tag';
-export * from './Utility';
export * from './TextInput';
export * from './ThemeProvider';
export * from './Tile';
export * from './TileButton';
export * from './Tooltip';
+export * from './Trend';
+export * from './Utility';