npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@nxgt/material-core

v0.24.0

Published

The framework-free core of @nxgt/material and @nxgt/material-vue: the PDF document model and its react-pdf renderer, print formatters, PDF file helpers, the PDF viewer's pure modules, the currency digits, the file-size formatter, the field-blur layers, th

Readme

@nxgt/material-core

The framework-free core that @nxgt/material (React) and @nxgt/material-vue (Vue) share, as one copy: the PDF document model and its react-pdf renderer, the print formatters, the PDF file helpers, the PDF viewer's framework-free half (page geometry, scroll spy, text search), the currency field's decimals, the file-size formatter, the slider's tick marks, the emoji list's groups, the date range presets, local calendar-day arithmetic (/day-math), a debounce, accent-insensitive text matching and a race-free async search (/debounce, /text-match, /async-search), the room an open list has above a Drawer's footer (/drawer-room), the file preview's kind detection, the fields' blur rule, a field's first reachable control (/focus-control) and its aria-describedby (/described-by), where the focus goes in a repeatable row group (/repeatable-group), the page behind a modal layer made inert (/inert-others), Escape pressed while an overlay closes, in a field whose list is open or in a control marked data-keeps-escape (/escape-forward), the Toaster's guard against a tap's emulated hover (/touch-hover), the focus a toast's action hands back (/toast-focus), a table row's activation, the zod error map's issue messages (/zod-issue-messages), a zod error map that words an issue by its code (/code-errors), the page's Content Security Policy nonce (/csp-nonce), the server and build side of the CSP (/csp), an object built on its first use (/lazy-object), one submit in flight (/submit-guard), the two-step verification helpers (/two-factor: a TOTP secret grouped to be typed, the recovery codes' text file, a text download), the empty and error states' kinds, copy keys, error details and illustrations (/states), printing one element of the page (/print-only), the list filter's headless core (/filter: its model, URL codec, state machine and wording), a server table's state in the URL (/table-url), the engine behind the background presets (/backgrounds), the geometry and motion of the progress indicators (/progress), Material 3 Expressive's shapes as data with SVG and CSS paths and morphs (/shapes), the media player's cues, chapters, transcript, keyboard map, queue, resume, gestures, peaks, quality menu and corner snapping (/media), the Vite plugin that bundles pdfkit's standard fonts and a build.assetsInlineLimit that keeps fonts out of data: URIs, and the Node-only resolution of those fonts that the Next.js helper and the Nuxt module run at build time.

You usually do not install it yourself. Both libraries depend on it and re-export what they always exported, at the same subpaths: @nxgt/material/print, @nxgt/material-vue/pdf, /lib and /vite read from here, and so do their PdfReader, CurrencyField, form fields (their focus, blur, aria-describedby and zod messages), file list and upload field (formatFileSize), modal layers (the inert page behind, Escape while closing, Escape in a field whose list is open or in a control marked data-keeps-escape), Toaster, DataTable rows, @nxgt/material/next and @nxgt/material-vue/nuxt. Import it directly when you want the model or a formatter without a UI library, or one of these helpers in a component of your own.

import { heading, pdfDocument, text } from '@nxgt/material-core/pdf/model';
import { formatAmount } from '@nxgt/material-core/print-format';

const invoice = pdfDocument({
	pages: [{ body: [heading('Invoice'), text(formatAmount(12.5, 'EUR'))] }],
});

0.x. The subpaths below are what the two libraries build on; they may still move between minor versions until 1.0.

Install

bun add @nxgt/material-core

Its types resolve under "moduleResolution": "bundler" and "nodenext". The built files are plain ES modules and also load in Node without a bundler.

| Optional peer | Needed by | | --- | --- | | react, @react-pdf/renderer@^4.9 | @nxgt/material-core/pdf/render and /pdf — rendering a document | | vite@^5.2 \|\| ^6 \|\| ^7 \|\| ^8 | @nxgt/material-core/vite — its declarations name Vite's Plugin type (types only); the plugin itself runs in your Vite config | | @fontsource-variable/inter@>=5 | @nxgt/material-core/vite — nxgtMaterial() loads Inter only when it is installed, else warns and keeps the system font; fonts: { sans: false } silences the warning |

Every other subpath imports none of them, and is safe to import on a server with no DOM. Two subpaths are Node only: /pdfkit-fonts imports node:module and node:path, and /vite imports node:fs/promises, node:fs and node:path (the styles check reads stylesheets from disk; pdf: { bundle: true } reads installed package.json files), both for a framework's or a Vite config at build time — never import them from code a browser bundle can reach. No other subpath imports a node: built-in.

Subpaths

| Subpath | What it holds | | --- | --- | | @nxgt/material-core | The model, the formatters, the file helpers, http, file-loader — every document and print subpath that imports no framework. The viewer, currency and blur helpers are on their own subpaths only | | /pdf/model | The document model: pdfDocument, page, heading, text, table, dataTable, themes and their types (PdfPageNumberScope, PdfTableStyle, PdfTableRow, PdfTableCell, …) — plain, JSON-serialisable data | | /pdf/render | The react-pdf renderer: renderPdfElement, createPdfContext, renderPdfPage, the node renderers | | /pdf | /pdf/model and /pdf/render together | | /pdf/http | contentDisposition, pdfBytesResponse, toWebStream — serving a rendered PDF | | /pdf/file-loader | createPdfFileLoader, IDLE_PDF_FILE — the state behind usePdfFile / usePdf | | /print-format | formatAmount, formatDate, createFormatters — Intl formatting | | /print-fraction-digits | fractionDigitsOption, MAX_FRACTION_DIGITS | | /print-value | PrintValue, formatPrintValue, isEmptyPrintValue | | /print-table | definePrintColumns, resolvePrintTable, resolvePrintFields, toPrintValue | | /pdf-source | PdfSource and its helpers (toPdfjsSource, pdfSourceKey, …) | | /pdf-file-name | resolvePdfFileName, DEFAULT_PDF_FILE_NAME | | /pdf-download | downloadPdfFile, printPdfFile — in a browser | | /pdf-viewer | The PDF viewer's pure half: page geometry and fit (readerFitScale, placeholderSize, rotatedSize, PdfPageSize, FitMode), page sizes (readPageSizes, createPageSizesLoader), the scroll spy (createScrollSpy and its ScrollSpyOptions, pageInView, revealHit, scrollIntoArea), the opening page's pin (pinToPage, whose optional onRelease runs once when it lets go, and pinHolds, whether the view is still where it puts the page), text search (pageTextFromItems, findTextMatches, searchDocument), escapeHtml, mapPdfOutline, link names (linkAnnotationLabel, labelLinkAnchors, resolveDestinationPage, with PdfLinkLabels, PdfLinkAnnotation, PdfDestinationDocument) | | /pdf-reader | Experimental, used by PdfReader internally: the PDF reader's engine, with pdf.js handed in by shape (PdfjsLike, PdfjsPasswordCallback). Setup and loading: configurePdfReader (PdfReaderConfig), readPdfReaderConfig, openPdfDocument (OpenPdfOptions, PdfjsSourceParams: { url, httpHeaders?, withCredentials? } or { data }; PdfDocumentHandle, typed after the pdf.js handed in), classifyPdfError (PdfLoadError), PDF_PASSWORD_INCORRECT. Rendering: planRender (RenderPlanInput, RenderPlan, MAX_CANVAS_PIXELS), createPageCache (PageCache, PageCacheKey; counted in entries or by a size function), renderPageToCanvas (PageRenderResult, CanvasUnavailableError), the page layouts layoutSpreads (SpreadMode, SpreadOptions; one page a row, or two) with pageAtOffset, anchorAt, offsetOf (PageLayout, PageRow, PageAnchor) and the row helpers rowStarts and mountedPages. Layers: createPdfLinkService (PdfLinkServiceOptions, PdfLinkService, PdfLinkTarget, PdfLinkDocument), PDF_LINK_REL, applyPageHighlight (PdfPageSearch, PdfTextSpans) | | /currency-digits | currencyFractionDigits — the decimals CurrencyField shows | | /field-blur-layers | layerOf, openerOf, FieldLayers — whose overlay a layer of the page is, for a field's blur | | /focus-control | firstControl, focusHandle, FocusHandle — the first control of a field a user can reach, and a stand-in element that focuses it for a form library | | /repeatable-group | focusAfterRemove, focusAfterMove, focusRowTarget, rowMoveTarget, canAddRow, canRemoveRow, RowFocusTarget, RowMoveDirection, RowAnnouncement, ROW_SLOT, ROW_FIELDS_SLOT, ADD_ATTRIBUTE — where the focus goes after a row of a RepeatableGroup is added, removed or moved, and what its live region says | | /escape-forward | forwardEscape, keepListEscape, isKeptEscape — Escape pressed while an overlay closes, pressed again for the overlay below once it has gone; Escape pressed in a field whose list is open, or in a control marked data-keeps-escape, kept from closing the overlay around it, and told from an Escape the page prevented | | /described-by | describedBy — an aria-describedby value from the caller's ids and a field's help and error ids | | /inert-others | inertOthers, INERT_MARKER — the rest of the page inert behind a modal layer, live regions kept, nested layers counted | | /backgrounds | buildBackgroundScene, buildGrain, assertSafeScene, el, SCENE_ATTRIBUTES, SCENE_TAGS, svgIdPrefix, observeBackgroundActivity, mulberry32, pick, round1, valueNoise2, scatter, motifShape, place, BACKGROUND_PRESETS, SHIPPED_BACKGROUND_PRESETS, resolveBackgroundPreset, resolveShippedPreset, presetLayers, SCENE_LAYER, BACKGROUND_TONES, BACKGROUND_INTENSITIES, MOTIF_SHAPES, MOTIF_DEFAULTS, and the types BackgroundPreset, ShippedBackgroundPreset, BackgroundTone, BackgroundIntensity, MotifDensity, MotifShape, MotifSize, OpacitySpread, ShapeFill, SceneBuilder, SceneOptions, SceneTag, ScatterOptions, ScatterPoint, PlaceOptions, MotifOptions, SceneNode, PresetInfo — the engine behind the libraries' background presets: a seeded generator, scattering, motif shapes, a plain SVG scene format with its safety check, ids for url(#…), and the observer that pauses an animation | | /progress | linearFrame, circularFrame, circularRadius, linePath, arcPath, ringWaves, n2, linearIndeterminate, circularIndeterminate, LINEAR_CYCLE, CIRCULAR_CYCLE, smilTimes, stepSpring, springAtRest, waveTarget, cubicBezier, EASING, and the types LinearSpec, LinearState, LinearFrame, Dot, CircularSpec, CircularState, CircularFrame, Spring, Bezier, WaveOptions, RingOptions — the geometry and motion behind the libraries' Progress and CircularProgress: flat and wavy strokes as SVG path data, whole frames (track, gap, active segments, stop dot), Material 3's indeterminate keyframes and the spring a value moves on | | /shapes | buildShape, roundedPolygon, shapeOutline, SHAPES, SHAPE_NAMES, toSvgPath, toCssShape, formatNumber, opticalFit, circleFit, area, bounds, normalize, morph, morphAt, morphSequence, align, resample, MORPH_CUBICS, the 36 shapes by name (circle, cookie9, softBurst, squircle…), and the types ShapeSpec, SpecPoint, ShapeName, ShapeFit, Rounding, Cubic, Outline, Point, Morph, PathOptions — Material 3 Expressive's 35 shapes and a squircle as data: outlines of cubics, an optical fit, an SVG path or a CSS shape(), and morphs between any two; deterministic and DOM-free | | /media | activeCueIndexes, activeCues, announcementFor, chapterAt, chapterMarkers, classifyTap, clearResume, commandForKey, cornerPosition, cueIndexAt, cueStartIndex, downsamplePeaks, flushTap, FOLLOW_QUIET_MS, formatClock, INITIAL_TAP_STATE, isResumable, levelLabel, MEDIA_RESUME_DEFAULTS, MEDIA_SHORTCUTS, nearestCorner, nextRepeatMode, parseThumbnails, parseTimestamp, parseWebVtt, peakIndexAt, playOrder, positionOf, qualityItems, readResume, resumeKey, searchTranscript, seededRandom, shouldFollow, shuffleOrder, spokenTimeParts, stepPosition, thumbnailAt, toChapters, transcriptLines, writeResume, and the types MediaAnnouncement, MediaAnnouncementEvent, MediaChapter, MediaCommand, MediaCorner, MediaCue, MediaKeyCommand, MediaKeyEventLike, MediaLevel, MediaQualityItem, MediaQualityOptions, MediaRepeatMode, MediaResumeOptions, MediaResumeStorage, MediaShortcut, MediaShortcutGroup, MediaThumbnail, SnapInsets, SnapSubject, TapAction, TapOptions, TapPoint, TapState, TranscriptLine — the media player's framework-free half: a forgiving WebVTT reader, the cue on at a time, chapters, thumbnail sprites, the synced transcript, time labels, the keyboard map, the live-region messages, the queue with seeded shuffle and repeat, storage-agnostic resume, tap gestures, waveform peaks, quality menu items and corner snapping | | /touch-hover | ignoreTouchHover, TOASTER_PHONE_QUERY, TOASTER_PHONE_POSITION — keeps a tap from hovering an element, so a toast a finger touched does not stay paused; where the Toaster puts toasts on a phone | | /toast-focus | focusOrigin, handFocusBack, trackToasterOrigin, TOASTER_SELECTOR — where the focus was before it entered a toast, and handing it back there before the toast's action runs, so a dialog the action opens keeps the focus | | /zod-issue-messages | sizeMessage, invalidFormatMessage, IssueText — the translated text of a zod too_big / too_small and invalid_format issue | | /row-activation | isRowActivation, followRowLink, ROW_CONTROL_SELECTOR, ROW_LINK_SELECTOR, RowClick — whether a click on a table row follows its link, and following it | | /format-file-size | formatFileSize — a byte count as '1.5 KB', or in a locale's unit | | /slider-ticks | tickValues — where a slider's tick marks sit | | /emoji-groups | groupKey, groupEmojis, searchEmojis, groupName, GROUP_NAMES and the type EmojiItem — how the emoji list groups, searches and names its emojis | | /date-range | DATE_RANGE_PRESET_IDS, presetRange, resolvePresetRange, isSameDayRange, isRangeDisabled, withTimeOf, endOfDayTime, clampRangeEnd, keepRangeTimes and the types DayRange, PartialDayRange, DateRangePreset, DateRangePresetId, DateRangePresetOption, DateRangeRules — DateRangeField's presets and times | | /csp-nonce | getCspNonce, setCspNonce, claimCspNonce(nonce, depth), createNoncedStyle, and getNonce / setNonce (the get-nonce API) — the page's Content Security Policy nonce, for a <style> created at runtime; browser only | | /csp | createCspNonce, cspPolicy, cspHash, applyCspNonce, CSP_NONCE_PLACEHOLDER, ZOD_JITLESS_SCRIPT, zodJitlessTag and the types CspPolicyOptions, CspDirectives — the Content Security Policy for a server or a build: a nonce per request, the recommended header, hashes of inline code, zod's jitless switch; no DOM | | /lazy-object | lazyObject, buildOnce — a value built on its first use rather than when its module loads: the libraries' zod schemas | | /debounce | debounce, the type Debounced — a function called once calls pause, with cancel and flush (SearchField's debounceMs) | | /initials | initials — an avatar's initials from a name, 'Ada Lovelace' → 'AL' (AccountMenu) | | /text-match | foldText, matchesText, matchRange, matchRanges, type MatchRange — case- and accent-insensitive matching ('e' finds "École"), lowercased for a locale, and where it matched (the first match, or every one) | | /async-search | createAsyncSearch, searchAnnouncement, DEFAULT_SEARCH_DEBOUNCE_MS, DEFAULT_MIN_QUERY_LENGTH and the types AsyncSearch, AsyncSearchConfig, AsyncSearchState, AsyncSearchStatus, SearchFn, SearchContext, SearchAnnouncement — Autocomplete's server search: debounced, each run aborting the last, a stale response dropped | | /day-math | daysAgo, dayBucket, isSameDay, startOfDay, localIsoDay, toDate, RECENT_DAYS, the types DayInput, DayBucket — local calendar-day arithmetic (DST-proof, a rolling week) behind DaySeparator and DayGroupedList | | /drawer-room | drawerRoom(element, top) — the height in px an open floating list may take under top inside a Drawer without covering its footer (at least 80, an 8px gap kept); null outside a Drawer (Autocomplete) | | /submit-guard | createSubmitGuard, busySettler and the types SubmitGuard, SubmitOnceOptions — one submit in flight until its request ends, behind both libraries' useSubmitOnce | | /code-errors | createCodeErrorMap, defaultCodeOf, isEmptyRequired and the types CodeErrorMap, CodeErrorMapOptions, CodeIssue — a zod 4 error map that words an issue by the code it carries (a refinement's params.code), behind both libraries' useCodeResolver | | /two-factor | groupSecret, recoveryCodesText, downloadText and the types RecoveryCodesTextOptions, DownloadTextOptions — a TOTP secret in groups of four, the recovery codes as a text file, and saving a text file, behind both libraries' EnrolmentSecret and RecoveryCodes | | /print-only | printOnly, PRINT_ATTRIBUTE, PRINT_TARGET_ATTRIBUTE — printing one element of the page with no inline style, behind RecoveryCodes' Print button; re-exported by both libraries' /lib | | /file-preview | fileKind, describeFile, safePreviewUrl, readTextPreview, FilePreviewHttpError, FilePreviewUnsafeUrlError, TEXT_PREVIEW_MAX_BYTES, fileExtension, fileNameFromUrl, dataUrlType, kindOfMimeType, normaliseMimeType, isTextFileKind, TEXT_FILE_KINDS and the types FileKind, FileKindInput, DescribedFile, FilePreviewSource, TextPreview — FilePreview's kind detection, URL allow-list and text reader | | /table-url | createSorting, readCursor, nextPage, previousPage, firstPage, serverPagination, queryKey, isNewQuery, pagingNames, FIRST_PAGE, isFiltered, withoutFilters, tableFilters, paramsFromQuery, queryFromParams and the types RouterQuery, PageCursor, PagingNames, CursorConnection, ServerPagination, ServerPaginationOptions, SortingOptions, TableSort, TableSorting, TableFilterOptions, TableFilterBinding — a server table's state in the URL: the sort, forward cursor paging, the footer's page, the query key, and helpers over /filter's codec; queryFromParams keeps every param as an own key, a __proto__ one included | | /filter | CLOSED_ADD_FLOW, DEFAULT_DATE_PRESETS, DEFAULT_FILTER_SHORTCUTS, EMPTY_FILTER_QUERY, FACET_FOLDED_SIZE, FACET_SHOW_MORE_AFTER, FILTER_ANSWER_GRACE_MS, FILTER_CHIP_ATTRIBUTE, FILTER_DATE_PRESET_IDS, FILTER_FOCUS_SELECTORS, FILTER_LAYER_SELECTOR, FILTER_MAX_VALUE_LENGTH, FILTER_MAX_VALUES, FILTER_OPERATOR_IDS, FILTER_PARAM, FILTER_QUICK_ATTRIBUTE, FILTER_RESULTS_WORDS, FILTER_RESULTS_WORDS_INLINE, FILTER_SECTION_ATTRIBUTE, FILTER_SHEET_HISTORY_KEY, FILTER_SHOW_RESULTS_WORDS, NO_FILTER_ADDED, NO_FILTER_LABELS, PREVIEW_TYPING_DELAY_MS, activeConditions, addableFields, addMenuFields, addedApplied, addedField, addedMessage, addFilterDays, addMenuItems, announcementKey, ariaKeyShortcuts, autocompleteOptions, bandQuickFields, barAnnouncement, booleanChoices, booleanOff, booleanValues, builtInDateRange, checkNumberDraft, chipConditions, chipParts, chipText, clearedMessage, clearQuery, commitNumberDraft, conditionOf, conditionPopoverClose, createFilterReducer, createPreviewRunner, customRenderer, dateOfFilterDay, dateOfFilterTime, datePresetItems, datePresetLabel, datePresetMessage, datePresetRange, defineFilterModel, draftCommit, editableFields, editedQuery, editingAfter, editorHint, effectiveConditions, expireFilterAnswer, facetCounts, facetFoldMessage, facetKind, facetOptions, facetsDraftEvent, facetsStage, fallbackText, fieldDatePreset, fieldDatePresetRange, fieldDatePresets, filterAnswerDue, filterAnswerGraced, filterAnswerGraceKey, filterCountPlural, filterCountText, filterDayOf, filterField, filterFocusElement, filterFormatters, filterMsg, filterOperator, filterQueryKey, filterReducer, filterSearchString, filterShortcutKeys, filterTimeOf, filterToday, filterType, focusAfterAdd, focusAfterClearAll, focusAfterEdit, focusAfterRemove, focusAfterSheet, focusAfterSheetClear, focusFilterTarget, focusSheetOpen, holdBackEntry, initialFilterState, isBooleanField, isBuiltInDatePreset, isComplete, isDatePresetKey, isDraftChanged, isFilterOperatorId, isFilterTypeId, isLayerOpen, isLockedField, isNarrowing, isQuickOn, isQuickToggle, isRangeOperator, isSameQuery, isUnreadableValue, isWideningField, labeledFormatters, labeledQuickText, matchCondition, matchQuery, missingLabels, nextAdded, numberDraft, numberDraftRestarts, numberSentKey, numberValuesKey, numberValuesOfKey, paramsKey, parseFilterQuery, pendingChipParts, plainFilterFormatters, previewTyped, quickFields, quickListFields, quickText, quickToggleEvent, removeLabel, resolveDateCondition, resolveDateConditions, resolveFilterDates, resultsMessage, schoolYear, sectionActiveCount, sectionCondition, sectionEvent, sectionOpen, sectionOperator, serializeFilterQuery, sheetApplyMessage, sheetCanClear, sheetSections, sheetTriggerCount, shortcutFor, shouldAnnounce, shownConditions, sliderBounds, sliderThumbs, titleOperator, truncateValues, unsearchableFields, unsearchableWarning, valueCounts, watchFilterAnswer, withCondition, withCustomEditors, withLabels, withoutCondition, withoutLocked, withSearch and its types (the section's "Types" line) — the list filter's headless core: the model, the URL codec a server loader runs, the state machine and add flow, client-mode matching, chip wording, focus, shortcuts, the phone sheet and the facets (see below) | | /states | EMPTY_STATE_KINDS, ERROR_STATE_KINDS, resolveEmptyStateKind, isErrorKind, stateCopy, STATE_ACTION_KEYS, describeError, STATE_ILLUSTRATIONS, STATE_ACCENT, STATE_ICON and the types EmptyStateKind, ErrorStateKind, StateKind, LegacyEmptyStateVariant, StateFamily, StateCopy, ErrorDetail, StateIllustration, IllustrationShape, IllustrationTone, StateAccent, StateIconName — the kinds, copy keys, error details and illustrations behind EmptyState and ErrorState | | /vite | nxgtMaterial(), pdfkitStandardFonts(), cspAssetsInlineLimit, CSP_NONCE_PLACEHOLDER, ZOD_JITLESS_SCRIPT and the types NxgtMaterialOptions, NxgtMaterialCspOptions, NxgtMaterialLibrary — see below | | /pdfkit-fonts | Node only. pdfkitStandardFontDirs, pdfkitStandardFontFiles, PDFKIT_STANDARD_FONTS, the types PdfkitStandardFont and PdfkitFontsWarn — where the app's pdfkit keeps its standard fonts, for a server tracer | | /data-grid | Headless, the logic of the editable DataGrid of @nxgt/material; nothing renders here. The model (buildGridModel, cellAccess, cellVersion and isNewCellVersion), navigation (navigate, gridCommandFor, whose GridKeyCommand has the members resizeColumn (Alt+ArrowLeft/Right on a header) and selectRow (Shift+Space)), row selection (rowHeaderIndex and GridModel.rowHeaderCol, the first column that is not a control column; GridColumnSpec.control marks a control column such as the selection column; the descriptors selectionCountMessage ("3 of 12 selected"), selectRowLabel and selectAllLabel), the range (extend, selectAll, rangeCells, …), the edit session (editReducer), validation (validateCell, serverErrors, refusalsByCell, Standard Schema), numberCodes, the changes per cell (NO_CHANGES, editCell, editCells for a bulk write, rebase, resolveConflict (and, for a lazy conflict whose resolve fetches their version only on "Keep mine", markConflictFetch and settleConflict), expireSaved, changeCounts over the changes or any entries, blocking), the save queue (IDLE_QUEUE, nextCall, startCall, applyAnswer, applyFailure, applyAbort, retry), undo and redo (EMPTY_HISTORY, record, undo, redo, and forgetEdits for the cells a refused save put back), copy and paste (parseTsv, serializeTsv, planPaste, planFill), the fill handle's series (fillTarget for where a drag ends, seriesValues for the numbers, dates and text a series continues, planSeriesFill and planBlockCopy for the all-or-nothing plan of a series fill and of Ctrl+D and Ctrl+R, seriesLanes (with GridSeriesLane and GridSeriesLanesInput) lane by lane, what a series fill produces before any parse or check, to tell a series from a copy, with the GridSeriesFn hook, the series input that a column's meta.series feeds, and the GridSeriesContext, GridSeriesInput, GridSeriesKind, GridSeriesMode, GridSeriesOut and GridSeriesPlanInput types), the totals (aggregate), the kept sort order (snapshotOrder, reconcileOrder, sortingChanged), the column layout (normalizeLayout, with columns locked to the start or the end through lockPosition, moveColumn, canMoveColumn (whether a column can move at all: not the row header, not a control column, not locked), headIndex (the row header's place among the layout columns, after the control columns), hideColumn, showColumn, clampWidth, sameLayout, the widths DEFAULT_MIN_WIDTH, DEFAULT_MAX_WIDTH and ROW_HEADER_MIN_WIDTH, the column…Message descriptors, the types GridColumnLayout, GridLayoutColumn, GridMoveOptions and GridWidthBounds), the conflict panel's text (conflictValues, conflictSummary, clearedText, unknownTheirsText, conflictFetchFailedText) and the live region's descriptors (savedMessage, cellStateText, the save bar's discardedMessage and blockedMessage, a refused save put back's revertedMessage and refusedRevertedMessage, a paste's and a fill's pasteProblemsMessage, pasteDroppedMessage, fillProblemsMessage, fillDroppedMessage and seriesFilledMessage, …), plus selectOptions, dateCell, isoDay, numberRuleMessages, hasPendingChanges, canUndo, canRedo and sameValue, with the types GridModel, GridColumnSpec, GridRowSpec, GridMsg, GridPos, GridRange, GridVersion, GridCode, GridChanges, GridSaveQueue, GridCall, GridSaveAnswer, GridRefusal, GridConflictAnswer, GridConflictFetched, GridSaveOutcome, GridHistory, GridStep, GridPasteInput, GridPastePlan, GridPasteProblem, GridAggregate and GridSort. A cell's version is 0 or undefined when it does not exist yet on the server. See below | | /parse-number | parseNumberInput, decimalsTyped, clampNumber, stepNumber — a typed number read with , or . as its decimal separator (the Arabic ٫ and ٬ and a Unicode − too), its typed decimals, and the clamp and step behind NumberField |

Usage

The root entry: model, formatters and file helpers

@nxgt/material-core re-exports every document and print subpath that imports no framework, so one import builds a document on a server with no react installed:

import { createFormatters, dataTable, definePrintColumns, heading, pdfDocument, text } from '@nxgt/material-core';

type Line = { item: string; amount: number };

const { formatDate } = createFormatters({ locale: 'fr-FR', timeZone: 'Europe/Paris' });
const lines: Line[] = [{ item: 'Design', amount: 1200 }, { item: 'Hosting', amount: 90 }];

const invoice = pdfDocument({
	language: 'fr',
	pages: [{
		body: [
			heading('Facture 42'),
			text(formatDate('2026-03-01')),
			dataTable(definePrintColumns<Line>([
				{ key: 'item', label: 'Item', flex: 3 },
				{ key: 'amount', label: 'Amount', format: 'amount', total: 'sum' },
			]), lines, { currency: 'EUR' }),
		],
	}],
});

The document is plain JSON: build it where the data is, render it where react and @react-pdf/renderer are installed.

Rendering a document: /pdf/render

import { renderToBuffer } from '@react-pdf/renderer';
import { pdfDocument, text } from '@nxgt/material-core/pdf/model';
import { renderPdfElement } from '@nxgt/material-core/pdf/render';

const bytes = await renderToBuffer(
	renderPdfElement(pdfDocument({ pages: [{ body: [text('Hello')] }] })),
);

@nxgt/material/print and @nxgt/material-vue/pdf wrap this in renderPdfBuffer, renderPdfStream and friends, with the library's translations; use theirs in an app built on either.

Two options of the model worth knowing: a group's own page numbers and a table's row styles.

import { page, pdfDocument, table } from '@nxgt/material-core/pdf/model';

// One entry of `pages` per person: "Page 1 of 2" restarts at each one.
const classFile = pdfDocument({
	pages: students.map((s) =>
		page({
			pageNumbers: { scope: 'group' },
			body: [
				table({
					columns: [{ label: 'Subject' }, { label: 'Mark', align: 'right' }],
					rows: [
						['Maths', 14],
						// A bold, tinted row with a rule above it; a cell's style wins over its row's.
						{ cells: ['Average', 14], style: { fontWeight: 'bold', backgroundColor: 'rowAlt', borderTop: true } },
					],
				}),
			],
		}),
	),
});

scope: 'group' is also on the pageNumber node, for a footer. See the printing guide for every option.

Model and renderer in one import: /pdf

/pdf is /pdf/model and /pdf/render together, for code that builds and renders in the same place. It needs the optional peers, like /pdf/render.

import { pdf } from '@react-pdf/renderer';
import { heading, pdfDocument, renderPdfElement } from '@nxgt/material-core/pdf';

const blob = await pdf(
	renderPdfElement(pdfDocument({ pages: [{ body: [heading('Report')] }] })),
).toBlob();

Serving a rendered PDF: /pdf/http

pdfBytesResponse wraps bytes in a Web Response with Content-Type, Content-Length and a Content-Disposition every client reads; toWebStream turns react-pdf's Node stream into a Web ReadableStream. Both run in any handler that returns a Response (Hono, a React Router loader, Bun.serve, …).

import { renderToBuffer, renderToStream } from '@react-pdf/renderer';
import { pdfDocument, text } from '@nxgt/material-core/pdf/model';
import { renderPdfElement } from '@nxgt/material-core/pdf/render';
import { contentDisposition, pdfBytesResponse, toWebStream } from '@nxgt/material-core/pdf/http';

const doc = pdfDocument({ pages: [{ body: [text('Invoice 42')] }] });

// Buffered: the whole file, with its length.
export async function download(): Promise<Response> {
	// A copy: a Node Buffer's `buffer` may be shared, a Response's may not.
	const bytes = new Uint8Array(await renderToBuffer(renderPdfElement(doc)));
	return pdfBytesResponse(bytes, {
		fileName: 'INV-42', // `.pdf` is added: INV-42.pdf
		disposition: 'inline', // 'attachment' by default
		headers: { 'Cache-Control': 'no-store' },
	});
}

// Streamed: no length, the headers are yours.
export async function stream(): Promise<Response> {
	const source = await renderToStream(renderPdfElement(doc));
	return new Response(toWebStream(source), {
		headers: {
			'Content-Type': 'application/pdf',
			'Content-Disposition': contentDisposition('inline', 'INV-42.pdf'),
		},
	});
}

Rendering to an object URL: /pdf/file-loader

createPdfFileLoader is the state machine behind usePdfFile (React) and usePdf (Vue), without the framework: it renders with the function you give it, keeps one object URL alive at a time, revokes the one it replaces, and drops a render that a newer load overtook. Use it to write the same hook in another framework; it does not serve a PDF.

import { pdf } from '@react-pdf/renderer';
import { createPdfFileLoader, IDLE_PDF_FILE, type PdfFileSnapshot } from '@nxgt/material-core/pdf/file-loader';
import { type PdfDocument, pdfDocument, text } from '@nxgt/material-core/pdf/model';
import { renderPdfElement } from '@nxgt/material-core/pdf/render';

let file: PdfFileSnapshot = IDLE_PDF_FILE; // { blob, url, loading, error }

const loader = createPdfFileLoader<PdfDocument>(
	(next) => { file = next; }, // called on every change
	(doc) => pdf(renderPdfElement(doc)).toBlob(),
);

await loader.load(pdfDocument({ pages: [{ body: [text('Preview')] }] }));
console.log(file.url); // blob:… — for an <iframe>, a reader or a link
await loader.load(null); // back to IDLE_PDF_FILE, URL revoked
loader.dispose(); // on unmount: revokes the URL, ignores renders in flight

Formatting values: /print-format, /print-value, /print-table, /print-fraction-digits

Plain Intl formatting, no framework: amounts, dates, typed print values and tables resolved from column definitions.

import { createFormatters, formatAmount } from '@nxgt/material-core/print-format';
import { fractionDigitsOption } from '@nxgt/material-core/print-fraction-digits';
import { definePrintColumns, resolvePrintTable } from '@nxgt/material-core/print-table';
import { formatPrintValue } from '@nxgt/material-core/print-value';

formatAmount(1234.5, 'EUR', { locale: 'fr-FR' }); // '1 234,50 €'
formatAmount(1234.5, 'JPY', { locale: 'en-US' }); // '¥1,235'
fractionDigitsOption(Number.NaN); // undefined: the currency's own decimals apply

const { formatDate } = createFormatters({ locale: 'en-US', timeZone: 'Europe/Paris' });
formatDate('2026-03-01'); // 'Mar 1, 2026' — a date-only value keeps its day

type Line = { item: string; amount: number };
const table = resolvePrintTable(
	definePrintColumns<Line>([
		{ key: 'item', label: 'Item' },
		{ key: 'amount', label: 'Amount', format: 'amount', total: 'sum' },
	]),
	[{ item: 'Design', amount: 1200 }],
	{ currency: 'EUR' },
);
table.rows.map((row) => row.map((cell) => formatPrintValue(cell, { locale: 'en-US' })));
// [['Design', '€1,200.00']]

PDF files in a browser: /pdf-source, /pdf-file-name, /pdf-download

A PdfSource is what both libraries' PdfReader opens: a URL, a Blob, an ArrayBuffer, a Uint8Array or { url, httpHeaders }. These helpers name it, key it, save it and print it.

import { downloadPdfFile, printPdfFile } from '@nxgt/material-core/pdf-download';
import { resolvePdfFileName } from '@nxgt/material-core/pdf-file-name';
import { type PdfSource, pdfSourceKey } from '@nxgt/material-core/pdf-source';

const source: PdfSource = { url: '/api/invoices/42.pdf', httpHeaders: { Authorization: 'Bearer …' } };

resolvePdfFileName(source); // '42.pdf' — fileName, then the File's name, then the URL
pdfSourceKey(source); // a stable key, to tell when the source changed

await downloadPdfFile(source, { fileName: 'Invoice 42' }); // saves 'Invoice 42.pdf'
await printPdfFile(source); // prints from a hidden frame, or opens a tab

downloadPdfFile and printPdfFile touch document: call them from a click handler, never at import or on a server.

The PDF viewer's pure half: /pdf-viewer

What both libraries' PdfReader computes without pdf.js or a framework: the zoom that fits a page, a page's placeholder size, where a search matches in a page's text. It takes pdf.js's objects by shape (numPages, getPage, getTextContent), so it never imports pdf.js itself.

import { findTextMatches, pageTextFromItems, placeholderSize, readerFitScale } from '@nxgt/material-core/pdf-viewer';

const letter = { width: 612, height: 792 }; // PDF points
readerFitScale('width', [letter], 0, { width: 1000, height: 800 }); // 1.58…
placeholderSize(letter, 1.5, 90); // { width: 1188, height: 918 } — turned

const page = pageTextFromItems([{ str: 'Café au', hasEOL: true }, { str: 'lait' }]);
findTextMatches(page.text, 'cafe au lait'); // [{ start: 0, end: 12 }] — case, accents and line breaks ignored

To open a reader on a page, pinToPage(node, beforeScroll, onRelease?) keeps that page at the top of the page area (an element inside the [data-slot="scroll-area-viewport"] of a [data-slot="scroll-area"]) while the pages above it change size, until a wheel, touch, pointer or key in the page area, or the function it returns. beforeScroll runs before each correction (hold your scroll spy there); onRelease runs once when the pin lets go — at once when there is nothing to pin, or no ResizeObserver (a server). pinHolds(node) tells whether the view still shows node where the pin puts it: at the top, or as near as the area can scroll at its end. A scroll that leaves it elsewhere came from find in page, a screen reader's cursor or your own scrollTop, none of which the pin hears: treat it as the user's. Such a scroll can land within the hold a correction starts, which ignores it: give createScrollSpy the option readerPosition, which says whether the view is still where the reader put it, and the spy measures once, two frames after release (past a resize's correction), when a held scroll left it elsewhere. Its fourth argument is { idleMs?, readerPosition? }; a bare number is still idleMs. Once such a scroll moved the view, up or down, the pin makes no further correction (it would scroll back to the page): it tells a scroll it did not make by a scrollTop other than the one its last correction left, with the page off the top; the browser anchoring the scroll on the page, or clamping it at the end as the pages above shrink, is not taken for one. It does not let go itself then, and onRelease does not run: call the function it returned once your spy reports the view. Keep each page's room (a min-height) until it has drawn, at each new scale or rotation too: a page list that shrinks for a moment has its scroll clamped up, which the pin would take for such a scroll.

import {
  createScrollSpy,
  pinHolds,
  pinToPage,
} from '@nxgt/material-core/pdf-viewer';

// pages: Map<number, HTMLElement>
let pinned = true;
const spy = createScrollSpy(
  () => area, // the reader's page area
  () => pages,
  onScroll,
  // still where the reader put it
  { readerPosition: () => !pinned || pinHolds(pages.get(3)) },
);
const unpin = pinToPage(
  () => pages.get(3), // the page's element, looked up again on each correction
  () => spy.hold(), // the corrections are not the user's scrolling
  () => { pinned = false; }, // the pin let go
);

function onScroll(page: number) {
  if (pinned && pinHolds(pages.get(3))) return; // still the reader's own scroll
  unpin(); // the view moved elsewhere: let go, then report
  reportPage(page);
}

pageInView(area, pages) is the spy's own measure, on demand: the page most on screen, the last one in view once the area is scrolled to its end, or null when no page is in view.

const page = pageInView(area, pages); // 3

pdf.js draws a link annotation as an empty <a>, which axe flags (link-name). labelLinkAnchors gives each such anchor in an annotation layer an aria-label; linkAnnotationLabel is the pure naming rule and resolveDestinationPage tells the page an internal destination lands on. An anchor that already has text or an aria-label is left alone, and its own title is kept in the name (goToPageTitled). Pass the labels already translated; the names it sets carry data-nxgt-link-label, so calling it again with new labels (another language) renames them.

import { labelLinkAnchors, resolveDestinationPage } from '@nxgt/material-core/pdf-viewer';

await labelLinkAnchors(
  pageElement, // holds the `.annotationLayer`
  (id) => annotations.get(id), // pdf.js annotation by id: { url?, dest? }
  (dest) => resolveDestinationPage(pdf, dest),
  {
    external: (host) => `Link: ${host}`,
    goToPage: (n) => `Go to page ${n}`,
    goToPageTitled: (n, title) => `Go to page ${n}: ${title}`,
    internal: 'Link in document',
  },
);

The PDF reader's engine: /pdf-reader

Experimental, and used by PdfReader internally: the engine under the new reader, its API still free to change. pdf.js is handed in by shape (it satisfies PdfjsLike), so core never imports it and a server never evaluates it. pdfjs-dist, its worker and its assets (cmaps/, standard_fonts/ and wasm/ under assetsUrl) are the application's to install and serve; they are not peers of core.

import {
  classifyPdfError, configurePdfReader, openPdfDocument, planRender,
} from '@nxgt/material-core/pdf-reader';

configurePdfReader({ assetsUrl: '/pdfjs', workerSrc: '/pdf.worker.min.mjs' });
const pdfjs = await import('pdfjs-dist'); // satisfies PdfjsLike
const controller = new AbortController();
try {
  const { doc, numPages, destroy } = await openPdfDocument(pdfjs, { url: '/a.pdf' }, {
    onPassword: (submit, reason) => submit(promptPassword(reason === 'wrong')),
    signal: controller.signal,
  });
  const page = await doc.getPage(1);
  console.log(numPages, page.getViewport({ scale: 1 }));
  await destroy();
} catch (error) {
  classifyPdfError(error).kind; // 'password-required' | 'invalid' | 'missing' | 'network' | 'canvas' | …
}
planRender({ cssWidth: 612, cssHeight: 792, devicePixelRatio: 3 }); // canvas size under MAX_CANVAS_PIXELS

createPageCache (an LRU of rendered pages with an eviction callback, its capacity counted in entries, or in whatever a size function measures, such as pixels), renderPageToCanvas (cancels the pdf.js task on abort; throws CanvasUnavailableError when the browser gives no 2D context) and mountedPages (which rows to mount for a scroll position) complete it. The most recently used entry always stays, even when it alone is over the budget.

import { createPageCache } from '@nxgt/material-core/pdf-reader';

const free = (canvas: HTMLCanvasElement) => {
  canvas.width = 0; // releases the backing store now (iOS caps the total)
  canvas.height = 0;
};
// At most 8M pixels of canvases, the least recently used evicted first.
const cache = createPageCache<HTMLCanvasElement>(
  8_000_000,
  free,
  (c) => c.width * c.height,
);
cache.set({ page: 1, scale: 1.5, rotation: 0 }, canvas);
cache.get({ page: 1, scale: 1.5, rotation: 0 }); // the canvas, now most recent

openPdfDocument takes pdf.js's own parameters (PdfjsSourceParams): { url, httpHeaders, withCredentials } for a file behind a login, { data } for bytes (toPdfjsParams from /pdf-source builds them from a URL, a Blob or bytes). Its handle's doc is typed after the pdf.js handed in, so pdfjs-dist's PDFDocumentProxy comes back whole. A byte source is transferred to the worker: pass a copy to keep the buffer.

const { doc } = await openPdfDocument(pdfjs, {
  url: '/api/invoices/42.pdf',
  httpHeaders: { Authorization: `Bearer ${token}` },
  withCredentials: true,
});
doc.getMetadata(); // pdfjs-dist's PDFDocumentProxy, typed

The page column. layoutSpreads with mode: 'single' stacks the pages (sizes already zoomed and turned) a gap apart, one page a row; pageAtOffset says which page a scroll position is on (the first page of the row most on screen); anchorAt and offsetOf turn a scroll offset into a page and a fraction of its row, and back, so a zoom puts the same place on screen.

import {
  anchorAt, layoutSpreads, offsetOf, pageAtOffset,
} from '@nxgt/material-core/pdf-reader';

const column = { mode: 'single', gap: 16 } as const;
const before = layoutSpreads(sizesAt(1), column);
const anchor = anchorAt(before, scroller.scrollTop); // { page: 3, fraction: 0.4 }
const after = layoutSpreads(sizesAt(1.5), column);
scroller.scrollTop = offsetOf(after, anchor); // page 3, 40% down, again
pageAtOffset(after, { top: scroller.scrollTop, height: 800, atEnd: false }); // 3

Two pages. layoutSpreads lays the same pages out in rows: mode: 'single' is the column above, 'spread' puts two pages side by side, and 'spread-cover' leaves the first page alone, then pairs, as in a book. dir: 'rtl' puts the first page of a pair on the right. The layout gives each page its tops, lefts, widths and heights, and its rows ({ top, height, pages }, pages left to right). gap is between rows, columnGap between the two pages of a row (default gap), dir defaults to ltr. A last odd page sits where the first of a pair would (on the left in ltr, on the right in rtl), and the cover on the right in ltr and on the left in rtl, facing the first pair. A document with no pair at all (one page, or two with a cover) is always a column.

pageAtOffset, anchorAt and offsetOf work per row: an anchor is a page and a fraction of its row, and anchorAt(layout, offset, x) takes the page of the pair under x (the lower number on a tie). Switching layout keeps that fraction of the row, so with pages of mixed heights the view shifts slightly.

import { anchorAt, layoutSpreads, offsetOf } from '@nxgt/material-core/pdf-reader';

const options = { mode: 'spread-cover', gap: 16, columnGap: 8, dir: 'rtl' } as const;
const before = layoutSpreads(sizesAt(1), options);
before.rows.map((row) => row.pages); // [[1], [3, 2], [5, 4]]
const anchor = anchorAt(before, scroller.scrollTop, viewMiddleX);
scroller.scrollTop = offsetOf(layoutSpreads(sizesAt(1.5), options), anchor);

Links. createPdfLinkService is what pdf.js's AnnotationLayer takes as linkService: an internal link goes to its page through your goToPage (pages not drawn yet included), an external one opens where externalLinkTarget says, with rel set to PDF_LINK_REL. Only http(s), ftp, mailto: and tel: links get an href; a javascript: or data: one stays inert, and named actions and scripts never run.

import { createPdfLinkService } from '@nxgt/material-core/pdf-reader';

const linkService = createPdfLinkService({
  doc, // the open document
  goToPage: (page) => reader.goToPage(page),
  externalLinkTarget: '_blank',
});
new pdfjs.AnnotationLayer({ div, page, viewport, linkService, /* … */ });

Search highlight. applyPageHighlight marks a page's search matches in the text layer pdf.js drew for it. Its PdfPageSearch holds the page's text as the document search read it (searchDocument in /pdf-viewer), the matches found in that text, the current match's rank on this page (-1 when the current match is on another page) and an optional query, marked span by span while the document's matches are not known yet; with neither, it clears the marks. The text is escaped: a document's text never becomes markup.

import { applyPageHighlight } from '@nxgt/material-core/pdf-reader';

const layer = new pdfjs.TextLayer({ textContentSource, container, viewport });
await layer.render();
applyPageHighlight(
  { divs: layer.textDivs, strs: layer.textContentItemsStr },
  { text: pageText, matches: pageMatches, current: 0, query: 'invoice' },
);
// Each match is a <mark class="pdf-highlight">; the current one has data-current.

A currency's decimals: /currency-digits

import { currencyFractionDigits } from '@nxgt/material-core/currency-digits';

currencyFractionDigits('EUR'); // 2
currencyFractionDigits('JPY'); // 0
currencyFractionDigits('KWD'); // 3
currencyFractionDigits('EUR', 0); // 0 — an explicit value wins, as in formatAmount

A file's size: /format-file-size

1024-based, at most one decimal and no trailing zero, capped at GB, locale-free by default: a server and a browser print the same string. What both libraries' FileList, NetworkUploadField (and its size limit message), FileCard and chat attachments show; both re-export it as formatFileSize.

import { formatFileSize } from '@nxgt/material-core/format-file-size';

formatFileSize(0); // '0 B'
formatFileSize(512); // '512 B'
formatFileSize(1536); // '1.5 KB'
formatFileSize(5 * 1024 ** 2); // '5 MB' — at most one decimal, never a trailing zero
formatFileSize(2.4 * 1024 ** 2); // '2.4 MB'
formatFileSize(-1); // '' — as for NaN and Infinity
formatFileSize(2.4 * 1024 ** 2, { locale: 'fr' }); // '2,4 Mo' — the language's decimal mark and unit, no trailing zero ('900 ko', and 2 MB gives '2 Mo'; a language that writes the unit as English does, 'de' for one, keeps 'MB')

A slider's tick marks: /slider-ticks

The values both libraries' SliderField (ticks) draws its marks at, from min to max inclusive and evenly spaced: false gives none, a number that many, true one per step (five without one). Any count is kept between 2 and 21 marks, except when min === max: then there is one mark, at min.

import { tickValues } from '@nxgt/material-core/slider-ticks';

tickValues(0, 100, false); // []
tickValues(0, 100, 5); // [0, 25, 50, 75, 100]
tickValues(0, 10, true, 5); // [0, 5, 10]
tickValues(0, 100, true, 1).length; // 21 — capped
tickValues(5, 5, true); // [5] — min === max

A debounced call: /debounce

The debounce both libraries' SearchField (debounceMs) and Autocomplete use: the wrapped function runs ms after the last call, with the last arguments. cancel() drops a pending call, flush() runs it now.

import { debounce } from '@nxgt/material-core/debounce';

const save = debounce((text: string) => console.log('save', text), 300);
save('a');
save('ab'); // 300 ms later: "save ab", once
save.flush(); // or now

An avatar's initials: /initials

What both libraries' AccountMenu shows in its avatar when there is no picture: the first letter of the first and of the last word, upper-cased, a whole code point each.

import { initials } from '@nxgt/material-core/initials';

initials('Ada Lovelace'); // 'AL'
initials('Ada'); // 'A'
initials('   '); // ''

Days relative to today: /day-math

What DaySeparator and DayGroupedList (and so Notifications) share: daysAgo(value, now?) counts local calendar days (not 24-hour blocks, so a DST day is still one), and dayBucket(ago) names the bucket: future, today, yesterday, week (2 to 6 days ago, a rolling week that ignores the locale's week start) or earlier. The wording stays in each component.

import { dayBucket, daysAgo } from '@nxgt/material-core/day-math';

dayBucket(daysAgo(new Date(2026, 9, 13, 23, 59), new Date(2026, 9, 14, 0, 1))); // 'yesterday'

Matching text as the user types: /text-match

foldText(text, locale?) lowercases text for locale (the runtime's when omitted; an invalid tag falls back to it), then strips its diacritics (normalize('NFD'), combining marks removed). matchesText(text, query, locale?) says whether the folded, trimmed query occurs in the folded text; a blank query matches everything. Both libraries' Autocomplete filters its local options with it, in the provider's language. matchRange(text, query, locale?) says where: the [start, end) offsets of the first match in text itself (a decomposed accent stays with its letter), or null with no match or a blank query; React's ComboboxField marks the match with it. matchRanges(text, query, locale?) gives every occurrence, under the same rule, as [start, end) offsets into the original text (so a caller can wrap the characters as written in a highlight); a range takes in the combining marks after it, ranges never overlap, and a blank query has none.

import { foldText, matchesText, matchRange, matchRanges } from '@nxgt/material-core/text-match';

foldText('Élève'); // 'eleve'
foldText('İSTANBUL', 'tr'); // 'istanbul'
matchesText('Orléans', 'orle'); // true
matchesText('Cafe', 'café'); // true — both ways
matchesText('Lyon', '   '); // true — a blank query keeps everything
matchRange('École', 'eco'); // [0, 3] — 'Éco'
matchRange('Lyon', 'paris'); // null
matchRanges('École', 'ecol'); // [[0, 4]]
matchRanges('Café au café', 'cafe'); // [[0, 4], [8, 12]]

Letters that are not a base letter plus a mark stay as they are: ß, ø and æ do not fold to ss, o or ae.

A search that never answers out of order: /async-search

createAsyncSearch(config, onChange) runs config.search(query, { signal }) once request(query) calls pause for debounceMs (250 by default). Each run aborts the one before it, and a response that still lands for an older run is dropped, so a slow first query never overwrites a faster second one; an abort is never reported as an error. A query shorter than minQueryLength (1 by default, trimmed) searches nothing and resets the state to idle. onChange receives each state: { status: 'idle' | 'loading' | 'success' | 'error', query, results, error? }, the last results kept while the next query loads.

import { createAsyncSearch } from '@nxgt/material-core/async-search';

type City = { value: string; label: string };

const search = createAsyncSearch<City>(
	{
		search: (query, { signal }) =>
			fetch(`/api/cities?q=${encodeURIComponent(query)}`, { signal }).then((r) => r.json()),
		minQueryLength: 2,
	},
	(state) => render(state),
);

input.addEventListener('input', () => search.request(input.value));
// On teardown: search.cancel() drops the pending search and aborts the one in flight.

configure(partial) swaps the search, the debounce or the minimum length (React re-reads the latest props through it), flush() runs the pending search now, and requesting the query the state already holds (or is loading) does nothing. searchAnnouncement({ loading, failed, settled, count }) is what a combobox's polite live region says: the failure, else "loading", else the count once the query has settled — null while the user is still typing.

An open list above a Drawer's footer: /drawer-room

drawerRoom(element, top) looks for the [data-slot="drawer-content"] around element and answers how many pixels a floating list that starts at top (a viewport coordinate) may take before it reaches the Drawer's [data-slot="drawer-footer"], or the Drawer's bottom edge without a footer, or the window's. It keeps an 8px gap and never answers less than 80, so the list stays usable. Outside a Drawer it answers null: the list keeps its own height.

import { drawerRoom } from '@nxgt/material-core/drawer-room';

const room = drawerRoom(field, field.getBoundingClientRect().bottom + 6);
list.style.maxHeight = room == null ? '' : `${room}px`;

Measure again when the field or the Drawer resizes or scrolls.

One submit in flight: /submit-guard

createSubmitGuard() returns { once, settle, pendingRun }. once(run) wraps a handler so it runs only when no submit is on its way: a second click, or Enter, before the first request ends is dropped (its event, if it carries one, has its default prevented, so the browser does not submit the form on its own). settle() opens the guard again; a run that returns a promise opens it when that promise ends, fulfilled or rejected, and a run that throws opens it and rethrows. Any other result keeps it closed until settle() — unless once(run, { settleSync: true }), which opens it at once. So: return the request's promise, or follow the request with a busy flag. The wrapper returns run's result — its promise as one that settles the same way, so a rejection still reaches the caller.

pendingRun() is the run on its way (an increasing number, undefined when open), and settle(run) opens the guard only while that run is still the one on its way. busySettler(guard) uses them: call the function it returns with each value of a busy flag, and once the flag was true and is false again it settles the run that was on its way when the flag rose — never a newer one.

import { createSubmitGuard } from '@nxgt/material-core/submit-guard';

const guard = createSubmitGuard();
const submit = guard.once((event: SubmitEvent) => {
	event.preventDefault();
	return fetch('/api/subscribe', { method: 'POST', body: new FormData(form) });
});
form.addEventListener('submit', submit); // a second submit before the reply is dropped

Both libraries wrap it as useSubmitOnce(busy?): busy is a boolean in React (useSubmitOnce(navigation.state !== 'idle')) and a ref or a getter in Vue (useSubmitOnce(() => pending.value)), followed through busySettler. Without busy, the hooks pass settleSync: true, so a handler that returns early does not lock the form.

Errors worded by code: /code-errors

createCodeErrorMap({ translate, codeOf?, fallback }) is a zod 4 error map (typed by shape: this package does not depend on zod). An issue's code — by default defaultCodeOf: a custom issue's params.code, when it is a string — is worded by translate(code, params); when there is no code, or translate returns undefined or the code itself (a translator's answer for a key it lacks), fallback(issue) words it. isEmptyRequired(issue) tells an empty required field (a string shorter than one character, or a missing value), to give it a code of its own.

import { z } from 'zod';
import {
	createCodeErrorMap,
	defaultCodeOf,
	isEmptyRequired,
} from '@nxgt/material-core/code-errors';

const words: Record<string, string> = {
	'errors.taken': 'This name is already taken.',
	'errors.required': 'Please fill this in.',
};
const error = createCodeErrorMap({
	translate: (code) => words[code],
	codeOf: (issue) => (isEmptyRequired(issue) ? 'errors.required' : defaultCodeOf(issue)),
	fallback: () => undefined, // zod's own message
});

const schema = z.object({
	username: z.string().min(1).refine((name) => name !== 'admin', { params: { code: 'errors.taken' } }),
});
schema.safeParse({ username: 'admin' }, { error }); // "This name is already taken."

Both libraries wrap it as useCodeResolver(schema), with their zodLocaleError as the fallback and the page's language.

Two-step verification: /two-factor

groupSecret(secret, size = 4) reads a TOTP secret in groups, as a person types it into an authenticator app ("JBSWY3DPEHPK3PXP" → "JBSW Y3DP EHPK 3PXP"); whitespace already in it is dropped first. recoveryCodesText(codes, { heading?, note? }) is the text file a person keeps: the heading, the note, a blank line, one code per line and a trailing newline (a line not given is left out). downloadText(text, fileName, { type? }) saves it through an object URL and a temporary <a download>, revoked a minute later, as /pdf-download does (its OBJECT_URL_TTL_MS).

import {
	downloadText,
	groupSecret,
	recoveryCodesText,
} from '@nxgt/material-core/two-factor';

groupSecret('JBSWY3DPEHPK3PXP'); // "JBSW Y3DP EHPK 3PXP"
const text = recoveryCodesText(['7KQ2-M9XD', 'P4TW-8HZC'], {
	heading: 'Recovery codes for [email protected]',
	note: 'Each code works once.',
});
downloadText(text, 'recovery-codes.txt');

Both libraries' EnrolmentSecret and RecoveryCodes are built on them.

Printing one element: /print-only

printOnly(element, name?) prints that element and nothing else of the page: it sets data-nxgt-print="<name>" on <html> and data-nxgt-print-target on the element, calls window.print(), and removes both on afterprint, or as soon as print() returns where it blocks until the dialog closes. It returns a function that removes them at once; call it when the element unmounts. No inline style and no new window: the hiding is the libraries' stylesheet (print-only.css, in their styles.css), which under print media and html[data-nxgt-print] shows only the target.

import { printOnly } from '@nxgt/material-core/print-only';

const stop = printOnly(document.querySelector('#codes') as HTMLElement, 'recovery-codes');
// when the element unmounts:
stop();

Both libraries re-export it from /lib (@nxgt/material/lib, @nxgt/material-vue/lib); RecoveryCodes' Print button uses it.

The emoji list's groups: /emoji-groups

How both libraries' EmojiList groups, searches and names its emojis: groupKey turns a catalog group into a key, groupEmojis builds the tabs (all, recent, then one per group), searchEmojis filters by name or shortcode, groupName gives a key's English name (a key it does not know reads as its words).

import { groupName, searchEmojis } from '@nxgt/material-core/emoji-groups';

groupName('people_body'); // 'People & body'
groupName('hand_signs'); // 'Hand signs'
searchEmojis([{ name: 'fire' }], 'FIR'); // [{ name: 'fire' }]

A date range's presets and times: /date-range

The pure half of both libraries' DateRangeField (presets, withTime / with-time): the built-in presets' ranges, whether a field's rules rule a range out, and the time of day on each end. Plain Date maths in the local time zone; nothing reads the clock unless you leave now out. Both libraries re-export DATE_RANGE_PRESET_IDS and the preset types.

import {
	clampRangeEnd,
	isRangeDisabled,
	presetRange,
	resolvePresetRange,
} from '@nxgt/material-core/date-range';

const now = new Date(2026, 2, 18, 15, 30); // Wednesday 18 March 2026

presetRange('last-7-days', now); // 12 → 18 March, both at 00:00 (today counts)
presetRange('this-month', now); // 1 → 18 March
presetRange('last-month', now); // 1 → 28 February
resolvePresetRange('today', now, { withTime: true }); // 18 March 00:00 → 23:59
resolvePresetRange({ label: 'Q1', range: (n) => ({ from: new Date(n.getFullYear(), 0, 1), to: new Date(n.getFullYear(), 2, 31) }) }, now);

// Ruled out when its end comes before its start, it starts before `min`,
// ends after `max`, or either end is a disabled day; the days between are
// not checked.
isRangeDisabled(presetRange('yesterday', now), { min: now }); // true

// On the same day, an end before the start becomes the start.
clampRangeEnd({ from: new Date(2026, 2, 18, 14), to: new Date(2026, 2, 18, 9) }); // to: 14:00

keepRangeTimes(next, previous) gives days picked on a calendar the times previous had at each end (a new start 00:00, a new end 23:59), then clamps the end; withTimeOf(day, time) and endOfDayTime(day) set one end's time; isSameDayRange(a, b) compares two ranges by their days.

The page's CSP nonce: /csp-nonce

Under a strict Content Security Policy (style-src-elem 'nonce-X'), a <style> element created at runtime is blocked unless it carries the page's nonce. getCspNonce() finds it, from the first of: the value given to setCspNonce, <meta property="csp-nonce" nonce="…"> (what Vite's html.cspNonce writes) or <meta name="csp-nonce" content="…">, the first <script nonce> or <style nonce> of the page, then globalThis.__webpack_nonce__. It reads an element's nonce property, since browsers hide the attribute's value once the page is parsed. createNoncedStyle() returns a new, unattached <style> that carries it.

import { createNoncedStyle, setCspNonce } from '@nxgt/material-core/csp-nonce';

// Nothing to set when the page carries Vite's tag (html.cspNonce):
//   <meta property="csp-nonce" nonce="…">
// With a server-rendered nonce, set it in entry.client.ts before the first
// component renders:
setCspNonce(nonceFromTheServer);

const style = createNoncedStyle();
style.textContent = '.spin { animation: spin 1s linear infinite }';
document.head.append(style);
  • A content attribute is not hidden from CSS selectors, so prefer the nonce attribute.
  • Browser only. Without a document (a server), setCspNonce does nothing and getCspNonce returns undefined: a nonce belongs to one request, and a module global would hand it to the next. Pass it down the request instead. createNoncedStyle() with no document needs an explicit doc argument, otherwise it throws. An SSR setup that installs a global document (jsdom or domino) makes setCspNonce a global shared by every request, so do not call it on the server.
  • The value is kept on globalThis under Symbol.for('nxgt.cspNonce'), so two copies of this package in one bundle share it.
  • For a provider component (the libraries' NonceProvider): call claimCspNonce(nonce, depth) once mounted, with how many providers enclose it, and the function it returns on unmount. The deepest claim held is the stored nonce, so nested providers stay right, and releasing the last one puts back what the app had set with setCspNonce.
  • getNonce and setNonce are the same two functions under get-nonce's names, so a bundler alias can point that package here.

The CSP for a server or a build: /csp

/csp-nonce reads the nonce in a browser; /csp is for the code that chooses it: an SSR request handler, a build script, a Vite plugin. It uses only globalThis.crypto, btoa and strings, so it runs on Node, Bun, Deno, workers and browsers, and keeps no state.

import {
  applyCspNonce,
  createCspNonce,
  cs