@socprime/react-ui
v0.0.19
Published
SOC Prime React UI component library and Storybook design system.
Readme
@socprime/react-ui
React component library for SOC Prime products. Ships pre-built ESM modules with TypeScript definitions: generic UI primitives built on Radix UI, CVA and Tailwind CSS, plus a set of SOC Prime domain components behind separate entry points.
Requirements
| Requirement | Notes |
| --- | --- |
| React 19 | react and react-dom are peer dependencies |
| Tailwind CSS v4 | The library ships class names, not compiled utilities — your Tailwind build generates them |
| ESM-capable bundler | Vite, webpack 5, Next.js, Rollup, esbuild. The package is ESM-only; require() is not supported |
| TypeScript 5 (optional) | moduleResolution must be bundler or node16 |
| Node.js 20+ | Only for the build/dev toolchain of your app |
Installation
npm install @socprime/react-ui
# or
yarn add @socprime/react-uiInstall the required peer dependencies alongside it:
npm install react react-dom react-router-dom @tanstack/react-tableOptional peers are only needed for the corresponding subpath — see Peer dependencies.
Quick start
Adopting the library takes three steps. Skipping any of them produces components that render but look broken.
1. Add the theme
The library styles itself entirely through CSS custom properties and Tailwind theme tokens that your app must define. Copy the reference theme into your global stylesheet (for example src/index.css), then import it once at your entry point:
// src/main.tsx
import './index.css';2. Point Tailwind at the package
Tailwind only generates utilities it can find in scanned sources. Classes like rounded-xs or bg-btn-primary exist only inside the library, so your Tailwind build must scan it:
/* src/index.css */
@import 'tailwindcss';
@source "../node_modules/@socprime/react-ui/**/*.js";Without this line every component renders unstyled.
3. Set up the app shell
// src/App.tsx
import { BrowserRouter } from 'react-router-dom';
import { Button, Toaster, applyTheme } from '@socprime/react-ui';
import { useEffect } from 'react';
export function App() {
// Adds the `.dark` / `.light` class and `data-theme` attribute to <html>.
useEffect(() => applyTheme('dark'), []);
return (
<BrowserRouter>
<Button variant="primary" size="m" onClick={() => console.log('clicked')}>
Get started
</Button>
{/* Render once, anywhere in the tree — required for toast notifications. */}
<Toaster />
</BrowserRouter>
);
}BrowserRouter (or any React Router provider) is required if you render Aside, PageHeader, or a Button with the to prop — those use React Router internally. Everything else works without a router.
Tooltip provides its own context, so no extra provider is needed for it.
Entry points
The public API is deliberately narrow: seven entry points, no deep imports.
| Import path | Contents | Extra peers |
| --- | --- | --- |
| @socprime/react-ui | Generic, product-agnostic primitives | — |
| @socprime/react-ui/domain | SOC Prime domain components | — |
| @socprime/react-ui/editor | Monaco-based code editor | monaco-editor, @monaco-editor/react |
| @socprime/react-ui/charts | Charts and statistics cards | recharts |
| @socprime/react-ui/attack-flow | MITRE ATT&CK flow graph | @xyflow/react, dagre |
| @socprime/react-ui/preset | Storybook config factory | @storybook/react-vite, vite, vite-plugin-svgr |
| @socprime/react-ui/decorators | Storybook decorators | @storybook/react-vite |
Heavy modules live behind their own subpaths so that an app which never imports /editor does not pay for Monaco — neither in bundle size nor in installed dependencies.
import { Button, Table, Toaster } from '@socprime/react-ui';
import { Severity, type TSeverity } from '@socprime/react-ui/domain';
import { Editor } from '@socprime/react-ui/editor';
import { AreaChartWithGradient, StatisticCard } from '@socprime/react-ui/charts';
import { AttackFlow } from '@socprime/react-ui/attack-flow';Deep imports such as @socprime/react-ui/dist/ui/Button are blocked by the exports map. This is intentional — it keeps the internal layout of the package free to change without breaking consumers.
Components
Root export
Forms
Field, FieldSet, FieldGroup, FieldLabel, FieldTitle, FieldDescription, FieldContent, FieldError, FieldLegend, FieldSeparator, Input, InputNumber, InputPassword, Textarea, Checkbox, RadioGroup, RadioGroupItem, SegmentedControl, Switch, Slider, Select, SelectDefault, SelectTrigger, SelectContent, SelectItem, SelectGroup, SelectLabel, SelectValue, SelectSeparator, SelectScrollUpButton, SelectScrollDownButton, MultiSelect, SearchInput, SearchInputFields, Suggestions, Label, LabelInfo, HelperText
Actions and overlays
Button, buttonVariants, DeleteButtonConfirm, Dialog, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription, DialogOverlay, DialogPortal, DialogClose, DefaultDialog, ConfirmDeleteDialog, DropdownMenu (with Trigger, Content, Item, CheckboxItem, RadioGroup, RadioItem, Label, Group, Separator, Shortcut, Sub, SubTrigger, SubContent, Portal), DropdownsCheckbox, DropdownsSelect, Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, TooltipParent, Toaster
Data display
Table, TableHeader, TableHead, TableBody, TableRow, TableCell, TableFooter, TableCaption, TableWrap, SortableHeader, GroupHeaderRow, ServerGroupedTableBody, SkeletonTable, Pagination, PaginationWrap, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, PaginationEllipsis, Badge, badgeVariants, EmptyState, TextTruncate
Layout and navigation
Aside, PageHeader, Tabs, Accordion, AccordionItem, AccordionTrigger, AccordionContent, AccordionSection, FolderTreeExpandTrigger, ScrollArea, ScrollBar, Separator, TreeGuide, TreeRow
Feedback and state
Spinner, SpinnerCustom, SpinnerSquare, AnimatedDots, Skeleton, Progress, ConditionalContent
Theming
ThemeToggle, applyTheme, useDocumentTheme, ThemeName
Every component exports its props type under the <Name>Props convention — ButtonProps, TableWrapProps, and so on.
Subpath exports
| Entry point | Exports |
| --- | --- |
| /domain | Severity, CorrelationTimer, SyncProcessBar, TSeverity |
| /editor | Editor, EditorProps, Theme |
| /charts | AreaChartWithGradient, StatisticCard, AreaChartDataPoint, StatisticsGraphData, StatisticsGraphDataPoint |
| /attack-flow | AttackFlow, AttackFlowProps, NodeData |
| /preset | createStorybookConfig, createStorybookPreview, StorybookHostOptions |
| /decorators | withTheme, withRouter, applyTheme, ThemeName |
Theme contract
This is the part that most often goes wrong on first integration. The library never ships colors of its own — it references Tailwind theme tokens and CSS custom properties by name and expects the host application to define them.
You need three things in your global stylesheet:
- CSS custom properties for
:root,.lightand.dark. - A
@theme inlineblock mapping those properties onto Tailwind color tokens. - Keyframes, base styles and utilities used by
Accordion,AnimatedDots,SyncProcessBar,CheckboxandTextTruncate.
The .light / .dark classes are toggled on <html> by applyTheme() and by the ThemeToggle component.
@import 'tailwindcss';
@source "../node_modules/@socprime/react-ui/**/*.js";
@custom-variant dark (&:is(.dark *));
@custom-variant light (&:is(.light *));
:root {
--font-size: 1rem;
--font-weight-normal: 400;
--radius-xs: 0.3rem;
--radius-s: 0.4rem;
--radius-sm: 0.5rem;
--white: #ffffff;
--ring: #95c9b4;
--darkGray: #6b7280;
--btn-primary: #4ac18e;
--btn-primary-hover: #3bac7c;
--btn-primary-active: #329269;
--btn-primary-disabled: #333544;
--btn-secondary: #3b3d4f;
--btn-secondary-hover: #50536b;
--btn-secondary-active: #5b5e7a;
--btn-secondary-disabled: #333544;
--btn-destructive: #ee3523;
--btn-destructive-hover: #dc2311;
--btn-destructive-active: #ba1e0e;
--btn-destructive-disabled: #333544;
--border-primary: #95c9b4;
--border-disabled: #d2d5d8;
--border-critical: #fd5749;
--border-critical-disabled: #ffa7a3;
--bg-critical-light: #fd5749;
--red-violet: #c32181;
}
.light {
--btn-secondary: #cfd0d3;
--tooltip-white: #ffffff;
--tooltip-black: #000000;
--bg-primary: #f9f9f9;
--bg-secondary: #f9f9f9;
--bg-success: #4ac18e;
--bg-warning: #ee9d23;
--bg-purple: #c27aff;
--bg-light-blue: #51a2ff;
--bg-blue: #015bbb;
--bg-gray-chateau: #99a1af;
--bg-comet: #54556a;
--bg-critical: #ee3523;
--bg-hover: #cfd0d3;
--bg-yellow: #ffe824;
--foreground: #292c3d;
--border: #cfd0d3;
--btn-ghost: #cfd0d3;
--text-subdued: #292c3d;
--text-default: #1a1e2f;
--text-dark: #1a1e2f;
--text-warning: #ee9d23;
--text-critical: #ee3523;
--mirisk: #99a1af;
}
.dark {
--btn-secondary: #252838;
--tooltip-white: #000000;
--tooltip-black: #ffffff;
--bg-black: #ffffff;
--bg-primary: #1f2232;
--bg-secondary: #252838;
--bg-success: #4ac18e;
--bg-warning: #ee9d23;
--bg-purple: #c27aff;
--bg-light-blue: #51a2ff;
--bg-blue: #015bbb;
--bg-gray-chateau: #99a1af;
--bg-comet: #54556a;
--bg-critical: #ee3523;
--bg-hover: #3b3d4f;
--bg-yellow: #ffe824;
--foreground: #f9f9f9;
--border: #3b3d4f;
--btn-ghost: #3b3d4f;
--text-subdued: #cfd0d3;
--text-default: #f9f9f9;
--text-dark: #1a1e2f;
--text-warning: #ee9d23;
--text-critical: #ee3523;
--silver: #d1d5dc;
}
@theme inline {
--color-primary: var(--bg-primary);
--color-secondary: var(--bg-secondary);
--color-foreground: var(--foreground);
--color-border: var(--border);
--color-default: var(--text-default);
--color-subdued: var(--text-subdued);
--color-dark: var(--text-dark);
--color-white: var(--white);
--color-dark-gray: var(--darkGray);
--color-silver: var(--silver);
--color-ring: var(--ring);
--color-hover: var(--bg-hover);
--color-btn-primary: var(--btn-primary);
--color-btn-primary-hover: var(--btn-primary-hover);
--color-btn-primary-active: var(--btn-primary-active);
--color-btn-primary-disabled: var(--btn-primary-disabled);
--color-btn-secondary: var(--btn-secondary);
--color-btn-secondary-hover: var(--btn-secondary-hover);
--color-btn-secondary-active: var(--btn-secondary-active);
--color-btn-secondary-disabled: var(--btn-secondary-disabled);
--color-btn-destructive: var(--btn-destructive);
--color-btn-destructive-hover: var(--btn-destructive-hover);
--color-btn-destructive-active: var(--btn-destructive-active);
--color-btn-destructive-disabled: var(--btn-destructive-disabled);
--color-btn-ghost: var(--btn-ghost);
--color-success: var(--bg-success);
--color-warning: var(--bg-warning);
--color-critical: var(--bg-critical);
--color-critical-light: var(--bg-critical-light);
--color-purple: var(--bg-purple);
--color-light-blue: var(--bg-light-blue);
--color-blue: var(--bg-blue);
--color-yellow: var(--bg-yellow);
--color-gray-chateau: var(--bg-gray-chateau);
--color-comet: var(--bg-comet);
--color-red-violet: var(--red-violet);
--color-tooltip-white: var(--tooltip-white);
--color-tooltip-black: var(--tooltip-black);
--text-3xs: 0.625rem;
--text-2xs: 0.75rem;
--text-xs: 0.875rem;
--text-sm: 1rem;
--text-m: 1.125rem;
--text-l: 1.25rem;
--text-xl: 1.375rem;
--text-lg: 1.5rem;
--animate-accordion-down: accordion-down 0.2s ease-out;
--animate-accordion-up: accordion-up 0.2s ease-out;
}
@layer base {
html,
body {
font-size: var(--font-size);
font-weight: var(--font-weight-normal);
color: var(--foreground);
background-color: var(--bg-primary);
scrollbar-width: thin;
scrollbar-color: var(--border) var(--bg-primary);
}
input[type='checkbox'] {
appearance: none;
width: 1rem;
height: 1rem;
border-width: 1px;
border-radius: 0.25rem;
cursor: pointer;
position: relative;
flex-shrink: 0;
}
input[type='checkbox']:checked::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0.375rem;
height: 0.625rem;
border: solid var(--white);
border-width: 0 2px 2px 0;
transform: translate(-50%, -60%) rotate(45deg);
}
input[type='checkbox']:focus,
input[type='checkbox']:focus-visible {
outline: none;
}
}
@keyframes accordion-down {
from { height: 0; opacity: 0; }
to { height: var(--radix-accordion-content-height); opacity: 1; }
}
@keyframes accordion-up {
from { height: var(--radix-accordion-content-height); opacity: 1; }
to { height: 0; opacity: 0; }
}
@keyframes sync-progress {
0% { transform: translateX(-100%); }
100% { transform: translateX(350%); }
}
@keyframes dot-loading-1 {
0%, 100% { opacity: 1; }
}
@keyframes dot-loading-2 {
0%, 33% { opacity: 0; }
34%, 99% { opacity: 1; }
100% { opacity: 0; }
}
@keyframes dot-loading-3 {
0%, 66% { opacity: 0; }
67%, 99% { opacity: 1; }
100% { opacity: 0; }
}
@layer utilities {
.text-default { color: var(--text-default); }
.animate-progress {
animation: sync-progress 2.5s cubic-bezier(0.4, 0, 0.2, 1) infinite;
will-change: transform;
}
.animate-dot-loading-1 { animation: dot-loading-1 1.2s step-end infinite; }
.animate-dot-loading-2 { animation: dot-loading-2 1.2s step-end infinite; }
.animate-dot-loading-3 { animation: dot-loading-3 1.2s step-end infinite; }
.truncate-1 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
}
}The palette above is the SOC Prime default. Override any custom property to rebrand — the components read tokens only, never hard-coded colors.
Component-level CSS
Button and SpinnerSquare ship a small compiled .css file each. They are imported by the components themselves, so no manual CSS import is needed; the package declares sideEffects so bundlers keep those imports while still tree-shaking unused components.
Peer dependencies
Required for every consumer:
{
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0",
"@tanstack/react-table": "^8.21.0"
}react-router-dom is required rather than optional: Button, PageHeader and Aside use React Router internally, and a second copy of the router in the bundle would carry its own context — links rendered by the library would not see your <BrowserRouter>.
Optional peers, needed only when you import the matching subpath:
| Package | Required for |
| --- | --- |
| monaco-editor, @monaco-editor/react | @socprime/react-ui/editor |
| recharts | @socprime/react-ui/charts |
| @xyflow/react, dagre | @socprime/react-ui/attack-flow |
| @storybook/react-vite, vite, vite-plugin-svgr | @socprime/react-ui/preset, @socprime/react-ui/decorators |
Everything else (@radix-ui/*, lucide-react, class-variance-authority, clsx, tailwind-merge, motion, sonner) is a regular dependency and installs automatically.
TypeScript
Types ship with the package — there is no separate @types package. Props types are exported as named type exports next to each component:
import { Button, type ButtonProps } from '@socprime/react-ui';
type SubmitButtonProps = Omit<ButtonProps, 'variant'> & { pending: boolean };Your tsconfig.json must use a resolution mode that understands exports:
{
"compilerOptions": {
"moduleResolution": "bundler"
}
}With the legacy "node" resolution, subpath imports resolve to neither JavaScript nor types.
Bundler notes
Tree shaking
dist/ is built as separate ESM modules rather than one bundle, so importing { Button } from the root export does not pull in Table or Dropdown. All relative imports are fully specified (./Button/index.js), which keeps the output valid for strict resolvers such as Node ESM and webpack 5 with fullySpecified.
Monaco Editor in Vite hosts
@socprime/react-ui/editor registers Monaco workers with new Worker(new URL(specifier, import.meta.url), { type: 'module' }) rather than the Vite-specific ?worker suffix. ?worker is only understood by the full Vite pipeline; during dependency pre-bundling (optimizeDeps, which runs through bare esbuild) such imports fail with errors like Cannot read file: .../ts.worker.js?worker. The new URL(...) form is understood natively by Vite, webpack 5+ and esbuild alike.
Storybook preset
If your team maintains its own Storybook on top of this design system, the package provides a config factory and decorators.
// .storybook/main.ts
import path from 'node:path';
import { createStorybookConfig, type StorybookHostOptions } from '@socprime/react-ui/preset';
const hostRoot = '/absolute/path/to/your/app';
export default createStorybookConfig({
hostRoot,
hostSrcDir: path.join(hostRoot, 'src'),
themeEntry: path.join(hostRoot, 'src/index.scss'),
packageRoot: path.resolve(import.meta.dirname, '..'),
appVersion: '1.0.0',
} satisfies StorybookHostOptions);// .storybook/preview.tsx
import { createStorybookPreview } from '@socprime/react-ui/preset';
import '@storybook-host/theme-entry';
export default createStorybookPreview();StorybookHostOptions
| Field | Description |
| --- | --- |
| hostRoot | Absolute path to the host app root. Its public/ is mounted as static files |
| hostSrcDir | Absolute path to host/src, used as a Sass loadPaths entry |
| themeEntry | Absolute path to the global theme stylesheet, exposed as the @storybook-host/theme-entry alias |
| packageRoot? | Root of your package, the one that owns stories/. Required when /preset is consumed as an installed dependency — otherwise createStorybookConfig throws instead of silently finding zero stories |
| appVersion? | Injected into the build as __APP_VERSION__ |
| extraStories? | Additional story globs, relative to packageRoot |
Decorators are available separately:
import { withTheme, withRouter } from '@socprime/react-ui/decorators';withTheme applies the .light / .dark class and adds a Theme toolbar toggle; withRouter wraps stories in a MemoryRouter so router-aware components work in isolation.
Troubleshooting
| Symptom | Cause |
| --- | --- |
| Components render completely unstyled | Missing @source directive, so Tailwind never generated the library's utilities |
| Colors are wrong or transparent | The theme custom properties are not defined, or .light / .dark is not set on <html> — call applyTheme() |
| useHref() may be used only in the context of a <Router> | Aside, PageHeader or Button to= rendered outside a React Router provider |
| Cannot find module '@socprime/react-ui/charts' in TS | moduleResolution is "node"; switch to "bundler" or "node16" |
| require() of ES Module | The package is ESM-only; use import or a dynamic import() |
| Toasts never appear | <Toaster /> is not rendered anywhere in the tree |
Versioning
See CHANGELOG.md for release history and breaking changes.
License
MIT
